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));
+}