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:
parent
e6e99e902b
commit
7e322e4f7b
14 changed files with 509 additions and 228 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -286,7 +286,6 @@ function handleCommentSubmit(event) {
|
|||
updateCommentsCount();
|
||||
initializeCommentsPage();
|
||||
initializeCommentDate();
|
||||
initializeCommentDropdown();
|
||||
activateRunkitTags();
|
||||
})
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
}
|
||||
});
|
||||
|
|
|
|||
71
app/javascript/packs/commentDropdowns.js
Normal file
71
app/javascript/packs/commentDropdowns.js
Normal 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();
|
||||
206
app/javascript/utilities/dropdownUtils.js
Normal file
206
app/javascript/utilities/dropdownUtils.js
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue