Optimise dropdowns for accessibility - Post comments and share (#13868)

* WIP - basic init of comment dropdowns with open and close on click

* WIP - initialize the share dropdown

* initialize all post dropdowns within packs, init copy to clipboard announcer

* refactor and add JSDocs to helper

* undo changes to base jsx

* update accessible name of post actions button in cypress test

* make sure dropdowns pack loaded on comment index page

* undo prettier changes in base jsx

* undo prettier changes in base jsx

* initialize comment dropdowns in podcasts

* add test for the post actions

* add article comment tests

* add cypress tests for comment dropdowns
This commit is contained in:
Suzanne Aitchison 2021-06-03 09:13:17 +01:00 committed by GitHub
parent e6e99e902b
commit 7e322e4f7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 509 additions and 228 deletions

View file

@ -3,7 +3,7 @@
initializeAllChatButtons, initializeAllTagEditButtons, initializeUserFollowButts,
initializeBaseTracking, initializeCommentsPage,
initializeArticleDate, initializeArticleReactions, initNotifications,
initializeCommentDate, initializeCommentDropdown, initializeSettings,
initializeCommentDate, initializeSettings,
initializeCommentPreview, initializeRuntimeBanner,
initializeTimeFixer, initializeDashboardSort, initializePWAFunctionality,
initializeEllipsisMenu, initializeArchivedPostFilter, initializeCreditsPage,
@ -24,7 +24,6 @@ function callInitializers() {
initializeArticleReactions();
initNotifications();
initializeCommentDate();
initializeCommentDropdown();
initializeSettings();
initializeCommentPreview();
initializeTimeFixer();

View file

@ -1,175 +0,0 @@
/* global Runtime */
function initializeCommentDropdown() {
const announcer = document.getElementById('article-copy-link-announcer');
function removeClass(className) {
return (element) => element.classList.remove(className);
}
function getAllByClassName(className) {
return Array.from(document.getElementsByClassName(className));
}
function showAnnouncer() {
const { activeElement } = document;
const input =
activeElement.localName === 'clipboard-copy'
? activeElement.getElementsByTagName('input')[0]
: document.getElementById('article-copy-link-input');
input.focus();
input.setSelectionRange(0, input.value.length);
announcer.hidden = false;
}
function hideAnnouncer() {
if (announcer) {
announcer.hidden = true;
}
}
function copyPermalink(event) {
event.preventDefault();
const permalink = event.target.href;
Runtime.copyToClipboard(permalink).then(() => {
// eslint-disable-next-line no-undef
addSnackbarItem({ message: 'Copied to clipboard' });
});
}
function copyArticleLink() {
const inputValue = document.getElementById('article-copy-link-input').value;
Runtime.copyToClipboard(inputValue).then(() => {
showAnnouncer();
});
}
function shouldCloseDropdown(event) {
var copyIcon = document.getElementById('article-copy-icon');
var isCopyIconChild = copyIcon && copyIcon.contains(event.target);
return !(
event.target.matches('.dropdown-icon') ||
event.target.parentElement.classList.contains('dropdown-icon') ||
event.target.matches('.dropbtn') ||
event.target.matches('clipboard-copy') ||
isCopyIconChild ||
event.target.parentElement.classList.contains('dropdown-link-row')
);
}
function removeClickListener() {
// disabling this rule because `removeEventListener` needs
// a reference to the specific handler. The function is hoisted.
// eslint-disable-next-line no-use-before-define
document.removeEventListener('click', outsideClickListener);
}
function removeCopyListener() {
const clipboardCopyElement = document.getElementsByTagName(
'clipboard-copy',
)[0];
if (clipboardCopyElement) {
clipboardCopyElement.removeEventListener('click', copyArticleLink);
}
}
function removeAllShowing() {
getAllByClassName('crayons-dropdown').forEach(removeClass('block'));
}
function outsideClickListener(event) {
if (shouldCloseDropdown(event)) {
removeAllShowing();
hideAnnouncer();
removeClickListener();
}
}
function initializeDropDownClick(dropdownOrDropdownContainer) {
return (event) => {
const { target } = event;
const button = (function getButton(potentialButton) {
while (
!potentialButton.classList.contains('dropbtn') ||
!potentialButton
) {
if (potentialButton === dropdownOrDropdownContainer) {
break;
}
potentialButton = potentialButton.parentElement;
}
return potentialButton;
})(target);
const dropdownContent = button.parentElement.querySelector(
'.crayons-dropdown',
);
if (!dropdownContent) {
return;
}
// Android native apps have enhanced sharing capabilities for Articles
const articleShowMoreClicked = button.id === 'article-show-more-button';
if (articleShowMoreClicked && Runtime.isNativeAndroid('shareText')) {
AndroidBridge.shareText(location.href);
return;
}
finalizeAbuseReportLink(
dropdownContent.getElementsByClassName('report-abuse-link-wrapper')[0],
);
if (dropdownContent.classList.contains('block')) {
dropdownContent.classList.remove('block');
removeClickListener();
removeCopyListener();
hideAnnouncer();
} else {
removeAllShowing();
dropdownContent.classList.add('block');
const clipboardCopyElement = document.getElementsByTagName(
'clipboard-copy',
)[0];
document.addEventListener('click', outsideClickListener);
if (clipboardCopyElement) {
clipboardCopyElement.addEventListener('click', copyArticleLink);
}
}
};
}
function finalizeAbuseReportLink(reportAbuseLink) {
// Add actual link location (SEO doesn't like these "useless" links, so adding in here instead of in HTML)
if (!reportAbuseLink) {
return;
}
reportAbuseLink.innerHTML = `<a href="${reportAbuseLink.dataset.path}" class="crayons-link crayons-link--block">Report abuse</a>`;
}
function copyPermalinkListener(copyPermalinkButton) {
copyPermalinkButton.addEventListener('click', copyPermalink);
}
const commentsContainer = document.getElementById('comment-trees-container');
const articleShowMoreButton = document.getElementById(
'article-show-more-button',
);
// We only want to add an event listener for the click once
for (const element of [commentsContainer, articleShowMoreButton]) {
if (element && !element.dataset.initialized) {
element.dataset.initialized = true;
element.addEventListener('click', initializeDropDownClick(element));
}
}
setTimeout(function addListeners() {
getAllByClassName('permalink-copybtn').forEach(copyPermalinkListener);
}, 100);
}

View file

@ -286,7 +286,6 @@ function handleCommentSubmit(event) {
updateCommentsCount();
initializeCommentsPage();
initializeCommentDate();
initializeCommentDropdown();
activateRunkitTags();
})
} else {

View file

@ -60,10 +60,10 @@ function buildCommentHTML(comment) {
</a>
<div class="comment__dropdown">
<button class="dropbtn comment__dropdown-trigger crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon" aria-label="Toggle dropdown menu" aria-haspopup="true">
<button id="comment-dropdown-trigger-${comment.id}" aria-controls="comment-dropdown-${comment.id}" aria-expanded="false" class="dropbtn comment__dropdown-trigger crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon" aria-label="Toggle dropdown menu" aria-haspopup="true">
${ iconSmallOverflowHorizontal }
</button>
<ul class="crayons-dropdown p-1 right-1 s:right-0 s:left-auto fs-base dropdown">
<ul id="comment-dropdown-${comment.id}" class="crayons-dropdown p-1 right-1 s:right-0 s:left-auto fs-base dropdown">
<li><a href="${ comment.url }" class="crayons-link crayons-link--block permalink-copybtn" aria-label="Copy link to ${ comment.user.name }'s comment" data-no-instant>Copy link</a></li>
<li><a href="${ comment.url }/settings" class="crayons-link crayons-link--block" aria-label="Go to ${ comment.user.name }'s comment settings">Settings</a></li>
<li><a href="/report-abuse?url=${ comment.url }" class="crayons-link crayons-link--block" aria-label="Report ${ comment.user.name }'s comment as abusive or violating our code of conduct and/or terms and conditions">Report abuse</a></li>

View file

@ -1,6 +1,9 @@
import { h, render } from 'preact';
import { Snackbar, addSnackbarItem } from '../Snackbar';
import { addFullScreenModeControl } from '../utilities/codeFullscreenModeSwitcher';
import { initializeDropdown } from '@utilities/dropdownUtils';
/* global Runtime */
const fullscreenActionElements = document.getElementsByClassName(
'js-fullscreen-code-action',
@ -20,48 +23,99 @@ if (snackZone) {
// eslint-disable-next-line no-restricted-globals
top.addSnackbarItem = addSnackbarItem;
function hideCopyLinkAnnouncerIfVisible() {
document.getElementById('article-copy-link-announcer').hidden = true;
}
// Initialize the share options
const shareDropdownButton = document.getElementById('article-show-more-button');
if (shareDropdownButton.dataset.initialized !== 'true') {
if (Runtime.isNativeAndroid('shareText')) {
// Android native apps have enhanced sharing capabilities for Articles and don't use our standard dropdown
shareDropdownButton.addEventListener('click', () =>
AndroidBridge.shareText(location.href),
);
} else {
const { closeDropdown } = initializeDropdown({
triggerElementId: 'article-show-more-button',
dropdownContentId: 'article-show-more-dropdown',
onClose: hideCopyLinkAnnouncerIfVisible,
});
// We want to close the dropdown on link select (since they open in a new tab)
document
.querySelectorAll('#article-show-more-dropdown [href]')
.forEach((link) => link.addEventListener('click', closeDropdown));
}
shareDropdownButton.dataset.initialized = 'true';
}
// Initialize the copy to clipboard functionality
function showAnnouncer() {
const { activeElement } = document;
const input =
activeElement.localName === 'clipboard-copy'
? activeElement.querySelector('input')
: document.getElementById('article-copy-link-input');
input.focus();
input.setSelectionRange(0, input.value.length);
document.getElementById('article-copy-link-announcer').hidden = false;
}
function copyArticleLink() {
const inputValue = document.getElementById('article-copy-link-input').value;
Runtime.copyToClipboard(inputValue).then(() => {
showAnnouncer();
});
}
document
.querySelector('clipboard-copy')
?.addEventListener('click', copyArticleLink);
const userDataIntervalID = setInterval(async () => {
const { user = null, userStatus } = document.body.dataset;
clearInterval(userDataIntervalID);
const root = document.getElementById('comment-subscription');
const isLoggedIn = (userStatus === "logged-in");
clearInterval(userDataIntervalID);
const root = document.getElementById('comment-subscription');
const isLoggedIn = userStatus === 'logged-in';
try {
const {
getCommentSubscriptionStatus,
setCommentSubscriptionStatus,
CommentSubscription,
} = await import('../CommentSubscription');
try {
const {
getCommentSubscriptionStatus,
setCommentSubscriptionStatus,
CommentSubscription,
} = await import('../CommentSubscription');
const { articleId } = document.getElementById('article-body').dataset;
const { articleId } = document.getElementById('article-body').dataset;
let subscriptionType = 'not_subscribed';
let subscriptionType = 'not_subscribed';
if (isLoggedIn && user !== null) {
({ config: subscriptionType } = await getCommentSubscriptionStatus(
articleId,
));
}
const subscriptionRequestHandler = async (type) => {
const message = await setCommentSubscriptionStatus(articleId, type);
addSnackbarItem({ message, addCloseButton: true });
};
render(
<CommentSubscription
subscriptionType={subscriptionType}
positionType="static"
onSubscribe={subscriptionRequestHandler}
onUnsubscribe={subscriptionRequestHandler}
isLoggedIn={isLoggedIn}
/>,
root,
);
} catch (e) {
document.getElementById('comment-subscription').innerHTML =
'<p className="color-accent-danger">Unable to load Comment Subscription component.<br />Try refreshing the page.</p>';
if (isLoggedIn && user !== null) {
({ config: subscriptionType } = await getCommentSubscriptionStatus(
articleId,
));
}
const subscriptionRequestHandler = async (type) => {
const message = await setCommentSubscriptionStatus(articleId, type);
addSnackbarItem({ message, addCloseButton: true });
};
render(
<CommentSubscription
subscriptionType={subscriptionType}
positionType="static"
onSubscribe={subscriptionRequestHandler}
onUnsubscribe={subscriptionRequestHandler}
isLoggedIn={isLoggedIn}
/>,
root,
);
} catch (e) {
document.getElementById('comment-subscription').innerHTML =
'<p className="color-accent-danger">Unable to load Comment Subscription component.<br />Try refreshing the page.</p>';
}
});

View file

@ -0,0 +1,71 @@
import { addSnackbarItem } from '../Snackbar';
import { initializeDropdown } from '@utilities/dropdownUtils';
/* global Runtime */
const handleCopyPermalink = (closeDropdown) => {
return (event) => {
event.preventDefault();
const permalink = event.target.href;
Runtime.copyToClipboard(permalink).then(() => {
addSnackbarItem({ message: 'Copied to clipboard' });
});
closeDropdown();
};
};
const initializeArticlePageDropdowns = () => {
const dropdownTriggers = document.querySelectorAll(
'button[id^=comment-dropdown-trigger]',
);
for (const dropdownTrigger of dropdownTriggers) {
if (dropdownTrigger.dataset.initialized) {
// Make sure we only initialize once
continue;
}
const dropdownContentId = dropdownTrigger.getAttribute('aria-controls');
const { closeDropdown } = initializeDropdown({
triggerElementId: dropdownTrigger.id,
dropdownContentId,
});
// Add actual link location (SEO doesn't like these "useless" links, so adding in here instead of in HTML)
const dropdownElement = document.getElementById(dropdownContentId);
const reportAbuseWrapper = dropdownElement.querySelector(
'.report-abuse-link-wrapper',
);
if (reportAbuseWrapper) {
reportAbuseWrapper.innerHTML = `<a href="${reportAbuseWrapper.dataset.path}" class="crayons-link crayons-link--block">Report abuse</a>`;
}
// Initialize the "Copy link" functionality
dropdownElement
.querySelector('.permalink-copybtn')
?.addEventListener('click', handleCopyPermalink(closeDropdown));
dropdownTrigger.dataset.initialized = 'true';
}
};
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach((mutation) => {
if (mutation.type === 'childList') {
initializeArticlePageDropdowns();
}
});
});
observer.observe(document.getElementById('comment-trees-container'), {
childList: true,
subtree: true,
});
InstantClick.on('change', () => {
observer.disconnect();
});
window.addEventListener('beforeunload', () => {
observer.disconnect();
});
initializeArticlePageDropdowns();

View file

@ -0,0 +1,206 @@
/**
* Helper query string to identify interactive/focusable HTML elements
*/
const INTERACTIVE_ELEMENTS_QUERY =
'button, [href], input, select, textarea, [tabindex="0"]';
/**
* Used to close the given dropdown if:
* - Escape is pressed
* - Tab is pressed and the newly focused element doesn't exist inside the dropdown
*
* @param {Object} args
* @param {string} args.triggerElementId The id of the button which activates the dropdown
* @param {string} args.dropdownContentId The id of the dropdown content element
* @param {Function} args.onClose Optional function for any side-effects which should occur on dropdown close
*/
const keyUpListener = ({
triggerElementId,
dropdownContentId,
onClose = () => {},
}) => {
return ({ key }) => {
if (key === 'Escape') {
// Close the dropdown and return focus to the trigger button to prevent focus being lost
const triggerElement = document.getElementById(triggerElementId);
const isCurrentlyOpen =
triggerElement.getAttribute('aria-expanded') === 'true';
if (isCurrentlyOpen) {
closeDropdown({ triggerElementId, dropdownContentId, onClose });
triggerElement.focus();
}
} else if (key === 'Tab') {
// Close the dropdown if the user has tabbed away from it
const isInsideDropdown = document
.getElementById(dropdownContentId)
?.contains(document.activeElement);
if (!isInsideDropdown) {
closeDropdown({ triggerElementId, dropdownContentId, onClose });
}
}
};
};
/**
* Used to listen for a click outside of a dropdown while it's open.
* Closes the dropdown and refocuses the trigger button, if another interactive item has not been clicked.
*
* @param {Object} args
* @param {string} args.triggerElementId The id of the button which activates the dropdown
* @param {string} args.dropdownContentId The id of the dropdown content element
* @param {Function} args.onClose Optional function for any side-effects which should occur on dropdown close
*/
const clickOutsideListener = ({
triggerElementId,
dropdownContentId,
onClose = () => {},
}) => {
return ({ target }) => {
const triggerElement = document.getElementById(triggerElementId);
const dropdownContent = document.getElementById(dropdownContentId);
if (!dropdownContent) {
// User may have navigated away from the page
return;
}
if (
target !== triggerElement &&
!dropdownContent.contains(target) &&
!triggerElement.contains(target)
) {
closeDropdown({ triggerElementId, dropdownContentId, onClose });
// If the user did not click on another interactive item, return focus to the trigger
if (!target.matches(INTERACTIVE_ELEMENTS_QUERY)) {
triggerElement.focus();
}
}
};
};
/**
* Open the given dropdown, updating aria attributes, attaching listeners and focus the first interactive element
*
* @param {Object} args
* @param {string} args.triggerElementId The id of the button which activates the dropdown
* @param {string} args.dropdownContent The id of the dropdown content element
* @param {Function} args.onClose Optional function for any side-effects which should occur on dropdown close
*/
const openDropdown = ({
triggerElementId,
dropdownContentId,
onClose = () => {},
}) => {
const dropdownContent = document.getElementById(dropdownContentId);
const triggerElement = document.getElementById(triggerElementId);
triggerElement.setAttribute('aria-expanded', 'true');
// Style set inline to prevent specificity issues
dropdownContent.style.display = 'block';
// Send focus to the first suitable element
dropdownContent.querySelector(INTERACTIVE_ELEMENTS_QUERY)?.focus();
document.addEventListener(
'keyup',
keyUpListener({ triggerElementId, dropdownContentId, onClose }),
);
document.addEventListener(
'click',
clickOutsideListener({ triggerElementId, dropdownContentId, onClose }),
);
};
/**
* Close the given dropdown, updating aria attributes and removing event listeners
*
* @param {Object} args
* @param {string} args.triggerElementId The id of the button which activates the dropdown
* @param {string} args.dropdownContent The id of the dropdown content element
* @param {Function} args.onClose Optional function for any side-effects which should occur on dropdown close
*/
const closeDropdown = ({
triggerElementId,
dropdownContentId,
onClose = () => {},
}) => {
const dropdownContent = document.getElementById(dropdownContentId);
document
.getElementById(triggerElementId)
?.setAttribute('aria-expanded', 'false');
dropdownContent.style.display = 'none';
document.removeEventListener(
'keyup',
keyUpListener({ triggerElementId, dropdownContentId, onClose }),
);
document.removeEventListener(
'click',
clickOutsideListener({ triggerElementId, dropdownContentId, onClose }),
);
onClose();
};
/**
* A helper function to initialize dropdown behaviors. This function attaches open/close click and keyup listeners,
* and makes sure relevant aria properties and keyboard focus are updated.
*
* @param {Object} args
* @param {string} args.triggerButtonElementId The ID of the button which triggers the dropdown open/close behavior
* @param {string} args.dropdownContentId The ID of the dropdown content which should open/close on trigger button press
* @param {Function} args.onClose An optional callback for when the dropdown is closed. This can be passed to execute any side-effects required when the dropdown closes.
*
* @returns {{closeDropdown: Function}} Object with callback to close the initialized dropdown
*/
export const initializeDropdown = ({
triggerElementId,
dropdownContentId,
onClose = () => {},
}) => {
const triggerButton = document.getElementById(triggerElementId);
const dropdownContent = document.getElementById(dropdownContentId);
if (!triggerButton || !dropdownContent) {
// The required props haven't been provided, do nothing
return;
}
// Ensure default values have been applied
triggerButton.setAttribute('aria-expanded', 'false');
triggerButton.setAttribute('aria-controls', dropdownContentId);
triggerButton.setAttribute('aria-haspopup', 'true');
triggerButton.addEventListener('click', () => {
if (
document
.getElementById(triggerElementId)
?.getAttribute('aria-expanded') === 'true'
) {
closeDropdown({
triggerElementId,
dropdownContentId,
onClose,
});
} else {
openDropdown({
triggerElementId,
dropdownContentId,
onClose,
});
}
});
return {
closeDropdown: () =>
closeDropdown({
triggerElementId,
dropdownContentId,
onClose,
}),
};
};

View file

@ -29,13 +29,13 @@
<% end %>
<div class="align-center m:relative">
<button id="article-show-more-button" class="dropbtn crayons-btn crayons-btn--ghost-dimmed crayons-btn--icon-rounded" aria-label="Toggle dropdown menu">
<%= inline_svg_tag("overflow-horizontal.svg", aria: true, class: "dropdown-icon crayons-icon", title: "More...") %>
<button id="article-show-more-button" aria-controls="article-show-more-dropdown" aria-expanded="false" aria-haspopup="true" class="dropbtn crayons-btn crayons-btn--ghost-dimmed crayons-btn--icon-rounded" aria-label="Share post options">
<%= inline_svg_tag("overflow-horizontal.svg", aria_hidden: true, class: "dropdown-icon crayons-icon", title: "More...") %>
</button>
<div class="crayons-dropdown p-1 right-1 left-1 s:left-auto bottom-100 m:bottom-0 m:right-auto m:left-100 fs-base">
<div id="article-show-more-dropdown" class="crayons-dropdown p-1 right-1 left-1 s:left-auto bottom-100 m:bottom-0 m:right-auto m:left-100 fs-base">
<clipboard-copy for="article-copy-link-input" aria-live="polite" aria-controls="article-copy-link-announcer" class="dropdown-link-row">
<div class="flex items-center">
<input type="text" id="article-copy-link-input" value="<%= article_url(@article) %>" class="crayons-textfield" readonly />
<input type="text" id="article-copy-link-input" value="<%= article_url(@article) %>" aria-label="Post URL" class="crayons-textfield" readonly />
<%= inline_svg_tag("copy.svg", aria: true, id: "article-copy-icon", class: "crayons-icon mx-2 shrink-0", title: "Copy article link to the clipboard") %>
</div>
<div id="article-copy-link-announcer" class="crayons-notice crayons-notice--success my-2 p-1" aria-live="polite" hidden>Copied to Clipboard</div>

View file

@ -8,9 +8,9 @@
<%= render "shared/webcomponents_loader_script" %>
<% if user_signed_in? %>
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", "articleModerationTools", defer: true %>
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", "articleModerationTools", "commentDropdowns", defer: true %>
<% else %>
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %>
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", "commentDropdowns", defer: true %>
<% end %>
<% cache("content-related-optional-scripts-#{@article.id}-#{@article.updated_at}-#{internal_navigation?}-#{user_signed_in?}", expires_in: 30.hours) do %>

View file

@ -11,10 +11,10 @@
<%= render "comments/comment_date", decorated_comment: decorated_comment %>
<div class="comment__dropdown">
<button class="dropbtn comment__dropdown-trigger crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon" aria-label="Toggle dropdown menu" aria-haspopup="true">
<button id="comment-dropdown-trigger-<%= comment.id %>" aria-controls="comment-dropdown-<%= comment.id %>" aria-expanded="false" class="dropbtn comment__dropdown-trigger crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon" aria-label="Toggle dropdown menu" aria-haspopup="true">
<%= inline_svg_tag("small-overflow-horizontal.svg", aria: true, class: "crayons-icon pointer-events-none", title: "Dropdown menu") %>
</button>
<ul class="crayons-dropdown p-1 right-1 s:right-0 s:left-auto fs-base dropdown">
<ul id="comment-dropdown-<%= comment.id %>" class="crayons-dropdown p-1 right-1 s:right-0 s:left-auto fs-base dropdown">
<li><a href="<%= URL.comment(comment) %>" class="crayons-link crayons-link--block permalink-copybtn" aria-label="Copy link to <%= comment.user.name %>'s comment" data-no-instant>Copy link</a></li>
<li class="comment-actions hidden" data-user-id="<%= comment.user_id %>" data-action="settings-button" data-path="<%= URL.comment(comment) %>/settings" aria-label="Go to <%= comment.user.name %>'s comment settings"></li>
<% action = comment.hidden_by_commentable_user ? "Unhide" : "Hide" %>

View file

@ -41,6 +41,8 @@
<% end %>
<% end %>
<%= javascript_packs_with_chunks_tag "commentDropdowns", defer: true %>
<div class="crayons-layout crayons-layout--limited-l gap-0">
<% if @commentable.class.name == "Article" %>
<%= render "shared/payment_pointer", user: @commentable.user %>

View file

@ -93,6 +93,7 @@
id="comments-container"
data-commentable-id="<%= @episode.id %>"
data-commentable-type="PodcastEpisode">
<%= javascript_packs_with_chunks_tag "commentDropdowns", defer: true %>
<%= render "/comments/form",
commentable: @episode,
commentable_type: "PodcastEpisode" %>

View file

@ -500,4 +500,80 @@ describe('Comment on articles', () => {
cy.findByTestId('modal-container').should('not.exist');
cy.findByRole('button', { name: /^Submit$/i }).should('have.focus');
});
it('should provide a dropdown of options', () => {
cy.findByRole('main').within(() => {
// Add a comment
cy.findByRole('textbox', { name: /^Add a comment to the discussion$/i })
.focus() // Focus activates the Submit button and mini toolbar below a comment textbox
.type('this is a comment');
cy.findByRole('button', { name: /^Submit$/i }).click();
// Open and inspect the dropdown menu
cy.findByRole('button', { name: /^Toggle dropdown menu$/i }).as(
'dropdownButton',
);
cy.get('@dropdownButton').click();
cy.findByRole('link', {
name: /^Copy link to Article Editor v1 User's comment$/i,
}).should('have.focus');
cy.findByRole('link', {
name: /^Go to Article Editor v1 User's comment settings$/i,
});
cy.findByRole('link', {
name: "Report Article Editor v1 User's comment as abusive or violating our code of conduct and/or terms and conditions",
});
cy.findByRole('link', { name: /^Edit this comment$/i });
cy.findByRole('link', { name: /^Delete this comment$/i });
// Verify that the dropdown closes again
cy.get('@dropdownButton').click();
cy.findByRole('link', {
name: /^Copy link to Article Editor v1 User's comment$/i,
}).should('not.exist');
});
});
it('should close the comment dropdown on Escape press, returning focus', () => {
// Add a comment
cy.findByRole('textbox', { name: /^Add a comment to the discussion$/i })
.focus() // Focus activates the Submit button and mini toolbar below a comment textbox
.type('this is a comment');
cy.findByRole('button', { name: /^Submit$/i }).click();
cy.findByRole('button', { name: /^Toggle dropdown menu$/i }).as(
'dropdownButton',
);
cy.get('@dropdownButton').click();
cy.findByRole('link', {
name: /^Copy link to Article Editor v1 User's comment$/i,
}).should('have.focus');
cy.get('body').type('{esc}');
cy.findByRole('link', {
name: /^Copy link to Article Editor v1 User's comment$/i,
}).should('not.exist');
cy.get('@dropdownButton').should('have.focus');
});
it('should show dropdown options on comment index page', () => {
cy.findByRole('textbox', { name: /^Add a comment to the discussion$/i })
.focus() // Focus activates the Submit button and mini toolbar below a comment textbox
.type('this is a comment');
cy.findByRole('button', { name: /^Submit$/i }).click();
cy.findByRole('button', { name: /^Toggle dropdown menu$/i }).click();
cy.findByRole('link', { name: /^Edit this comment$/i }).click();
// In the comment index page, click submit without making changes
cy.findByRole('button', { name: /^Submit$/i }).click();
// Check the dropdown has initialized
cy.findByRole('button', { name: /^Toggle dropdown menu$/i }).click();
cy.findByRole('link', { name: /^Edit this comment$/i });
// Close the dropdown again
cy.findByRole('button', { name: /^Toggle dropdown menu$/i }).click();
cy.findByRole('link', { name: /^Edit this comment$/i }).should('not.exist');
});
});

View file

@ -17,19 +17,67 @@ describe('Post sidebar actions', () => {
});
});
it('should open the share menu for the post', () => {
cy.findByRole('button', { name: /^Toggle dropdown menu$/i }).click();
it('should open and close the share menu for the post', () => {
// Check dropdown is closed by asserting the first option isn't visible
cy.findByRole('img', {
name: /^Copy article link to the clipboard$/i,
}).should('not.exist');
cy.findByTitle(/^Copy article link to the clipboard$/i);
cy.findByRole('button', { name: /^Share post options$/i }).as(
'dropdownButton',
);
cy.get('@dropdownButton').click();
cy.findByLabelText('Post URL').should('have.focus');
cy.findByRole('img', {
name: /^Copy article link to the clipboard$/i,
});
cy.findByRole('link', { name: /^Share to Twitter$/i });
cy.findByRole('link', { name: /^Share to LinkedIn$/i });
cy.findByRole('link', { name: /^Share to Reddit$/i });
cy.findByRole('link', { name: /^Share to Hacker News$/i });
cy.findByRole('link', { name: /^Share to Facebook$/i });
// There is a report abuse link a the bottom of the post too
// There is a report abuse link at the bottom of the post too
cy.findAllByRole('link', { name: /^Report Abuse$/i }).should(
'have.length',
2,
);
cy.get('@dropdownButton').click();
// Check dropdown is closed by asserting the first option isn't visible
cy.findByRole('img', {
name: /^Copy article link to the clipboard$/i,
}).should('not.exist');
});
it('should close the options dropdown on Escape press, returning focus', () => {
cy.findByRole('button', { name: /^Share post options$/i }).as(
'dropdownButton',
);
cy.get('@dropdownButton').click();
cy.findByLabelText('Post URL').should('have.focus');
cy.get('body').type('{esc}');
cy.findByLabelText('Post URL').should('not.be.visible');
cy.get('@dropdownButton').should('have.focus');
});
it('should display clipboard copy announcer until the dropdown is next closed', () => {
cy.findByRole('button', { name: /^Share post options$/i }).as(
'dropdownButton',
);
cy.get('@dropdownButton').click();
cy.findByText('Copied to Clipboard').should('not.be.visible');
cy.findByRole('img', {
name: /^Copy article link to the clipboard$/i,
}).click();
cy.findByText('Copied to Clipboard').should('be.visible');
// Close the dropdown, and reopen it to check the message has disappeared
cy.get('@dropdownButton').click();
cy.get('@dropdownButton').click();
cy.findByText('Copied to Clipboard').should('not.be.visible');
});
});