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( - + Foo 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