diff --git a/CHANGELOG.md b/CHANGELOG.md index eca6caab33..526db718da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Fix permission grid tooltip display. * Fixes a bug that crashes external frontend applications. * Fixes a false positive warning for module not in use for project level submodules (e.g. `widges/module.js`) and dot-folders (e.g. `.DS_Store`). +* a11y improvements for context menus. * Bumped `express-bearer-token` dependency to address a low-severity `npm audit` warning regarding noncompliant cookie names and values. Apostrophe did not actually use any noncompliant cookie names or values, so there was no vulnerability in Apostrophe. * Rich text "Styles" toolbar now has visually focused state. diff --git a/modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js b/modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js index cf7daa2f5a..85192f4c1a 100644 --- a/modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js +++ b/modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js @@ -10,6 +10,9 @@ export function useAposFocus() { return { elementsToFocus, focusedElement, + activeModal: modalStore.activeModal, + activeModalElementsToFocus: modalStore.activeModal?.elementsToFocus, + activeModalFocusedElement: modalStore.activeModal?.focusedElement, cycleElementsToFocus, focusLastModalFocusedElement, storeFocusedElement, @@ -26,7 +29,18 @@ export function useAposFocus() { // `cycleElementsToFocus` listeners relies on this dynamic list which has the advantage of // taking new or less elements to focus, after an update has happened inside a modal, // like an XHR call to get the pieces list in the AposDocsManager modal, for instance. - function cycleElementsToFocus(e, elements) { + // If the fnFocus argument is provided, it will be called with the event and + // the element to focus. Otherwise, the default behavior is to focus the element + // and prevent the default event behavior. + /** + * @param {KeyboardEvent} e event + * @param {HTMLElement[]} elements + * @param { + * (event: KeyboardEvent, element: HTMLElement) => void + * } [fnFocus] optional function to focus the element + * @returns {void} + */ + function cycleElementsToFocus(e, elements, fnFocus) { const elems = elements || elementsToFocus.value; if (!elems.length) { return; @@ -37,25 +51,74 @@ export function useAposFocus() { return; } - const firstElementToFocus = elems.at(0); - const lastElementToFocus = elems.at(-1); + let firstElementToFocus = elems.at(0); + let lastElementToFocus = elems.at(-1); + + // Take into account radio inputs with the same name, the + // browser will cycle through them as a group, stepping on + // the active one per stack. + const firstElementRadioStack = getInputRadioStack(firstElementToFocus, elems); + const lastElementRadioStack = getInputRadioStack(lastElementToFocus, elems); + firstElementToFocus = getInputCheckedOrCurrent(firstElementToFocus, firstElementRadioStack); + lastElementToFocus = getInputCheckedOrCurrent(lastElementToFocus, lastElementRadioStack); + + const focus = fnFocus || ((ev, el) => { + el.focus(); + ev.preventDefault(); + }); // If shift key pressed for shift + tab combination if (e.shiftKey) { - if (document.activeElement === firstElementToFocus) { + if (document.activeElement === firstElementToFocus || + firstElementRadioStack.includes(document.activeElement) + ) { // Add focus for the last focusable element - lastElementToFocus.focus(); - e.preventDefault(); + focus(e, lastElementToFocus); } return; } // If tab key is pressed - if (document.activeElement === lastElementToFocus) { + if (document.activeElement === lastElementToFocus || + lastElementRadioStack.includes(document.activeElement) + ) { // Add focus for the first focusable element - firstElementToFocus.focus(); - e.preventDefault(); + focus(e, firstElementToFocus); + } + } + + /** + * Returns an array of radio inputs with the same name attribute + * as the current element. If the current element is not a radio input, + * an empty array is returned. + * + * @param {HTMLElement} currentElement + * @param {HTMLElement[]} elements + * @returns {HTMLElement[]} + */ + function getInputRadioStack(currentElement, elements) { + return currentElement.getAttribute('type') === 'radio' + ? elements.filter( + e => (e.getAttribute('type') === 'radio' && + e.getAttribute('name') === currentElement.getAttribute('name')) + ) + : []; + } + + /** + * + * @param {HTMLElement} currentElement + * @param {HTMLElement[]} elements + * @returns + */ + function getInputCheckedOrCurrent(currentElement, elements = []) { + const checked = elements.find(el => (el.hasAttribute('checked'))); + + if (checked) { + return checked; } + + return currentElement; } // Focus the last focused element from the last modal. diff --git a/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerSelectBox.vue b/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerSelectBox.vue index b7d7151a95..89be0fc825 100644 --- a/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerSelectBox.vue +++ b/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerSelectBox.vue @@ -17,6 +17,7 @@ :label="selectBoxMessageButton" class="apos-select-box__select-all" text-color="var(--a-primary)" + :disabled="!showSelectAll" @click="$emit('select-all')" />

diff --git a/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue b/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue index d0ab73cff4..28b73516cd 100644 --- a/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +++ b/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue @@ -9,6 +9,7 @@ v-bind="attrs" :is="href ? 'a' : 'button'" :id="attrs.id ? attrs.id : id" + ref="buttonTrigger" :target="target" :href="href" class="apos-button" @@ -235,6 +236,9 @@ export default { methods: { click($event) { this.$emit('click', $event); + }, + focus() { + (this.$refs.buttonTrigger?.$el ?? this.$refs.buttonTrigger)?.focus(); } } }; diff --git a/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue b/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue index cb547bff44..c2e072e2d3 100644 --- a/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +++ b/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue @@ -1,5 +1,9 @@ diff --git a/modules/@apostrophecms/ui/ui/apos/composables/AposFocusTrap.js b/modules/@apostrophecms/ui/ui/apos/composables/AposFocusTrap.js new file mode 100644 index 0000000000..10f7877a42 --- /dev/null +++ b/modules/@apostrophecms/ui/ui/apos/composables/AposFocusTrap.js @@ -0,0 +1,217 @@ +import { useAposFocus } from 'Modules/@apostrophecms/modal/composables/AposFocus'; +import { + computed, ref, unref, nextTick +} from 'vue'; + +/** + * Handle focus trapping inside a modal or any other element. + * + * Options: + * - `retries`: Number of retries to focus (trap) the first element in the given + * container. Default is 3. + * - `withPriority`: If true, 'data-apos-focus-priority' attribute will be used + * to find the first element to focus. Default is true. + * - `triggerRef`: (optional) A ref to the element that will trigger the focus trap. + * It's used as a focus target when exiting the current element focusable elements. + * If boolean `true` is passed, the active modal focused element will be used. + * - `onExit`: (optional) A callback to be called when exiting the focus trap. + * + * @param {{ + * retries?: number; + * withPriority?: boolean; + * triggerRef?: import('vue').Ref + * | HTMLElement | boolean; + * onExit?: () => void; + * }} options + * @returns {{ +* runTrap: (containerRef: import('vue').Ref | HTMLElement) => Promise; + * hasRunningTrap: import('vue').ComputedRef; + * stopTrap: () => void; + * resetTrap: () => void; + * onTab: (event: KeyboardEvent) => void; + * }} + */ +export function useFocusTrap({ + triggerRef, + onExit = () => {}, + retries = 3, + withPriority = true +}) { + const { + activeModalFocusedElement, + findPriorityElementOrFirst, + cycleElementsToFocus: parentCycleElementsToFocus, + focusElement + } = useAposFocus(); + + const shouldRun = ref(false); + const isRunning = ref(false); + const currentRetries = ref(0); + const elementsToFocus = ref([]); + const hasRunningTrap = computed(() => { + return isRunning.value; + }); + const triggerRefElement = computed(() => { + const value = unref(triggerRef); + if (value === true) { + return activeModalFocusedElement; + } + if (value) { + const element = value.$el || value; + if (element instanceof HTMLElement) { + return element.hasAttribute('tabindex') + ? element + : (element.querySelector('[tabindex]') || element); + } + } + return null; + }); + + const selectors = [ + '[tabindex]', + '[href]', + 'input', + 'select', + 'textarea', + 'button', + '[data-apos-focus-priority]' + ]; + const selector = selectors + .map(addExcludingAttributes) + .join(', '); + + return { + runTrap: run, + hasRunningTrap, + stopTrap: stop, + resetTrap: reset, + onTab: cycle + }; + + /** + * The internal implementation of the focus trap. + */ + async function trapFocus(containerRef) { + if (!unref(containerRef) || !shouldRun.value) { + return; + } + const elements = [ ...unref(containerRef).querySelectorAll(selector) ]; + const firstElementToFocus = unref(withPriority) + ? findPriorityElementOrFirst(elements) + : elements[0]; + const isPriorityElement = unref(withPriority) + ? firstElementToFocus?.hasAttribute('data-apos-focus-priority') + : firstElementToFocus; + + if (!isPriorityElement && unref(retries) > currentRetries.value) { + currentRetries.value++; + await wait(20); + return trapFocus(containerRef); + } + await nextTick(); + + if (shouldRun.value) { + focusElement(findChecked(firstElementToFocus, elements)); + elementsToFocus.value = elements; + } + } + + /** + * Run the focus trap + */ + async function run(containerRef) { + if (isRunning.value) { + return; + } + shouldRun.value = true; + isRunning.value = true; + await trapFocus(containerRef); + isRunning.value = false; + shouldRun.value = false; + } + + /** + * Stop the focus trap + */ + function stop() { + shouldRun.value = false; + } + + /** + * Reset the focus trap + */ + function reset() { + shouldRun.value = false; + isRunning.value = false; + currentRetries.value = 0; + elementsToFocus.value = []; + } + + /** + * Cycle through the elements to focus in the container element. + * If no modal is active, it will use the natural focusable elements. + * + * @param {KeyboardEvent} event + */ + function cycle(event) { + const elements = unref(elementsToFocus); + parentCycleElementsToFocus(event, elements, focus); + + // Keep the if branches for better readability and future changes. + function focus(ev, element) { + let toFocusEl; + const currentFocused = triggerRefElement.value; + + // If no trigger element is found, fallback to the original behavior. + if (!currentFocused) { + element.focus(); + ev.preventDefault(); + } + + // We did a full cycle and are returning back to the first element. + // We don't want that, but to exit the cycle and continue to the next + // modal element to focus or the next natural focusable element (if + // not inside a modal). + if (element === elements[0]) { + toFocusEl = currentFocused; + } + + // We are shift + tabbing from the first element. We want to focus the + // modal last focused element if available. + if (element === elements.at(-1)) { + toFocusEl = currentFocused; + } + + if (toFocusEl) { + toFocusEl.focus(); + ev.preventDefault(); + } + + // The focus handler is called ONLY when we are exiting the container + // element. No matter if we find a focusable element or not, we should + // call the onExit callback - the focus should be outside the container + // element. + onExit(); + } + } +}; + +function addExcludingAttributes(selector) { + return `${selector}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden])`; +} + +function findChecked(element, elements) { + if (element?.getAttribute('type') === 'radio') { + return elements.find( + el => (el.getAttribute('type') === 'radio' && + el.getAttribute('name') === element.getAttribute('name') && + el.hasAttribute('checked')) + ) || element; + } + + return element; +} + +async function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}