diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4c61adb6..362f1c213 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
+## [6.14.0](https://github.com/dequelabs/cauldron/compare/v6.13.0...v6.14.0) (2024-12-20)
+
+
+### Features
+
+* **react:** add focusTrap options to AnchoredOverlay ([#1775](https://github.com/dequelabs/cauldron/issues/1775)) ([0a00d24](https://github.com/dequelabs/cauldron/commit/0a00d24f75b0183ee90e30f3e7362eeebc1c6847))
+
+
+### Bug Fixes
+
+* **react:** fix a11y active-descendant issue with controlled Listboxes ([#1776](https://github.com/dequelabs/cauldron/issues/1776)) ([897963b](https://github.com/dequelabs/cauldron/commit/897963ba1310fad155020bdb43738730d82f1283))
+* **react:** fix listbox focus issue when listbox options have changed ([#1777](https://github.com/dequelabs/cauldron/issues/1777)) ([55d370c](https://github.com/dequelabs/cauldron/commit/55d370c097b8ff0b9dab012d364253fc44ab8a17))
+
## [6.13.0](https://github.com/dequelabs/cauldron/compare/v6.12.0...v6.13.0) (2024-12-18)
diff --git a/docs/pages/components/AnchoredOverlay.mdx b/docs/pages/components/AnchoredOverlay.mdx
index 13bd6cfd4..17b6af5d1 100644
--- a/docs/pages/components/AnchoredOverlay.mdx
+++ b/docs/pages/components/AnchoredOverlay.mdx
@@ -173,6 +173,16 @@ function AnchoredOverlayWithOffsetExample() {
type: 'number',
description: 'An optional offset number to position the anchor element from its anchored target.'
},
+ {
+ name: 'focusTrap',
+ type: 'boolean',
+ description: 'When set, traps focus within the AnchoredOverlay.'
+ },
+ {
+ name: 'focusTrapOptions',
+ type: 'object',
+ description: 'When `focusTrap` is true, optional arguments to configure the focus trap.'
+ },
{
name: 'as',
type: 'React.ElementType',
diff --git a/package.json b/package.json
index 49c556d7e..7804261cc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "cauldron",
"private": true,
- "version": "6.13.0",
+ "version": "6.14.0",
"license": "MPL-2.0",
"scripts": {
"clean": "rimraf dist docs/dist",
diff --git a/packages/react/package.json b/packages/react/package.json
index e5a504c56..99f7e1645 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@deque/cauldron-react",
- "version": "6.13.0",
+ "version": "6.14.0",
"license": "MPL-2.0",
"description": "Fully accessible react components library for Deque Cauldron",
"homepage": "https://cauldron.dequelabs.com/",
diff --git a/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx
index 4fdbd385b..53bb799b3 100644
--- a/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx
+++ b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AnchoredOverlay from './';
import axe from '../../axe';
@@ -123,6 +123,76 @@ test('should call onPlacementChange with initial placement', () => {
expect(onPlacementChange).toHaveBeenCalledWith('top');
});
+test('should not trap focus when focusTrap is false', async () => {
+ const targetRef = { current: document.createElement('button') };
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+
+
+
+
+ >
+ );
+
+ const buttons = screen.getAllByRole('button');
+
+ buttons[0].focus();
+ expect(buttons[0]).toHaveFocus();
+ await user.tab();
+ expect(buttons[1]).toHaveFocus();
+ await user.tab();
+ expect(buttons[2]).toHaveFocus();
+});
+
+test('should trap focus when focusTrap is true', async () => {
+ const targetRef = { current: document.createElement('button') };
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ const buttons = within(screen.getByTestId('overlay')).getAllByRole('button');
+
+ expect(buttons[0]).toHaveFocus();
+ await user.tab();
+ expect(buttons[1]).toHaveFocus();
+ await user.tab();
+ expect(buttons[2]).toHaveFocus();
+ await user.tab();
+ expect(buttons[0]).toHaveFocus();
+});
+
+test('should restore focus when focusTrap is unmounted', async () => {
+ const targetRef = { current: document.createElement('button') };
+ const outsideButton = document.createElement('button');
+ document.body.appendChild(outsideButton);
+ outsideButton.focus();
+
+ const { unmount } = render(
+
+
+
+ );
+
+ expect(screen.getByText('Inside Button')).toHaveFocus();
+ unmount();
+ expect(outsideButton).toHaveFocus();
+
+ document.body.removeChild(outsideButton);
+});
+
test('should support ref prop', () => {
const targetRef = { current: document.createElement('button') };
const ref = React.createRef();
diff --git a/packages/react/src/components/AnchoredOverlay/index.tsx b/packages/react/src/components/AnchoredOverlay/index.tsx
index 74b40379d..4934bda75 100644
--- a/packages/react/src/components/AnchoredOverlay/index.tsx
+++ b/packages/react/src/components/AnchoredOverlay/index.tsx
@@ -10,6 +10,7 @@ import { type PolymorphicProps } from '../../utils/polymorphicComponent';
import resolveElement from '../../utils/resolveElement';
import useSharedRef from '../../utils/useSharedRef';
import useEscapeKey from '../../utils/useEscapeKey';
+import useFocusTrap from '../../utils/useFocusTrap';
type AnchoredOverlayProps<
Overlay extends HTMLElement,
@@ -27,6 +28,10 @@ type AnchoredOverlayProps<
onPlacementChange?: (placement: Placement) => void;
/** An optional offset number to position the anchor element from its anchored target. */
offset?: number;
+ /** When set, traps focus within the AnchoredOverlay. */
+ focusTrap?: boolean;
+ /** When `focusTrap` is true, optional arguments to configure the focus trap. */
+ focusTrapOptions?: Parameters[1];
children?: React.ReactNode;
} & PolymorphicProps>;
@@ -56,6 +61,8 @@ const AnchoredOverlay = forwardRef(
style,
open = false,
offset,
+ focusTrap,
+ focusTrapOptions,
onOpenChange,
onPlacementChange,
...props
@@ -99,6 +106,8 @@ const AnchoredOverlay = forwardRef(
}
});
+ useFocusTrap(ref, !focusTrap ? { disabled: true } : focusTrapOptions);
+
useEffect(() => {
if (typeof onPlacementChange === 'function') {
onPlacementChange(placement);
diff --git a/packages/react/src/components/Listbox/Listbox.tsx b/packages/react/src/components/Listbox/Listbox.tsx
index def6c872e..2ee88e4d3 100644
--- a/packages/react/src/components/Listbox/Listbox.tsx
+++ b/packages/react/src/components/Listbox/Listbox.tsx
@@ -104,15 +104,19 @@ const Listbox = forwardRef<
)
);
setSelectedOptions(matchingOptions);
- setActiveOption(matchingOptions[0] || null);
+ if (!activeOption) {
+ setActiveOption(matchingOptions[0] || null);
+ }
} else {
const matchingOption = options.find((option) =>
optionMatchesValue(option, listboxValue)
);
setSelectedOptions(matchingOption ? [matchingOption] : []);
- setActiveOption(matchingOption || null);
+ if (!activeOption) {
+ setActiveOption(matchingOption || null);
+ }
}
- }, [isControlled, options, value, defaultValue]);
+ }, [isControlled, options, value, defaultValue, activeOption]);
useEffect(() => {
if (activeOption) {
@@ -243,7 +247,10 @@ const Listbox = forwardRef<
const handleFocus = useCallback(
(event: React.FocusEvent) => {
- if (!activeOption) {
+ if (
+ !activeOption ||
+ !options.some((option) => option.element === activeOption.element)
+ ) {
const firstOption = options.find(
(option) => !isDisabledOption(option)
);
diff --git a/packages/react/src/components/Listbox/index.test.tsx b/packages/react/src/components/Listbox/index.test.tsx
index 7f3a3e699..6ff5eda97 100644
--- a/packages/react/src/components/Listbox/index.test.tsx
+++ b/packages/react/src/components/Listbox/index.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import Listbox from './';
import { ListboxGroup, ListboxOption } from './';
import axe from '../../axe';
@@ -202,7 +202,7 @@ test('should set the first non-disabled option as active on focus', () => {
);
- fireEvent.focus(screen.getByRole('option', { name: 'Banana' }));
+ fireEvent.focus(screen.getByRole('listbox'));
expect(screen.getByRole('option', { name: 'Banana' })).toHaveClass(
'ListboxOption--active'
);
@@ -212,6 +212,42 @@ test('should set the first non-disabled option as active on focus', () => {
);
});
+test('should set the first non-disabled option as active on focus when the options have changed', () => {
+ const { rerender } = render(
+
+ Apple
+ Banana
+ Cantaloupe
+
+ );
+
+ waitFor(() => {
+ fireEvent.focus(screen.getByRole('listbox'));
+ expect(screen.getByRole('listbox')).toHaveFocus();
+ });
+
+ rerender(
+
+ Dragon Fruit
+ Elderberry
+ Fig
+
+ );
+
+ waitFor(() => {
+ fireEvent.focus(screen.getByRole('listbox'));
+ expect(screen.getByRole('listbox')).toHaveFocus();
+ });
+
+ expect(screen.getByRole('option', { name: 'Elderberry' })).toHaveClass(
+ 'ListboxOption--active'
+ );
+ expect(screen.getByRole('listbox')).toHaveAttribute(
+ 'aria-activedescendant',
+ screen.getByRole('option', { name: 'Elderberry' }).getAttribute('id')
+ );
+});
+
test('should set selected value with "value" prop when listbox option only has text label', () => {
render(
diff --git a/packages/react/src/components/MenuItem/index.test.tsx b/packages/react/src/components/MenuItem/index.test.tsx
index 62978e8ce..0822dc789 100644
--- a/packages/react/src/components/MenuItem/index.test.tsx
+++ b/packages/react/src/components/MenuItem/index.test.tsx
@@ -9,9 +9,11 @@ const user = userEvent.setup();
test('clicks first direct child link given a click', async () => {
const onClick = sinon.spy();
+ // Note: Using a hash link instead of a url link because jsdom doesn't correctly
+ // support navigation and throws a noisy console error we don't care about
render(
diff --git a/packages/styles/package.json b/packages/styles/package.json
index 3c8f7c617..439ea667d 100644
--- a/packages/styles/package.json
+++ b/packages/styles/package.json
@@ -1,6 +1,6 @@
{
"name": "@deque/cauldron-styles",
- "version": "6.13.0",
+ "version": "6.14.0",
"license": "MPL-2.0",
"description": "deque cauldron pattern library styles",
"repository": "https://github.com/dequelabs/cauldron",
diff --git a/vpats/2024-12-19-cauldron.md b/vpats/2024-12-19-cauldron.md
new file mode 100644
index 000000000..acb9d7854
--- /dev/null
+++ b/vpats/2024-12-19-cauldron.md
@@ -0,0 +1,65 @@
+# Cauldron Accessibility Conformance Report WCAG Edition
+
+**Name of Product**: Cauldron
+
+**Report Date**: 2024-12-19
+
+## Table 1: Success Criteria, Level A
+
+| Criteria | Conformance Level | Remarks and Explanations |
+| --- | --- | --- |
+| [1.1.1 Non-text Content](http://www.w3.org/TR/WCAG20/#text-equiv-all) (Level A) | Supports | |
+| [1.2.1 Audio-only and Video-only (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-av-only-alt) (Level A) | Supports | |
+| [1.2.2 Captions (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-captions) (Level A) | Supports | |
+| [1.2.3 Audio Description or Media Alternative (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-audio-desc) (Level A) | Supports | |
+| [1.3.1 Info and Relationships](http://www.w3.org/TR/WCAG20/#content-structure-separation-programmatic) (Level A) | Supports | |
+| [1.3.2 Meaningful Sequence](http://www.w3.org/TR/WCAG20/#content-structure-separation-sequence) (Level A) | Supports | |
+| [1.3.3 Sensory Characteristics](http://www.w3.org/TR/WCAG20/#content-structure-separation-understanding) (Level A) | Supports | |
+| [1.4.1 Use of Color](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-without-color) (Level A) | Supports | |
+| [1.4.2 Audio Control](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-dis-audio) (Level A) | Supports | |
+| [2.1.1 Keyboard](http://www.w3.org/TR/WCAG20/#keyboard-operation-keyboard-operable) (Level A) | Supports | |
+| [2.1.2 No Keyboard Trap](http://www.w3.org/TR/WCAG20/#keyboard-operation-trapping) (Level A) | Supports | |
+| [2.1.4 Character Key Shortcuts](http://www.w3.org/TR/WCAG20/#keyboard-operation-keyboard-operable) (Level A) | Supports | |
+| [2.2.1 Timing Adjustable](http://www.w3.org/TR/WCAG20/#time-limits-required-behaviors) (Level A) | Supports | |
+| [2.2.2 Pause, Stop, Hide](http://www.w3.org/TR/WCAG20/#time-limits-pause) (Level A) | Supports | |
+| [2.3.1 Three Flashes or Below Threshold](http://www.w3.org/TR/WCAG20/#seizure-does-not-violate) (Level A) | Supports | |
+| [2.4.1 Bypass Blocks](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-skip) (Level A) | Supports | |
+| [2.4.2 Page Titled](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-title) (Level A) | Supports | |
+| [2.4.3 Focus Order](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-focus-order) (Level A) | Supports | |
+| [2.4.4 Link Purpose (In Context)](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-refs) (Level A) | Supports | |
+| [2.5.1 Pointer Gestures](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level A) | Supports | |
+| [2.5.2 Pointer Cancellation](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level A) | Supports | |
+| [2.5.3 Label in Name](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-descriptive) (Level A) | Supports | |
+| [2.5.4 Motion Actuation](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-motion-actuation) (Level A) | Supports | |
+| [3.1.1 Language of Page](http://www.w3.org/TR/WCAG20/#meaning-doc-lang-id) (Level A) | Supports | |
+| [3.2.1 On Focus](http://www.w3.org/TR/WCAG20/#consistent-behavior-receive-focus) (Level A) | Supports | |
+| [3.2.2 On Input](http://www.w3.org/TR/WCAG20/#consistent-behavior-unpredictable-change) (Level A) | Supports | |
+| [3.3.1 Error Identification](http://www.w3.org/TR/WCAG20/#minimize-error-identified) (Level A) | Supports | |
+| [3.3.2 Labels or Instructions](http://www.w3.org/TR/WCAG20/#minimize-error-cues) (Level A) | Supports | |
+| [4.1.1 Parsing](http://www.w3.org/TR/WCAG20/#ensure-compat-parses) (Level A) | Supports | |
+| [4.1.2 Name, Role, Value](http://www.w3.org/TR/WCAG20/#ensure-compat-rsv) (Level A) | Supports | |
+
+## Table 2: Success Criteria, Level AA
+
+| Criteria | Conformance Level | Remarks and Explanations |
+| --- | --- | --- |
+| [1.2.4 Captions (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-captions) (Level AA) | Supports | |
+| [1.2.5 Audio Description or Media Alternative (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-audio-desc) (Level AA) | Supports | |
+| [1.3.4 Orientation](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-orientation) (Level AA) | Supports | |
+| [1.3.5 Identify Input Purpose](http://www.w3.org/TR/WCAG20/#input-purposes) (Level AA) | Supports | |
+| [1.4.3 Contrast (Minimum)](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast) (Level AA) | Supports | |
+| [1.4.4 Resize text](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-scale) (Level AA) | Supports | |
+| [1.4.5 Images of Text](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-text-presentation) (Level AA) | Supports | |
+| [1.4.10 Reflow](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-scale) (Level AA) | Supports | |
+| [1.4.11 Non-text Contrast](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast) (Level AA) | Supports | |
+| [1.4.12 Text Spacing](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-spacing) (Level AA) | Supports | |
+| [1.4.13 Content on Hover or Focus](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-dis-audio) (Level AA) | Supports | |
+| [2.4.5 Multiple Ways](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level AA) | Supports | |
+| [2.4.6 Headings and Labels](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-descriptive) (Level AA) | Partially Supports | - [[#1393] [A11y] - Programmatic label does not convey purpose of control](https://github.com/dequelabs/cauldron/issues/1393) (2024-03-08)
|
+| [2.4.7 Focus Visible](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-focus-visible) (Level AA) | Supports | |
+| [3.1.2 Language of Parts](http://www.w3.org/TR/WCAG20/#meaning-doc-lang-id) (Level AA) | Supports | |
+| [3.2.3 Consistent Navigation](http://www.w3.org/TR/WCAG20/#consistent-behavior-consistent-locations) (Level AA) | Supports | |
+| [3.2.4 Consistent Identification](http://www.w3.org/TR/WCAG20/#consistent-behavior-consistent-functionality) (Level AA) | Supports | |
+| [3.3.3 Error Suggestion](http://www.w3.org/TR/WCAG20/#minimize-error-suggestions) (Level AA) | Supports | |
+| [3.3.4 Error Prevention (Legal, Financial, Data)](http://www.w3.org/TR/WCAG20/#minimize-error-reversible) (Level AA) | Supports | |
+| [4.1.3 Status Messages](http://www.w3.org/TR/WCAG20/#ensure-compat-rsv) (Level AA) | Supports | |
\ No newline at end of file