Skip to content

Commit

Permalink
PRO-6684 cointext menus focus trap [a11y] (#4805)
Browse files Browse the repository at this point in the history
* Introduce useFocusTrap composable, use it in context menu

* Better UX and event listeners

* Fix context menus outside modals
  • Loading branch information
myovchev authored Nov 23, 2024
1 parent d3fd291 commit e8c1c9e
Show file tree
Hide file tree
Showing 6 changed files with 384 additions and 20 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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.
Expand Down
81 changes: 72 additions & 9 deletions modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export function useAposFocus() {
return {
elementsToFocus,
focusedElement,
activeModal: modalStore.activeModal,
activeModalElementsToFocus: modalStore.activeModal?.elementsToFocus,
activeModalFocusedElement: modalStore.activeModal?.focusedElement,
cycleElementsToFocus,
focusLastModalFocusedElement,
storeFocusedElement,
Expand All @@ -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;
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
:label="selectBoxMessageButton"
class="apos-select-box__select-all"
text-color="var(--a-primary)"
:disabled="!showSelectAll"
@click="$emit('select-all')"
/>
<AposButton
Expand All @@ -26,6 +27,7 @@
label="apostrophe:clearSelection"
class="apos-select-box__select-all"
text-color="var(--a-primary)"
:disabled="!showSelectAll"
@click="clearSelection"
/>
</p>
Expand Down
4 changes: 4 additions & 0 deletions modules/@apostrophecms/ui/ui/apos/components/AposButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -235,6 +236,9 @@ export default {
methods: {
click($event) {
this.$emit('click', $event);
},
focus() {
(this.$refs.buttonTrigger?.$el ?? this.$refs.buttonTrigger)?.focus();
}
}
};
Expand Down
99 changes: 88 additions & 11 deletions modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<template>
<div class="apos-context-menu">
<section
ref="contextMenuRef"
class="apos-context-menu"
@keydown.tab="onTab"
>
<slot name="prebutton" />
<div
ref="dropdown"
class="apos-popover__btn apos-context-menu__dropdown"
>
<AposButton
v-bind="button"
ref="button"
ref="dropdownButton"
class="apos-context-menu__btn"
role="button"
:data-apos-test="identifier"
Expand All @@ -25,6 +29,7 @@
v-if="isOpen"
ref="dropdownContent"
v-click-outside-element="hide"
:data-apos-test="isRendered ? 'context-menu-content' : null"
class="apos-context-menu__dropdown-content"
:class="popoverClass"
data-apos-menu
Expand All @@ -44,7 +49,7 @@
</AposContextMenuDialog>
</div>
</div>
</div>
</section>
</template>

<script setup>
Expand All @@ -54,9 +59,11 @@ import {
import {
computePosition, offset, shift, flip, arrow
} from '@floating-ui/dom';
import { useAposTheme } from 'Modules/@apostrophecms/ui/composables/AposTheme';
import { createId } from '@paralleldrive/cuid2';
import { useAposTheme } from '../composables/AposTheme.js';
import { useFocusTrap } from '../composables/AposFocusTrap.js';
const props = defineProps({
identifier: {
type: String,
Expand Down Expand Up @@ -122,22 +129,43 @@ const props = defineProps({
activeItem: {
type: String,
default: null
},
trapFocus: {
type: Boolean,
default: true
}
});
const emit = defineEmits([ 'open', 'close', 'item-clicked' ]);
const isOpen = ref(false);
const isRendered = ref(false);
const placement = ref(props.menuPlacement);
const event = ref(null);
/** @type {import('vue').Ref<HTMLElement | null>}} */
const contextMenuRef = ref(null);
/** @type {import('vue').Ref<HTMLElement | null>}} */
const dropdown = ref(null);
/** @type {import('vue').Ref<import('vue').ComponentPublicInstance | null>} */
const dropdownButton = ref(null);
/** @type {import('vue').Ref<HTMLElement | null>} */
const dropdownContent = ref(null);
const dropdownContentStyle = ref({});
const arrowEl = ref(null);
const iconToCenterTo = ref(null);
const menuOffset = getMenuOffset();
const otherMenuOpened = ref(false);
const {
onTab, runTrap, hasRunningTrap, resetTrap
} = useFocusTrap({
withPriority: true
// If enabled, the dropdown gets closed when the focus leaves
// the context menu.
// triggerRef: dropdownButton,
// onExit: hide
});
defineExpose({
hide,
setDropdownPosition
Expand Down Expand Up @@ -170,19 +198,28 @@ const buttonState = computed(() => {
return isOpen.value ? [ 'active' ] : null;
});
watch(isOpen, (newVal) => {
watch(isOpen, async (newVal) => {
emit(newVal ? 'open' : 'close', event.value);
if (newVal) {
setDropdownPosition();
window.addEventListener('resize', setDropdownPosition);
window.addEventListener('scroll', setDropdownPosition);
window.addEventListener('keydown', handleKeyboard);
dropdownContent.value.querySelector('[tabindex]')?.focus();
contextMenuRef.value?.addEventListener('keydown', handleKeyboard);
if (props.trapFocus && !hasRunningTrap.value) {
await runTrap(dropdownContent);
}
if (!props.trapFocus) {
dropdownContent.value.querySelector('[tabindex]')?.focus();
}
isRendered.value = true;
} else {
if (props.trapFocus) {
resetTrap();
}
window.removeEventListener('resize', setDropdownPosition);
window.removeEventListener('scroll', setDropdownPosition);
window.removeEventListener('keydown', handleKeyboard);
if (!otherMenuOpened.value) {
contextMenuRef.value?.addEventListener('keydown', handleKeyboard);
if (!otherMenuOpened.value && !props.trapFocus) {
dropdown.value.querySelector('[tabindex]').focus();
}
}
Expand Down Expand Up @@ -276,11 +313,51 @@ async function setDropdownPosition() {
});
}
const ignoreInputTypes = [
'text',
'password',
'email',
'file',
'number',
'search',
'tel',
'url',
'date',
'time',
'datetime-local',
'month',
'search',
'week'
];
/**
* @param {KeyboardEvent} event
*/
function handleKeyboard(event) {
if (event.key === 'Escape') {
if (event.key !== 'Escape' || !isOpen.value) {
return;
}
/** @type {HTMLElement} */
const target = event.target;
// If inside of an input or textarea, don't close the dropdown
// and don't allow other event listeners to close it either (e.g. modals)
if (
target?.nodeName?.toLowerCase() === 'textarea' ||
(target?.nodeName?.toLowerCase() === 'input' &&
ignoreInputTypes.includes(target.getAttribute('type'))
)
) {
event.stopImmediatePropagation();
hide();
return;
}
dropdownButton.value?.focus
? dropdownButton.value.focus()
: dropdownButton.value?.$el?.focus();
event.stopImmediatePropagation();
hide();
}
</script>
Expand Down
Loading

0 comments on commit e8c1c9e

Please sign in to comment.