diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 50a10b143..9da94aee7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,4 +61,10 @@ jobs: NODE_ENV=production yarn --cwd packages/react build NODE_ENV=production yarn --cwd packages/styles build - run: yarn build:docs - - run: yarn test:a11y + # Newer versions of Ubuntu have increased security restrictions in which + # puppeteer is unable to launch without additional configuration + # See: https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md + - run: | + export CHROME_DEVEL_SANDBOX=/opt/google/chrome/chrome-sandbox + sudo chmod 4755 /opt/google/chrome/chrome-sandbox + yarn test:a11y diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ea90994..b4c61adb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ 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.13.0](https://github.com/dequelabs/cauldron/compare/v6.12.0...v6.13.0) (2024-12-18) + + +### Features + +* **react,style:** add AnchoredOverlay component, refactor Tooltip and Popover to use AnchoredOverlay ([#1760](https://github.com/dequelabs/cauldron/issues/1760)) ([6773975](https://github.com/dequelabs/cauldron/commit/67739754b0c6368ec1db878ace917730db102797)) +* **react:** add max-width column configuration to Table grids ([#1770](https://github.com/dequelabs/cauldron/issues/1770)) ([dc7e4ac](https://github.com/dequelabs/cauldron/commit/dc7e4ac43be846ee6faa6b1cf878753aed1b6a31)) +* **react:** add multiselect to Listbox component ([#1763](https://github.com/dequelabs/cauldron/issues/1763)) ([afa8fb4](https://github.com/dequelabs/cauldron/commit/afa8fb4f89d43e453eccfa2c39587e4fee0bc9cf)) +* **react:** remove focus-trap-react replacing with internally managed focus traps ([#1730](https://github.com/dequelabs/cauldron/issues/1730)) ([775bed0](https://github.com/dequelabs/cauldron/commit/775bed0e4becc55af34e69495ee0b6e3db3a11be)) + + +### Bug Fixes + +* **react:** remove defaultProps from sidebar, use default parameters instead ([#1764](https://github.com/dequelabs/cauldron/issues/1764)) ([e4a3441](https://github.com/dequelabs/cauldron/commit/e4a3441d8497f656dbec6afa35a989a139d430e7)) + ## [6.12.0](https://github.com/dequelabs/cauldron/compare/v6.11.0...v6.12.0) (2024-12-04) diff --git a/docs/pages/components/AnchoredOverlay.mdx b/docs/pages/components/AnchoredOverlay.mdx new file mode 100644 index 000000000..13bd6cfd4 --- /dev/null +++ b/docs/pages/components/AnchoredOverlay.mdx @@ -0,0 +1,189 @@ +--- +title: AnchoredOverlay +description: A component that displays an anchored layered element relative to a target element. +source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/AnchoredOverlay/index.tsx +--- + +import { useRef, useState } from 'react' +import { Select, Button, AnchoredOverlay } from '@deque/cauldron-react' +export const placements = [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'auto', + 'auto-start', + 'auto-end' +] + +```jsx +import { AnchoredOverlay } from '@deque/cauldron-react' +``` + +Under the hood, `AnchoredOverlay` uses [floating-ui](https://floating-ui.com/) to dynamically position an overlay element relative to a target element. It is intentionally un-styled to be composed with other components, such as [Tooltip]('./Tooltip'), [Popover](./Popover), or via more complex overlay components. + + + `AnchoredOverlay` is a positioning component and does not include built-in accessibility features like ARIA attributes, focus management, or keyboard interactions that would be needed for components like tooltips, dialogs, or popovers. When using `AnchoredOverlay`, you'll need to implement these accessibility patterns yourself based on your specific use case. + + +## Examples + +### Placement + +By default, initial placement is set to `auto` when it is not set via props. However the placement can [dynamically change](https://floating-ui.com/docs/autoplacement) when using `auto` or [flip](https://floating-ui.com/docs/flip) when using positional placement. + +If there are presentation elements that are dependent on the position of the `AnchoredOverlay`, you should use `onPlacementChange` to keep these presentation elements in sync with any updated placements. + +```jsx example +function AnchoredOverlayExample() { + const [placement, setPlacement] = useState('top') + const [open, setOpen] = useState(false) + const targetRef = useRef() + const handlePlacementChange = ({ target }) => setPlacement(target.value); + const toggleOpen = () => setOpen(!open) + const handleClose = () => setOpen(false) + + return ( + <> + ({ value: placement }))} + onChange={handlePlacementChange} + /> + + setOpen(openState)} + offset={20} + style={{ + padding: 'var(--space-small)', + backgroundColor: 'var(--panel-background-color)', + display: open ? 'block' : 'none' + }} + > + Anchored Overlay Element with offset placement {placement} + + + ) +} +``` + +## Props + +', 'React.RefObject'], + required: true, + description: 'A target element or ref to attach the overlay anchor element.' + }, + { + name: 'placement', + type: 'string', + defaultValue: 'auto', + description: 'Positional placement value to anchor the overlay element relative to its anchored target.' + }, + { + name: 'open', + type: 'boolean', + defaultValue: 'false', + description: 'Determines if the overlay anchor is currently visible.' + }, + { + name: 'onOpenChange', + type: '(open: boolean) => void', + description: 'A callback function that is called when the overlay state changes.' + }, + { + name: 'onPlacementChange', + type: '(placement: Placement) => void', + description: 'A callback function that is called when the placement of the overlay changes.' + }, + { + name: 'offset', + type: 'number', + description: 'An optional offset number to position the anchor element from its anchored target.' + }, + { + name: 'as', + type: 'React.ElementType', + defaultValue: 'div', + description: 'The element type to render as.' + } + ]} +/> + +## Related Components + +- [Tooltip](./Tooltip) +- [Popover](./Popover) + diff --git a/docs/pages/components/Listbox.mdx b/docs/pages/components/Listbox.mdx index fe62efec1..f7200b2eb 100644 --- a/docs/pages/components/Listbox.mdx +++ b/docs/pages/components/Listbox.mdx @@ -102,7 +102,7 @@ function ControlledListboxExample() { One Two @@ -128,6 +128,25 @@ Uncontrolled listboxes will automatically set `aria-selected="true"` for the sel ``` +### Multiselect + +Listboxes can also support multiple selection of listbox options. + +```jsx example +<> +
Multiselect Listbox
+ + One + Two + Three + + +``` + + + Multiselect Listbox components will pass in array values for the selected options in `onSelectionChange` and expect an array of values for `value` and `defaultValue` props. + + ## Props ### Listbox @@ -180,6 +199,11 @@ Uncontrolled listboxes will automatically set `aria-selected="true"` for the sel type: 'boolean', description: 'When set, sets the listbox option as "aria-disabled="true" and removes the element from key navigation.' }, + { + name: 'selected', + type: 'boolean', + description: 'When set, sets the listbox option as "aria-selected="true".' + }, { name: 'activeClass', type: 'string', diff --git a/docs/pages/components/Popover.mdx b/docs/pages/components/Popover.mdx index 5ec2fa27f..4d720a093 100644 --- a/docs/pages/components/Popover.mdx +++ b/docs/pages/components/Popover.mdx @@ -13,7 +13,7 @@ import { Popover, Button } from '@deque/cauldron-react' ## Examples -Cauldron's tooltip relies on [Popper](https://popper.js.org/) to position tooltips dynamically. Popover can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. +Cauldron's tooltip relies on [Floating UI](https://floating-ui.com/) to position tooltips dynamically. Popover can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. ### Prompt Popover diff --git a/docs/pages/components/Scrim.mdx b/docs/pages/components/Scrim.mdx index eaff4732a..83561bf0a 100644 --- a/docs/pages/components/Scrim.mdx +++ b/docs/pages/components/Scrim.mdx @@ -13,7 +13,7 @@ import { Scrim } from '@deque/cauldron-react' A scrim is a component that is a semi-transparent overlay over the viewport to provide emphasis, focus, or draw attention to certain information. This is most commonly used as a backdrop for dialogs, modals, and other elements that need primary focus on the page. - It is recommended that a focus trap is used to contain keyboard focus within the element that has primary focus. [Alert](./Alert) and [Modal](./Modal) both use [react-focus-trap](https://www.npmjs.com/package/focus-trap-react) to manage focus. If `Scrim` is being used with other components, a focus trap may be needed to prevent certain keyboard accessibility issues. + It is recommended that a focus trap is used to contain keyboard focus within the element that has primary focus. If `Scrim` is being used with other components, a focus trap (among other AT considerations) may be needed to prevent certain keyboard accessibility issues. ## Example diff --git a/docs/pages/components/Table.mdx b/docs/pages/components/Table.mdx index a81007b69..2da3381d9 100644 --- a/docs/pages/components/Table.mdx +++ b/docs/pages/components/Table.mdx @@ -336,7 +336,7 @@ function SortableTableExample() { ### Grid Layout -The Table component supports an optional css grid layout that can specify column alignment and width definitions per column. +The Table component supports an optional css grid layout that can specify column alignment and width and max-width definitions per column. ```jsx example diff --git a/docs/pages/components/Tooltip.mdx b/docs/pages/components/Tooltip.mdx index 822715511..a15ff5f51 100644 --- a/docs/pages/components/Tooltip.mdx +++ b/docs/pages/components/Tooltip.mdx @@ -13,7 +13,7 @@ import { Tooltip } from '@deque/cauldron-react' ## Examples -Cauldron's tooltip relies on [Popper](https://popper.js.org/) to position tooltips dynamically. Tooltips can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. +Cauldron's tooltip relies on [Floating UI](https://floating-ui.com/) to position tooltips dynamically. Tooltips can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. diff --git a/package.json b/package.json index 3260c6b0f..49c556d7e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cauldron", "private": true, - "version": "6.12.0", + "version": "6.13.0", "license": "MPL-2.0", "scripts": { "clean": "rimraf dist docs/dist", diff --git a/packages/react/package.json b/packages/react/package.json index 333ff7361..e5a504c56 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@deque/cauldron-react", - "version": "6.12.0", + "version": "6.13.0", "license": "MPL-2.0", "description": "Fully accessible react components library for Deque Cauldron", "homepage": "https://cauldron.dequelabs.com/", @@ -23,13 +23,11 @@ "test": "jest --maxWorkers=1 --coverage" }, "dependencies": { - "@popperjs/core": "^2.5.4", + "@floating-ui/react-dom": "^2.1.2", "classnames": "^2.2.6", - "focus-trap-react": "^10.2.3", "focusable": "^2.3.0", "keyname": "^0.1.0", "react-id-generator": "^3.0.1", - "react-popper": "^2.2.4", "react-syntax-highlighter": "^15.5.0", "tslib": "^2.4.0" }, diff --git a/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx new file mode 100644 index 000000000..4fdbd385b --- /dev/null +++ b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AnchoredOverlay from './'; +import axe from '../../axe'; + +test('should render children', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Hello World + + ); + expect(screen.getByText('Hello World')).toBeInTheDocument(); +}); + +test('should support className prop', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toHaveClass('custom'); +}); + +test('should support as prop for polymorphic rendering', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay').tagName).toBe('SPAN'); +}); + +test('should support auto placement', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toBeInTheDocument(); +}); + +test('should support auto-start placement', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toBeInTheDocument(); +}); + +test('should support auto-end placement', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toBeInTheDocument(); +}); + +test('should call onOpenChange when escape is pressed', async () => { + const targetRef = { current: document.createElement('button') }; + const onOpenChange = jest.fn(); + const user = userEvent.setup(); + + render( + + Content + + ); + + await user.keyboard('{Escape}'); + expect(onOpenChange).toHaveBeenCalledWith(false); +}); + +test('should call onPlacementChange with initial placement', () => { + const targetRef = { current: document.createElement('button') }; + const onPlacementChange = jest.fn(); + + render( + + Content + + ); + + expect(onPlacementChange).toHaveBeenCalledWith('top'); +}); + +test('should support ref prop', () => { + const targetRef = { current: document.createElement('button') }; + const ref = React.createRef(); + + render( + + Content + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toEqual(screen.getByTestId('overlay')); +}); + +test('should return no axe violations when opened', async () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + + const results = await axe(screen.getByTestId('overlay')); + expect(results).toHaveNoViolations(); +}); + +test('should return no axe violations when not open', async () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + + const results = await axe(screen.getByTestId('overlay')); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/react/src/components/AnchoredOverlay/index.tsx b/packages/react/src/components/AnchoredOverlay/index.tsx new file mode 100644 index 000000000..74b40379d --- /dev/null +++ b/packages/react/src/components/AnchoredOverlay/index.tsx @@ -0,0 +1,118 @@ +import { autoUpdate, type Placement } from '@floating-ui/dom'; +import React, { forwardRef, useEffect } from 'react'; +import { + useFloating, + offset as offsetMiddleware, + flip as flipMiddleware, + autoPlacement as autoPlacementMiddleware +} from '@floating-ui/react-dom'; +import { type PolymorphicProps } from '../../utils/polymorphicComponent'; +import resolveElement from '../../utils/resolveElement'; +import useSharedRef from '../../utils/useSharedRef'; +import useEscapeKey from '../../utils/useEscapeKey'; + +type AnchoredOverlayProps< + Overlay extends HTMLElement, + Target extends HTMLElement +> = { + /** A target element or ref to attach the overlay anchor element. */ + target: Target | React.MutableRefObject | React.RefObject; + /** Positional placement value to anchor the overlay element relative to its anchored target. */ + placement?: Placement | 'auto' | 'auto-start' | 'auto-end'; + /** Determines if the overlay anchor is currently visible. */ + open?: boolean; + /** A callback function that is called when the overlay state changes. */ + onOpenChange?: (open: boolean) => void; + /** A callback function that is called when the placement of the overlay changes. */ + onPlacementChange?: (placement: Placement) => void; + /** An optional offset number to position the anchor element from its anchored target. */ + offset?: number; + children?: React.ReactNode; +} & PolymorphicProps>; + +function getAutoAlignment( + placement: 'auto' | 'auto-start' | 'auto-end' +): 'start' | 'end' | null { + switch (placement) { + case 'auto-start': + return 'start'; + case 'auto-end': + return 'end'; + default: + return null; + } +} + +const AnchoredOverlay = forwardRef( + < + Overlay extends HTMLElement = HTMLElement, + Target extends HTMLElement = HTMLElement + >( + { + as, + placement: initialPlacement = 'auto', + target, + children, + style, + open = false, + offset, + onOpenChange, + onPlacementChange, + ...props + }: AnchoredOverlayProps, + refProp: React.Ref + ) => { + const ref = useSharedRef(refProp); + const Component = as || 'div'; + const { floatingStyles, placement } = useFloating({ + open, + // default to initial placement on top when placement is auto + // @ts-expect-error auto placement is not a valid placement for floating-ui + placement: initialPlacement.startsWith('auto') ? 'top' : initialPlacement, + middleware: [ + offsetMiddleware(offset ?? 0), + initialPlacement.startsWith('auto') + ? autoPlacementMiddleware({ + alignment: getAutoAlignment(initialPlacement as 'auto') + }) + : flipMiddleware() + ].filter(Boolean), + elements: { + reference: resolveElement(target), + floating: ref.current + }, + whileElementsMounted: autoUpdate + }); + + useEscapeKey({ + active: open, + capture: true, + defaultPrevented: true, + callback: (event: KeyboardEvent) => { + // when an anchored overlay is open, we want to prevent other potential "escape" + // keypress events, like the closing of modals from occurring + event.preventDefault(); + // istanbul ignore else + if (typeof onOpenChange === 'function') { + onOpenChange(!open); + } + } + }); + + useEffect(() => { + if (typeof onPlacementChange === 'function') { + onPlacementChange(placement); + } + }, [placement]); + + return ( + + {children} + + ); + } +); + +AnchoredOverlay.displayName = 'AnchoredOverlay'; + +export default AnchoredOverlay; diff --git a/packages/react/src/components/BottomSheet/BottomSheet.test.tsx b/packages/react/src/components/BottomSheet/BottomSheet.test.tsx index d2b8159e9..d05745284 100644 --- a/packages/react/src/components/BottomSheet/BottomSheet.test.tsx +++ b/packages/react/src/components/BottomSheet/BottomSheet.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import BottomSheet from './'; import axe from '../../axe'; @@ -9,53 +9,56 @@ afterEach(() => { jest.restoreAllMocks(); }); -test('should render label', () => { +test('should render label', async () => { render( Children ); - expect(screen.getByText('Title')).toBeInTheDocument(); + const label = await screen.findByText('Title'); + expect(label).toBeInTheDocument(); }); -test('should render children', () => { +test('should render children', async () => { render( Hello World ); - expect(screen.getByText('Hello World')).toBeInTheDocument(); + const content = await screen.findByText('Hello World'); + expect(content).toBeInTheDocument(); }); -test('should support className prop', () => { +test('should support className prop', async () => { render( Children ); - expect(screen.getByTestId('bottomsheet').firstElementChild).toHaveClass( - 'BottomSheet', - 'bananas' - ); + const bottomsheet = await screen.findByTestId('bottomsheet'); + expect(bottomsheet.firstElementChild).toHaveClass('BottomSheet', 'bananas'); }); -test('should support open prop', () => { +test('should support open prop', async () => { const { rerender } = render( Children ); - const bottomsheet = screen.getByTestId('bottomsheet'); + const bottomsheet = await screen.findByTestId('bottomsheet'); expect(bottomsheet).not.toHaveClass('Drawer--open'); expect(bottomsheet).not.toBeVisible(); + rerender( Children ); - expect(bottomsheet).toHaveClass('Drawer--open'); - expect(bottomsheet).toBeVisible(); + + const openBottomsheet = await screen.findByTestId('bottomsheet'); + expect(openBottomsheet).toHaveClass('Drawer--open'); + expect(openBottomsheet).toBeVisible(); }); test('should call onClose prop on esc keypress', async () => { @@ -86,28 +89,33 @@ test('should call onClose prop on click outside', async () => { expect(onClose).toHaveBeenCalled(); }); -test('should set focus to bottom sheet by default when opened', () => { +test('should set focus to bottom sheet by default when opened', async () => { const { rerender } = render( Children ); - expect(screen.getByTestId('bottomsheet')).not.toHaveFocus(); + const initialSheet = await screen.findByTestId('bottomsheet'); + expect(initialSheet).not.toHaveFocus(); + rerender( Children ); - expect(screen.getByTestId('bottomsheet').firstElementChild).toHaveFocus(); + + const openSheet = await screen.findByTestId('bottomsheet'); + expect(openSheet.firstElementChild).toHaveFocus(); }); -test('should set focus to focusable element when opened', () => { +test('should set focus to focusable element when opened', async () => { const { rerender } = render( ); + // button is initially hidden, but we can still get it to ensure it has focus const button = screen.getAllByRole('button', { hidden: true })[1]; @@ -123,11 +131,13 @@ test('should set focus to focusable element when opened', () => { ); - expect(screen.getByTestId('bottomsheet')).not.toHaveFocus(); + + const sheet = await screen.findByTestId('bottomsheet'); + expect(sheet).not.toHaveFocus(); expect(button).toHaveFocus(); }); -test('should set focus to custom element when opened', () => { +test('should set focus to custom element when opened', async () => { const ref = React.createRef(); const { rerender } = render( { ); - expect(screen.getByTestId('bottomsheet')).not.toHaveFocus(); - expect(screen.getByRole('button', { name: 'focus me' })).toHaveFocus(); + + const sheet = await screen.findByTestId('bottomsheet'); + const focusButton = await screen.findByRole('button', { name: 'focus me' }); + expect(sheet).not.toHaveFocus(); + expect(focusButton).toHaveFocus(); }); -test('should set focus to custom ref element', () => { +test('should set focus to custom ref element', async () => { const ref = React.createRef(); const { rerender } = render( { ); - expect(screen.getByTestId('bottomsheet')).not.toHaveFocus(); + + const sheet = await screen.findByTestId('bottomsheet'); + expect(sheet).not.toHaveFocus(); expect(ref.current).toHaveFocus(); }); -test('should return focus to triggering element when closed', () => { +test('should return focus to triggering element when closed', async () => { const { rerender } = render( <> @@ -193,8 +208,8 @@ test('should return focus to triggering element when closed', () => { ); - // ensure the trigger element is initially focused - screen.getByRole('button', { name: 'trigger' }).focus(); + const trigger = await screen.findByRole('button', { name: 'trigger' }); + trigger.focus(); rerender( <> @@ -204,6 +219,7 @@ test('should return focus to triggering element when closed', () => { ); + rerender( <> @@ -213,10 +229,11 @@ test('should return focus to triggering element when closed', () => { ); - expect(screen.getByRole('button', { name: 'trigger' })).toHaveFocus(); + const finalTrigger = await screen.findByRole('button', { name: 'trigger' }); + expect(finalTrigger).toHaveFocus(); }); -test('should return focus to custom element when closed', () => { +test('should return focus to custom element when closed', async () => { const button = document.createElement('button'); document.body.appendChild(button); @@ -229,6 +246,7 @@ test('should return focus to custom element when closed', () => { Children ); + rerender( { Children ); + rerender( { ); - expect(button).toHaveFocus(); + waitFor(() => { + expect(button).toHaveFocus(); + }); }); -test('should support ref prop', () => { +test('should support ref prop', async () => { const ref = React.createRef(); render( @@ -260,10 +281,11 @@ test('should support ref prop', () => { ); - expect(ref.current).toBeInstanceOf(HTMLDivElement); - expect(ref.current).toEqual( - screen.getByTestId('bottomsheet').firstElementChild - ); + waitFor(async () => { + const bottomsheet = await screen.findByTestId('bottomsheet'); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toEqual(bottomsheet.firstElementChild); + }); }); test('should return no axe violations when open', async () => { @@ -273,7 +295,8 @@ test('should return no axe violations when open', async () => { ); - const results = await axe(screen.getByTestId('bottomsheet')); + const bottomsheet = await screen.findByTestId('bottomsheet'); + const results = await axe(bottomsheet); expect(results).toHaveNoViolations(); }); @@ -284,6 +307,7 @@ test('should return no axe violations when closed', async () => { ); - const results = await axe(screen.getByTestId('bottomsheet')); + const bottomsheet = await screen.findByTestId('bottomsheet'); + const results = await axe(bottomsheet); expect(results).toHaveNoViolations(); }); diff --git a/packages/react/src/components/Checkbox/Checkbox.test.tsx b/packages/react/src/components/Checkbox/Checkbox.test.tsx index 7bf17fd97..bcdc65f54 100644 --- a/packages/react/src/components/Checkbox/Checkbox.test.tsx +++ b/packages/react/src/components/Checkbox/Checkbox.test.tsx @@ -1,5 +1,5 @@ import React, { createRef } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { spy } from 'sinon'; import { axe } from 'jest-axe'; @@ -97,18 +97,20 @@ test('should handle focus correctly', async () => { expect(onFocus.calledOnce).toBeTruthy(); }); -test('should handle blur correctly', () => { +test('should handle blur correctly', async () => { const onBlur = spy(); - const input = renderCheckbox({ onBlur, checked: true }); + const input = await renderCheckbox({ onBlur, checked: true }); const checkboxIcon = input.parentElement!.querySelector( '.Checkbox__overlay' ) as HTMLElement; expect(checkboxIcon).not.toHaveClass('.Checkbox__overlay--focused'); expect(onBlur.notCalled).toBeTruthy(); - input.focus(); - input.blur(); - expect(input).not.toHaveFocus(); + await waitFor(() => { + input.focus(); + input.blur(); + expect(input).not.toHaveFocus(); + }); expect(checkboxIcon).not.toHaveClass('Checkbox__overlay--focused'); expect(onBlur.calledOnce).toBeTruthy(); }); diff --git a/packages/react/src/components/Code/Code.test.tsx b/packages/react/src/components/Code/Code.test.tsx index fc4947049..819e8f015 100644 --- a/packages/react/src/components/Code/Code.test.tsx +++ b/packages/react/src/components/Code/Code.test.tsx @@ -10,7 +10,7 @@ const sandbox = createSandbox(); beforeEach(() => { global.ResizeObserver = global.ResizeObserver || (() => null); sandbox.stub(global, 'ResizeObserver').callsFake((listener) => { - listener(); + listener([]); return { observe: sandbox.stub(), disconnect: sandbox.stub() diff --git a/packages/react/src/components/Combobox/ComboboxOption.tsx b/packages/react/src/components/Combobox/ComboboxOption.tsx index cdf9a50ee..ddd01c19b 100644 --- a/packages/react/src/components/Combobox/ComboboxOption.tsx +++ b/packages/react/src/components/Combobox/ComboboxOption.tsx @@ -79,8 +79,11 @@ const ComboboxOption = forwardRef( }); const isActive = !!active?.element && active.element === comboboxOptionRef.current; - const isSelected = - !!selected?.element && selected.element === comboboxOptionRef.current; + const isSelected = !!( + selected && + !!selected[0]?.element && + selected[0].element === comboboxOptionRef.current + ); const isMatching = (typeof matches === 'boolean' && matches) || (typeof matches === 'function' && matches(children)); diff --git a/packages/react/src/components/Dialog/index.tsx b/packages/react/src/components/Dialog/index.tsx index 28e286a42..b18f869a1 100644 --- a/packages/react/src/components/Dialog/index.tsx +++ b/packages/react/src/components/Dialog/index.tsx @@ -1,14 +1,14 @@ -import React from 'react'; +import React, { useRef, useEffect, useCallback, forwardRef } from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; -import FocusTrap from 'focus-trap-react'; import Offscreen from '../Offscreen'; import Icon from '../Icon'; import ClickOutsideListener from '../ClickOutsideListener'; import AriaIsolate from '../../utils/aria-isolate'; -import setRef from '../../utils/setRef'; -import nextId from 'react-id-generator'; +import { useId } from 'react-id-generator'; import { isBrowser } from '../../utils/is-browser'; +import useSharedRef from '../../utils/useSharedRef'; +import useFocusTrap from '../../utils/useFocusTrap'; export interface DialogProps extends React.HTMLAttributes { children: React.ReactNode; @@ -28,196 +28,157 @@ export interface DialogProps extends React.HTMLAttributes { portal?: React.RefObject | HTMLElement; } -interface DialogState { - isolator?: AriaIsolate; -} - const isEscape = (event: KeyboardEvent) => event.key === 'Escape' || event.key === 'Esc' || event.keyCode === 27; -const noop = () => { - //not empty -}; - -export default class Dialog extends React.Component { - static defaultProps = { - onClose: noop, - forceAction: false, - closeButtonText: 'Close' - }; - - private element: HTMLDivElement | null; - private heading: HTMLHeadingElement | null; - private headingId: string = nextId('dialog-title-'); - - constructor(props: DialogProps) { - super(props); - - this.close = this.close.bind(this); - this.focusHeading = this.focusHeading.bind(this); - this.handleClickOutside = this.handleClickOutside.bind(this); - this.handleEscape = this.handleEscape.bind(this); - this.state = {}; - } - - componentDidMount() { - if (this.props.show) { - this.attachEventListeners(); - this.attachIsolator(() => setTimeout(this.focusHeading)); - } - } - - componentWillUnmount() { - const { isolator } = this.state; - isolator?.deactivate(); - this.removeEventListeners(); - } - - componentDidUpdate(prevProps: DialogProps) { - if (!prevProps.show && this.props.show) { - this.attachIsolator(this.focusHeading); - this.attachEventListeners(); - } else if (prevProps.show && !this.props.show) { - this.removeEventListeners(); - this.close(); - } - } - - private attachIsolator(done: () => void) { - this.setState( - { - isolator: new AriaIsolate(this.element as HTMLElement) - }, - done - ); - } - - render() { - const { - dialogRef, - forceAction, +const Dialog = forwardRef( + ( + { + dialogRef: dialogRefProp, + forceAction = false, className, children, - closeButtonText, + closeButtonText = 'Close', heading, - show, + show = false, + portal, + onClose = () => null, ...other - } = this.props; + }, + ref + ): React.ReactPortal | null => { + const dialogRef = useSharedRef(dialogRefProp || ref); + const [headingId] = useId(1, 'dialog-title-'); + const headingRef = useRef(null); + const isolatorRef = useRef(); + + const handleClose = useCallback(() => { + isolatorRef.current?.deactivate(); + if (show) { + onClose(); + } + }, [show, onClose]); + + const handleClickOutside = useCallback(() => { + if (show && !forceAction) { + handleClose(); + } + }, [show, forceAction, handleClose]); + + const focusHeading = useCallback(() => { + headingRef.current?.focus(); + isolatorRef.current?.activate(); + }, []); + + const handleEscape = useCallback( + (keyboardEvent: KeyboardEvent) => { + if (!keyboardEvent.defaultPrevented && isEscape(keyboardEvent)) { + handleClose(); + } + }, + [handleClose] + ); + + useEffect(() => { + if (!show || !dialogRef.current) return; + + isolatorRef.current = new AriaIsolate(dialogRef.current); + setTimeout(focusHeading); + + return () => { + isolatorRef.current?.deactivate(); + }; + }, [show, focusHeading]); + + useEffect(() => { + if (!forceAction) { + const portalElement = portal + ? 'current' in portal + ? portal.current + : portal + : document.body; + + if (show) { + portalElement?.addEventListener('keyup', handleEscape); + } + + return () => { + portalElement?.removeEventListener('keyup', handleEscape); + }; + } + }, [show, forceAction, portal, handleEscape]); + + useFocusTrap(dialogRef, { + disabled: !show, + initialFocusElement: headingRef + }); if (!show || !isBrowser()) { return null; } - const portal = this.props.portal || document.body; + const portalElement = portal + ? 'current' in portal + ? portal.current + : portal + : // eslint-disable-next-line ssr-friendly/no-dom-globals-in-react-fc + document.body; - const close = !forceAction ? ( - ) : null; - const Heading = `h${ - typeof heading === 'object' && 'level' in heading && !!heading.level + const HeadingLevel = `h${ + typeof heading === 'object' && 'level' in heading && heading.level ? heading.level : 2 }` as 'h1'; - const Dialog = ( - - -
{ - this.element = el; - if (!dialogRef) { - return; - } - setRef(dialogRef, el); - }} - aria-labelledby={this.headingId} - {...other} - > -
-
- (this.heading = el)} - tabIndex={-1} - id={this.headingId} - > - {typeof heading === 'object' && 'text' in heading - ? heading.text - : heading} - - {close} -
- {children} + const dialog = ( + +
+
+
+ + {typeof heading === 'object' && 'text' in heading + ? heading.text + : heading} + + {closeButton}
+ {children}
- - +
+
); return createPortal( - Dialog, - ('current' in portal ? portal.current : portal) || document.body - ) as React.JSX.Element; - } - - close() { - this.state.isolator?.deactivate(); - if (this.props.show) { - this.props.onClose?.(); - } - } - - handleClickOutside() { - const { show, forceAction } = this.props; - if (show && !forceAction) { - this.close(); - } - } - - focusHeading() { - if (this.heading) { - this.heading.focus(); - } - this.state.isolator?.activate(); + dialog, + // eslint-disable-next-line ssr-friendly/no-dom-globals-in-react-fc + portalElement || document.body + ) as React.ReactPortal; } +); - private handleEscape(keyboardEvent: KeyboardEvent) { - if (!keyboardEvent.defaultPrevented && isEscape(keyboardEvent)) { - this.close(); - } - } +Dialog.displayName = 'Dialog'; - private attachEventListeners() { - const { forceAction } = this.props; - if (!forceAction) { - const portal = this.props.portal || document.body; - const targetElement = - portal instanceof HTMLElement ? portal : portal.current; - targetElement?.addEventListener('keyup', this.handleEscape); - } - } - - private removeEventListeners() { - const portal = this.props.portal || document.body; - const targetElement = - portal instanceof HTMLElement ? portal : portal.current; - targetElement?.removeEventListener('keyup', this.handleEscape); - } -} +export default Dialog; interface DialogAlignmentProps { align?: 'left' | 'center' | 'right'; @@ -266,4 +227,5 @@ const DialogFooter = ({
); DialogFooter.displayName = 'DialogFooter'; + export { Dialog, DialogContent, DialogFooter }; diff --git a/packages/react/src/components/Drawer/Drawer.test.tsx b/packages/react/src/components/Drawer/Drawer.test.tsx index 2dcd86eb9..4de141bbc 100644 --- a/packages/react/src/components/Drawer/Drawer.test.tsx +++ b/packages/react/src/components/Drawer/Drawer.test.tsx @@ -305,8 +305,7 @@ test('should not trap focus when behavior is non-modal', async () => { 'aria-hidden', 'true' ); - await user.keyboard('{Tab}'); - expect(document.body).toHaveFocus(); + document.body.focus(); await user.keyboard('{Tab}'); expect(screen.getByRole('button', { name: 'outside' })).toHaveFocus(); await user.keyboard('{Tab}'); @@ -320,7 +319,7 @@ test('should return no axe violations when open', async () => { ); - const results = await axe(screen.getByTestId('drawer')); + const results = await axe(await screen.findByTestId('drawer')); expect(results).toHaveNoViolations(); }); @@ -331,6 +330,6 @@ test('should return no axe violations when closed', async () => { ); - const results = await axe(screen.getByTestId('drawer')); + const results = await axe(await screen.findByTestId('drawer')); expect(results).toHaveNoViolations(); }); diff --git a/packages/react/src/components/Drawer/index.tsx b/packages/react/src/components/Drawer/index.tsx index 525e2e12b..18a74c1cf 100644 --- a/packages/react/src/components/Drawer/index.tsx +++ b/packages/react/src/components/Drawer/index.tsx @@ -1,19 +1,18 @@ +import type { ElementOrRef } from '../../types'; import React, { forwardRef, useState, useEffect, - useLayoutEffect, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; -import FocusTrap from 'focus-trap-react'; import classnames from 'classnames'; import Scrim from '../Scrim'; import ClickOutsideListener from '../ClickOutsideListener'; import useEscapeKey from '../../utils/useEscapeKey'; import useSharedRef from '../../utils/useSharedRef'; -import focusableSelector from '../../utils/focusable-selector'; +import useFocusTrap from '../../utils/useFocusTrap'; import resolveElement from '../../utils/resolveElement'; import AriaIsolate from '../../utils/aria-isolate'; import { isBrowser } from '../../utils/is-browser'; @@ -25,8 +24,8 @@ export interface DrawerProps open?: boolean; behavior?: 'modal' | 'non-modal'; focusOptions?: { - initialFocus?: T | React.RefObject | React.MutableRefObject; - returnFocus?: T | React.RefObject | React.MutableRefObject; + initialFocus?: ElementOrRef; + returnFocus?: ElementOrRef; }; onClose?: () => void; portal?: React.RefObject | HTMLElement; @@ -50,9 +49,6 @@ const Drawer = forwardRef( ) => { const drawerRef = useSharedRef(ref); const openRef = useRef(!!open); - const previousActiveElementRef = useRef( - null - ) as React.MutableRefObject; const { initialFocus: focusInitial, returnFocus: focusReturn } = focusOptions; const [isTransitioning, setIsTransitioning] = useState(!!open); @@ -101,36 +97,6 @@ const Drawer = forwardRef( }; }, [isModal, open]); - useLayoutEffect(() => { - if (open) { - previousActiveElementRef.current = - document.activeElement as HTMLElement; - - const initialFocusElement = resolveElement(focusInitial); - if (initialFocusElement) { - initialFocusElement.focus(); - } else { - const focusable = drawerRef.current?.querySelector( - focusableSelector - ) as HTMLElement; - if (focusable) { - focusable.focus(); - } else { - // fallback focus - drawerRef.current?.focus(); - } - } - } else if (previousActiveElementRef.current) { - const returnFocusElement = resolveElement(focusReturn); - if (returnFocusElement) { - returnFocusElement.focus(); - } else { - // fallback focus - previousActiveElementRef.current?.focus(); - } - } - }, [open, focusInitial, focusReturn]); - useEscapeKey( { callback: handleClose, active: open, defaultPrevented: true }, [onClose] @@ -141,6 +107,13 @@ const Drawer = forwardRef( return null; } + useFocusTrap(drawerRef, { + disabled: !isModal || !open, + initialFocusElement: focusInitial || drawerRef, + returnFocus: true, + returnFocusElement: focusReturn + }); + const portalElement = resolveElement(portal); return createPortal( @@ -151,37 +124,25 @@ const Drawer = forwardRef( touchEvent={open ? undefined : false} target={drawerRef} > - drawerRef.current +
-
- {children} -
- + {children} +
, diff --git a/packages/react/src/components/IconButton/IconButton.test.tsx b/packages/react/src/components/IconButton/IconButton.test.tsx index ed406b2a0..5d459e7ea 100644 --- a/packages/react/src/components/IconButton/IconButton.test.tsx +++ b/packages/react/src/components/IconButton/IconButton.test.tsx @@ -1,11 +1,11 @@ import React, { createRef } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { axe } from 'jest-axe'; import IconButton from './'; -it('should render button', () => { +test('should render button', async () => { render(); - const button = screen.getByRole('button', { name: 'Edit' }); + const button = await screen.findByRole('button', { name: 'Edit' }); expect(button).toBeInTheDocument(); expect(button).toHaveAttribute('type', 'button'); expect(button).toHaveAttribute('tabIndex', '0'); @@ -13,51 +13,52 @@ it('should render button', () => { expect(button).toHaveTextContent(''); }); -it('should render secondary variant', () => { +test('should render secondary variant', async () => { render(); - const button = screen.getByRole('button', { name: 'Edit' }); + const button = await screen.findByRole('button', { name: 'Edit' }); expect(button).toHaveClass('IconButton--secondary'); }); -it('should render primary variant', () => { +test('should render primary variant', async () => { render(); - const button = screen.getByRole('button', { name: 'Edit' }); + const button = await screen.findByRole('button', { name: 'Edit' }); expect(button).toHaveClass('IconButton--primary'); }); -it('should render tertiary variant', () => { +test('should render tertiary variant', async () => { render(); - const button = screen.getByRole('button', { name: 'Edit' }); + const button = await screen.findByRole('button', { name: 'Edit' }); expect(button).toHaveClass('IconButton--tertiary'); }); -it('should render error variant', () => { +test('should render error variant', async () => { render(); - const button = screen.getByRole('button', { name: 'Edit' }); + const button = await screen.findByRole('button', { name: 'Edit' }); expect(button).toHaveClass('IconButton--error'); }); -it('should render a "as" an anchor', () => { +test('should render a "as" an anchor', async () => { render(); - const button = screen.queryByRole('link', { name: 'Edit' }); + const button = await screen.findByRole('link', { name: 'Edit' }); expect(button).toBeInTheDocument(); expect(button).not.toHaveAttribute('role'); }); -it('should be disabled', () => { +test('should be disabled', async () => { render(); - expect(screen.queryByRole('button')).toBeDisabled(); + expect(await screen.findByRole('button')).toBeDisabled(); }); -it('should use aria-disabled for non-buttons when disabled', () => { +test('should use aria-disabled for non-buttons when disabled', async () => { render( ); - expect(screen.queryByRole('link')).not.toBeDisabled(); - expect(screen.queryByRole('link')).toHaveAttribute('aria-disabled', 'true'); + const link = await screen.findByRole('link'); + expect(link).not.toBeDisabled(); + expect(link).toHaveAttribute('aria-disabled', 'true'); }); -it('should add button role for custom components', () => { +test('should add button role for custom components', async () => { const CustomButton = React.forwardRef(function Component( props, ref @@ -65,12 +66,13 @@ it('should add button role for custom components', () => { return
; }); render(); - expect(screen.getByTestId('custom')).toBeInTheDocument(); - expect(screen.getByTestId('custom')).toHaveAttribute('role', 'button'); - expect(screen.getByTestId('custom')).toHaveAttribute('tabIndex', '0'); + const custom = await screen.findByTestId('custom'); + expect(custom).toBeInTheDocument(); + expect(custom).toHaveAttribute('role', 'button'); + expect(custom).toHaveAttribute('tabIndex', '0'); }); -it('should add link role when component behaves like a link', () => { +test('should add link role when component behaves like a link', async () => { const CustomLink = React.forwardRef(function Component( props, ref @@ -81,31 +83,38 @@ it('should add link role when component behaves like a link', () => { // @ts-expect-error this technically should be allowed ); - expect(screen.getByTestId('custom')).toBeInTheDocument(); - expect(screen.getByTestId('custom')).toHaveAttribute('role', 'link'); - expect(screen.getByTestId('custom')).toHaveAttribute('tabIndex', '0'); + const custom = await screen.findByTestId('custom'); + expect(custom).toBeInTheDocument(); + expect(custom).toHaveAttribute('role', 'link'); + expect(custom).toHaveAttribute('tabIndex', '0'); }); -it('should not render tooltip when disabled prop is true', () => { +test('should not render tooltip when disabled prop is true', () => { render(); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); expect(screen.queryByRole('button')).toHaveAttribute('tabIndex', '-1'); expect(screen.queryByRole('button')).toHaveAccessibleName('Edit'); }); -it('should support className prop', () => { +test('should support className prop', async () => { render(); - expect(screen.queryByRole('button')).toHaveClass('IconButton', 'bananas'); + expect(await screen.findByRole('button')).toHaveClass( + 'IconButton', + 'bananas' + ); }); -it('should support ref prop', () => { +test('should support ref prop', async () => { const ref = createRef(); render(); + waitFor(() => { + expect(screen.getByRole('button')).toBeInTheDocument(); + }); expect(ref.current).toBeTruthy(); expect(ref.current).toEqual(screen.queryByRole('button')); }); -it('should support tooltipProps', () => { +test('should support tooltipProps', async () => { render( <>
custom name
@@ -120,18 +129,19 @@ it('should support tooltipProps', () => { // Note: this test is a bit obtuse since by default Tooltip overrides // aria-labelledby so we're testing the "none" association to ensure // we can set our own custom aria-label when necessary - expect(screen.queryByRole('button')).toHaveAccessibleName('custom name'); + expect(await screen.findByRole('button')).toHaveAccessibleName('custom name'); }); test('should return no axe violations', async () => { render(); - const results = await axe(screen.getByRole('button')); + const results = await axe(await screen.findByRole('button')); expect(results).toHaveNoViolations(); }); test('should return no axe violations when rendered as anchor', async () => { render(); - const results = await axe(screen.getByRole('link')); + const button = await screen.findByRole('link'); + const results = await axe(button); expect(results).toHaveNoViolations(); }); @@ -142,7 +152,9 @@ test('should return no axe violations when rendered as CustomElement', async () ) { return
; }); + render(); - const results = await axe(screen.getByTestId('custom')); + const button = await screen.findByTestId('custom'); + const results = await axe(button); expect(results).toHaveNoViolations(); }); diff --git a/packages/react/src/components/Listbox/Listbox.tsx b/packages/react/src/components/Listbox/Listbox.tsx index 7be14b7a1..def6c872e 100644 --- a/packages/react/src/components/Listbox/Listbox.tsx +++ b/packages/react/src/components/Listbox/Listbox.tsx @@ -16,20 +16,34 @@ import useSharedRef from '../../utils/useSharedRef'; const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ']; -interface ListboxProps +interface BaseListboxProps extends PolymorphicProps< - Omit, 'onSelect'> + Omit, 'onSelect' | 'defaultValue'> > { - value?: ListboxValue; navigation?: 'cycle' | 'bound'; - onSelectionChange?: ({ - value - }: { + onActiveChange?: (option: ListboxOption) => void; +} + +interface SingleSelectListboxProps extends BaseListboxProps { + multiselect?: false; + value?: ListboxValue; + defaultValue?: ListboxValue; + onSelectionChange?: (props: { target: T; previousValue: ListboxValue; value: ListboxValue; }) => void; - onActiveChange?: (option: ListboxOption) => void; +} + +interface MultiSelectListboxProps extends BaseListboxProps { + multiselect: true; + value?: ListboxValue[]; + defaultValue?: ListboxValue[]; + onSelectionChange?: (props: { + target: T; + previousValue: ListboxValue[]; + value: ListboxValue[]; + }) => void; } // id for listbox options should always be defined since it should @@ -45,7 +59,10 @@ const optionMatchesValue = (option: ListboxOption, value: unknown): boolean => typeof option.value !== 'undefined' && option.value === value; -const Listbox = forwardRef( +const Listbox = forwardRef< + HTMLElement, + SingleSelectListboxProps | MultiSelectListboxProps +>( ( { as: Component = 'ul', @@ -53,6 +70,7 @@ const Listbox = forwardRef( defaultValue, value, navigation = 'bound', + multiselect = false, onKeyDown, onFocus, onSelectionChange, @@ -65,25 +83,36 @@ const Listbox = forwardRef( const [activeOption, setActiveOption] = useState( null ); - const [selectedOption, setSelectedOption] = useState( - null - ); + const [selectedOptions, setSelectedOptions] = useState([]); const listboxRef = useSharedRef(ref); const isControlled = typeof value !== 'undefined'; useLayoutEffect(() => { - if (!isControlled && selectedOption) { + if (!isControlled && selectedOptions.length > 0) { return; } const listboxValue = isControlled ? value : defaultValue; - const matchingOption = options.find((option) => - optionMatchesValue(option, listboxValue) - ); + if (!listboxValue) { + return; + } - setSelectedOption(matchingOption || null); - setActiveOption(matchingOption || null); - }, [isControlled, options, value]); + if (multiselect) { + const matchingOptions = options.filter((option) => + (listboxValue as ListboxValue[]).find((value) => + optionMatchesValue(option, value) + ) + ); + setSelectedOptions(matchingOptions); + setActiveOption(matchingOptions[0] || null); + } else { + const matchingOption = options.find((option) => + optionMatchesValue(option, listboxValue) + ); + setSelectedOptions(matchingOption ? [matchingOption] : []); + setActiveOption(matchingOption || null); + } + }, [isControlled, options, value, defaultValue]); useEffect(() => { if (activeOption) { @@ -94,17 +123,56 @@ const Listbox = forwardRef( const handleSelect = useCallback( (option: ListboxOption) => { setActiveOption(option); + const optionIsSelected = selectedOptions.some( + (selected) => selected.element === option.element + ); + const previousValues = selectedOptions.map( + (selected) => selected.value + ); + // istanbul ignore else if (!isControlled) { - setSelectedOption(option); + if (!multiselect) { + setSelectedOptions([option]); + } else { + setSelectedOptions( + optionIsSelected + ? [ + ...selectedOptions.filter( + (selected) => selected.element !== option.element + ) + ] + : [...selectedOptions, option] + ); + } + } + + if (multiselect) { + (onSelectionChange as MultiSelectListboxProps['onSelectionChange'])?.( + { + target: option.element, + value: optionIsSelected + ? selectedOptions + .filter( + (selectedOption) => + selectedOption.element !== option.element + ) + .map((selectedOption) => selectedOption.value) + : [...previousValues, option.value], + previousValue: previousValues + } + ); + } else { + ( + onSelectionChange as SingleSelectListboxProps['onSelectionChange'] + )?.({ + target: option.element, + value: option.value, + previousValue: selectedOptions[0]?.value + }); } - onSelectionChange?.({ - target: option.element, - value: option.value, - previousValue: selectedOption?.value - }); }, - [isControlled, selectedOption] + [isControlled, selectedOptions, multiselect, onSelectionChange] ); const handleKeyDown = useCallback( @@ -170,12 +238,12 @@ const Listbox = forwardRef( break; } }, - [options, activeOption, navigation] + [options, activeOption, navigation, handleSelect] ); const handleFocus = useCallback( (event: React.FocusEvent) => { - if (!activeOption && !selectedOption) { + if (!activeOption) { const firstOption = options.find( (option) => !isDisabledOption(option) ); @@ -184,13 +252,16 @@ const Listbox = forwardRef( setActiveOption(firstOption); } // istanbul ignore else - } else if (event.target === listboxRef.current) { - setActiveOption(selectedOption); + } else if ( + selectedOptions.length && + event.target === listboxRef.current + ) { + setActiveOption(selectedOptions[selectedOptions.length - 1]); } onFocus?.(event); }, - [options, activeOption, selectedOption] + [options, activeOption, selectedOptions] ); return ( @@ -200,6 +271,7 @@ const Listbox = forwardRef( tabIndex="0" onKeyDown={handleKeyDown} onFocus={handleFocus} + aria-multiselectable={multiselect ? true : undefined} aria-activedescendant={ activeOption ? getOptionId(activeOption) : undefined } @@ -208,7 +280,8 @@ const Listbox = forwardRef( @@ -217,7 +290,7 @@ const Listbox = forwardRef( ); } -) as PolymorphicComponent; +) as PolymorphicComponent; Listbox.displayName = 'Listbox'; diff --git a/packages/react/src/components/Listbox/ListboxContext.tsx b/packages/react/src/components/Listbox/ListboxContext.tsx index df8ed0f3a..69c33ef46 100644 --- a/packages/react/src/components/Listbox/ListboxContext.tsx +++ b/packages/react/src/components/Listbox/ListboxContext.tsx @@ -10,7 +10,8 @@ type ListboxOption = { type ListboxContext = { options: T[]; active: T | null; - selected: T | null; + selected: T[] | null; + multiselect: boolean; setOptions: React.Dispatch>; onSelect: (option: T) => void; }; @@ -24,6 +25,7 @@ const ListboxContext = createContext({ options: [], active: null, selected: null, + multiselect: false, setOptions: () => null, onSelect: () => null }); @@ -32,6 +34,7 @@ function ListboxProvider({ options, active, selected, + multiselect, setOptions, onSelect, children @@ -44,10 +47,11 @@ function ListboxProvider({ options, active, selected, + multiselect, setOptions, onSelect }), - [options, active, selected, setOptions] + [options, active, selected, multiselect, setOptions] ); return {children}; diff --git a/packages/react/src/components/Listbox/ListboxOption.tsx b/packages/react/src/components/Listbox/ListboxOption.tsx index fc0eb46f0..2c353d30d 100644 --- a/packages/react/src/components/Listbox/ListboxOption.tsx +++ b/packages/react/src/components/Listbox/ListboxOption.tsx @@ -14,6 +14,7 @@ interface ListboxOptionProps extends PolymorphicProps> { value?: ListboxValue; disabled?: boolean; + selected?: boolean; activeClass?: string; } @@ -30,6 +31,7 @@ const ListboxOption = forwardRef( children, value, disabled, + selected: selectedProp, activeClass = 'ListboxOption--active', onClick, ...props @@ -39,10 +41,14 @@ const ListboxOption = forwardRef( const { active, selected, setOptions, onSelect } = useListboxContext(); const listboxOptionRef = useSharedRef(ref); const [id] = propId ? [propId] : useId(1, 'listbox-option'); - const isActive = - active !== null && active.element === listboxOptionRef.current; + const isActive = active?.element === listboxOptionRef.current; const isSelected = - selected !== null && selected.element === listboxOptionRef.current; + typeof selectedProp === 'boolean' + ? selectedProp + : selected !== null && + !!selected.find( + (option) => option.element === listboxOptionRef.current + ); const optionValue = typeof value !== 'undefined' ? value @@ -98,7 +104,7 @@ const ListboxOption = forwardRef( onSelect({ element: listboxOptionRef.current, value: optionValue }); onClick?.(event); }, - [optionValue] + [optionValue, onSelect, onClick, disabled] ); return ( diff --git a/packages/react/src/components/Listbox/index.test.tsx b/packages/react/src/components/Listbox/index.test.tsx index 2f97f88a7..7f3a3e699 100644 --- a/packages/react/src/components/Listbox/index.test.tsx +++ b/packages/react/src/components/Listbox/index.test.tsx @@ -568,11 +568,214 @@ test('should retain selected value when options changes with defaultValue', () = assertListItemIsSelected(2); }); +test('should render multiselect listbox', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + expect(screen.getByRole('listbox')).toHaveAttribute( + 'aria-multiselectable', + 'true' + ); +}); + +test('should allow multiple selections in uncontrolled multiselect listbox', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + fireEvent.click(screen.getByRole('option', { name: 'Apple' })); + fireEvent.click(screen.getByRole('option', { name: 'Banana' })); + + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', { name: 'Cantaloupe' })).toHaveAttribute( + 'aria-selected', + 'false' + ); +}); + +test('should handle deselection in multiselect listbox', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + const appleOption = screen.getByRole('option', { name: 'Apple' }); + + // Select then deselect + fireEvent.click(appleOption); + expect(appleOption).toHaveAttribute('aria-selected', 'true'); + + fireEvent.click(appleOption); + expect(appleOption).toHaveAttribute('aria-selected', 'false'); +}); + +test('should handle deselection selection with multiple selected options in multiselect listbox', () => { + const handleSelectionChange = jest.fn(); + + render( + + Apple + Banana + Cantaloupe + + ); + + const listbox = screen.getByRole('listbox'); + fireEvent.focus(listbox); + fireEvent.keyDown(listbox, { key: 'Enter' }); + + // the most recently selected item should be the initial active one + expect(handleSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: ['Apple'], + previousValue: ['Apple', 'Banana'] + }) + ); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute( + 'aria-selected', + 'false' + ); +}); + +test('should handle controlled multiselect selection', () => { + const handleSelectionChange = jest.fn(); + + render( + + Apple + Banana + Cantaloupe + + ); + + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + + fireEvent.click(screen.getByRole('option', { name: 'Cantaloupe' })); + + expect(handleSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: ['Apple', 'Banana', 'Cantaloupe'], + previousValue: ['Apple', 'Banana'] + }) + ); +}); + +test('should set initial values with defaultValue in multiselect', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListItemIsSelected(0); + assertListItemIsSelected(1); + expect(screen.getByRole('option', { name: 'Cantaloupe' })).toHaveAttribute( + 'aria-selected', + 'false' + ); +}); + +test('should handle keyboard selection in multiselect', () => { + const handleSelectionChange = jest.fn(); + + render( + + Apple + Banana + Cantaloupe + + ); + + const listbox = screen.getByRole('listbox'); + fireEvent.focus(listbox); + + // Move to first item and select + simulateKeypress('ArrowDown'); + fireEvent.keyDown(listbox, { key: 'Enter' }); + + // Move to second item and select + simulateKeypress('ArrowDown'); + fireEvent.keyDown(listbox, { key: ' ' }); + + expect(handleSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: ['Banana', 'Cantaloupe'], + previousValue: ['Banana'] + }) + ); + assertListItemIsSelected(1); + assertListItemIsSelected(2); +}); + test('should return no axe violations', async () => { const { container } = render( <>
Colors and Numbers
- + + + Red + Green + Blue + + + One + Two + Three + + + + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); + +test('should return no axe violations with multiselect', async () => { + const { container } = render( + <> +
Colors and Numbers
+ Red Green diff --git a/packages/react/src/components/LoaderOverlay/index.tsx b/packages/react/src/components/LoaderOverlay/index.tsx index 58dfca511..304f5136d 100644 --- a/packages/react/src/components/LoaderOverlay/index.tsx +++ b/packages/react/src/components/LoaderOverlay/index.tsx @@ -1,9 +1,9 @@ import React, { forwardRef, useEffect } from 'react'; -import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; import Loader from '../Loader'; import AxeLoader from './axe-loader'; import AriaIsolate from '../../utils/aria-isolate'; +import useFocusTrap from '../../utils/useFocusTrap'; import useSharedRef from '../../utils/useSharedRef'; export interface LoaderOverlayProps @@ -48,33 +48,25 @@ const LoaderOverlay = forwardRef( } }, []); - const Wrapper = focusTrap ? FocusTrap : React.Fragment; - const wrapperProps = focusTrap - ? { - focusTrapOptions: { - fallbackFocus: '.Loader__overlay' - } - } - : {}; + useFocusTrap(overlayRef, { + disabled: !focusTrap, + initialFocusElement: overlayRef + }); return ( - -
-
- - -
- {label ? ( - {label} - ) : null} - {children} +
+
+ +
- + {label ? {label} : null} + {children} +
); } ); diff --git a/packages/react/src/components/MenuItem/index.test.tsx b/packages/react/src/components/MenuItem/index.test.tsx index 2c6d4a19c..62978e8ce 100644 --- a/packages/react/src/components/MenuItem/index.test.tsx +++ b/packages/react/src/components/MenuItem/index.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import sinon from 'sinon'; -import { screen, render } from '@testing-library/react'; +import { screen, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MenuItem from '../MenuItem'; import { axe } from 'jest-axe'; @@ -29,6 +29,7 @@ test('calls onClick prop', async () => { await user.click(screen.getByText('BOOGNISH')); expect(onClick.calledOnce).toBeTruthy(); }); + test('clicks the menuitem given enter/space keydowns', async () => { const onClick = sinon.spy(); const handleKeyDown = (event: React.KeyboardEvent) => { @@ -67,6 +68,9 @@ test('should return no axe violations', async () => { Foo ); + await waitFor(() => { + expect(container).toBeInTheDocument(); + }); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/packages/react/src/components/Pagination/Pagination.tsx b/packages/react/src/components/Pagination/Pagination.tsx index 65358cdd2..5b8d3098c 100644 --- a/packages/react/src/components/Pagination/Pagination.tsx +++ b/packages/react/src/components/Pagination/Pagination.tsx @@ -1,6 +1,6 @@ import React from 'react'; import classNames from 'classnames'; -import { Placement } from '@popperjs/core'; +import type AnchoredOverlay from '../AnchoredOverlay'; import IconButton from '../IconButton'; import { ContentNode } from '../../types'; @@ -18,7 +18,7 @@ interface Props extends React.HTMLAttributes { onPreviousPageClick?: (event: React.MouseEvent) => void; onFirstPageClick?: (event: React.MouseEvent) => void; onLastPageClick?: (event: React.MouseEvent) => void; - tooltipPlacement?: Placement; + tooltipPlacement?: React.ComponentProps['placement']; thin?: boolean; className?: string; } diff --git a/packages/react/src/components/Pagination/index.test.tsx b/packages/react/src/components/Pagination/index.test.tsx index b154f2885..defabf46d 100644 --- a/packages/react/src/components/Pagination/index.test.tsx +++ b/packages/react/src/components/Pagination/index.test.tsx @@ -1,16 +1,21 @@ import React, { useState } from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + act +} from '@testing-library/react'; import Pagination, { usePagination } from './'; import axe from '../../axe'; -test('should set `thin` variant when thin prop is provided', () => { +test('should set `thin` variant when thin prop is provided', async () => { render(); - expect(screen.getByRole('list').parentElement).toHaveClass( - 'Pagination--thin' - ); + const list = await screen.findByRole('list'); + expect(list.parentElement).toHaveClass('Pagination--thin'); }); -test('should disable first/prev page buttons', () => { +test('should disable first/prev page buttons', async () => { const onFirstPageClick = jest.fn(); const onPreviousPageClick = jest.fn(); @@ -23,31 +28,23 @@ test('should disable first/prev page buttons', () => { /> ); - expect(screen.getAllByRole('button')[0]).toHaveAttribute( - 'aria-disabled', - 'true' - ); - expect(screen.getAllByRole('button')[1]).toHaveAttribute( - 'aria-disabled', - 'true' - ); - expect(screen.getAllByRole('button')[2]).toHaveAttribute( - 'aria-disabled', - 'false' - ); - expect(screen.getAllByRole('button')[3]).toHaveAttribute( - 'aria-disabled', - 'false' - ); + const buttons = await screen.findAllByRole('button'); + + expect(buttons[0]).toHaveAttribute('aria-disabled', 'true'); + expect(buttons[1]).toHaveAttribute('aria-disabled', 'true'); + expect(buttons[2]).toHaveAttribute('aria-disabled', 'false'); + expect(buttons[3]).toHaveAttribute('aria-disabled', 'false'); - fireEvent.click(screen.getAllByRole('button')[0]); - fireEvent.click(screen.getAllByRole('button')[1]); + await act(async () => { + fireEvent.click(buttons[0]); + fireEvent.click(buttons[1]); + }); expect(onFirstPageClick).not.toHaveBeenCalled(); expect(onPreviousPageClick).not.toHaveBeenCalled(); }); -test('should disable last/next page buttons', () => { +test('should disable last/next page buttons', async () => { const onNextPageClick = jest.fn(); const onLastPageClick = jest.fn(); @@ -60,40 +57,34 @@ test('should disable last/next page buttons', () => { /> ); - expect(screen.getAllByRole('button')[0]).toHaveAttribute( - 'aria-disabled', - 'false' - ); - expect(screen.getAllByRole('button')[1]).toHaveAttribute( - 'aria-disabled', - 'false' - ); - expect(screen.getAllByRole('button')[2]).toHaveAttribute( - 'aria-disabled', - 'true' - ); - expect(screen.getAllByRole('button')[3]).toHaveAttribute( - 'aria-disabled', - 'true' - ); + const buttons = await screen.findAllByRole('button'); - fireEvent.click(screen.getAllByRole('button')[2]); - fireEvent.click(screen.getAllByRole('button')[3]); + expect(buttons[0]).toHaveAttribute('aria-disabled', 'false'); + expect(buttons[1]).toHaveAttribute('aria-disabled', 'false'); + expect(buttons[2]).toHaveAttribute('aria-disabled', 'true'); + expect(buttons[3]).toHaveAttribute('aria-disabled', 'true'); + + await act(async () => { + fireEvent.click(buttons[2]); + fireEvent.click(buttons[3]); + }); expect(onNextPageClick).not.toHaveBeenCalled(); expect(onLastPageClick).not.toHaveBeenCalled(); }); -test('should support custom status label text', () => { +test('should support custom status label text', async () => { render( hello world
} /> ); - expect(screen.getByText('hello world')).toHaveAttribute('id', 'foo'); - expect(screen.getByRole('log')).toHaveTextContent('hello world'); + const element = await screen.findByText('hello world'); + expect(element).toHaveAttribute('id', 'foo'); + const log = await screen.findByRole('log'); + expect(log).toHaveTextContent('hello world'); }); -test('should call on { Next, Previous, First, Last } click as expected', () => { +test('should call on { Next, Previous, First, Last } click as expected', async () => { const onNextPageClick = jest.fn(); const onPreviousPageClick = jest.fn(); const onFirstPageClick = jest.fn(); @@ -111,37 +102,48 @@ test('should call on { Next, Previous, First, Last } click as expected', () => { /> ); - fireEvent.click(screen.getAllByRole('button')[0]); + const buttons = await screen.findAllByRole('button'); + + await act(async () => { + fireEvent.click(buttons[0]); + }); expect(onFirstPageClick).toHaveBeenCalledTimes(1); expect(onPreviousPageClick).not.toHaveBeenCalled(); expect(onNextPageClick).not.toHaveBeenCalled(); expect(onLastPageClick).not.toHaveBeenCalled(); - fireEvent.click(screen.getAllByRole('button')[1]); + await act(async () => { + fireEvent.click(buttons[1]); + }); expect(onFirstPageClick).toHaveBeenCalledTimes(1); expect(onPreviousPageClick).toHaveBeenCalledTimes(1); expect(onNextPageClick).not.toHaveBeenCalled(); expect(onLastPageClick).not.toHaveBeenCalled(); - fireEvent.click(screen.getAllByRole('button')[2]); + await act(async () => { + fireEvent.click(buttons[2]); + }); expect(onFirstPageClick).toHaveBeenCalledTimes(1); expect(onPreviousPageClick).toHaveBeenCalledTimes(1); expect(onNextPageClick).toHaveBeenCalledTimes(1); expect(onLastPageClick).not.toHaveBeenCalled(); - fireEvent.click(screen.getAllByRole('button')[3]); + await act(async () => { + fireEvent.click(buttons[3]); + }); expect(onFirstPageClick).toHaveBeenCalledTimes(1); expect(onPreviousPageClick).toHaveBeenCalledTimes(1); expect(onNextPageClick).toHaveBeenCalledTimes(1); expect(onLastPageClick).toHaveBeenCalledTimes(1); }); -test('should render the expected default status label', () => { +test('should render the expected default status label', async () => { render(); - expect(screen.getByRole('log')).toHaveTextContent('Showing 35 to 51 of 500'); + const log = await screen.findByRole('log'); + expect(log).toHaveTextContent('Showing 35 to 51 of 500'); }); -test('should initialize and handle pagesize change as expected', () => { +test('should initialize and handle pagesize change as expected', async () => { let testPagination = null; let testPageStatus = null; @@ -183,7 +185,11 @@ test('should initialize and handle pagesize change as expected', () => { pageEnd: 30 }); - fireEvent.click(screen.getAllByRole('button')[3]); + const buttons = await screen.findAllByRole('button'); + + await act(async () => { + fireEvent.click(buttons[3]); + }); expect(testPageStatus).toMatchObject({ currentPage: 4, @@ -191,7 +197,9 @@ test('should initialize and handle pagesize change as expected', () => { pageEnd: 40 }); - fireEvent.click(screen.getAllByRole('button')[0]); + await act(async () => { + fireEvent.click(buttons[0]); + }); expect(testPagination).toMatchObject({ itemsPerPage: 25, @@ -205,7 +213,7 @@ test('should initialize and handle pagesize change as expected', () => { }); }); -test('should initialize and call on { Next, Previous, First, Last } click as expected', () => { +test('should initialize and call on { Next, Previous, First, Last } click as expected', async () => { let testPagination; let testPageStatus; @@ -235,7 +243,11 @@ test('should initialize and call on { Next, Previous, First, Last } click as exp pageEnd: 30 }); - fireEvent.click(screen.getAllByRole('button')[1]); + const buttons = await screen.findAllByRole('button'); + + await act(async () => { + fireEvent.click(buttons[1]); + }); expect(testPageStatus).toMatchObject({ currentPage: 2, @@ -243,7 +255,9 @@ test('should initialize and call on { Next, Previous, First, Last } click as exp pageEnd: 20 }); - fireEvent.click(screen.getAllByRole('button')[2]); + await act(async () => { + fireEvent.click(buttons[2]); + }); expect(testPageStatus).toMatchObject({ currentPage: 3, @@ -251,7 +265,9 @@ test('should initialize and call on { Next, Previous, First, Last } click as exp pageEnd: 30 }); - fireEvent.click(screen.getAllByRole('button')[0]); + await act(async () => { + fireEvent.click(buttons[0]); + }); expect(testPageStatus).toMatchObject({ currentPage: 1, @@ -259,7 +275,9 @@ test('should initialize and call on { Next, Previous, First, Last } click as exp pageEnd: 10 }); - fireEvent.click(screen.getAllByRole('button')[3]); + await act(async () => { + fireEvent.click(buttons[3]); + }); expect(testPageStatus).toMatchObject({ currentPage: 50, @@ -270,23 +288,36 @@ test('should initialize and call on { Next, Previous, First, Last } click as exp test('returns no axe violations', async () => { const { container } = render(); + await waitFor(() => { + expect(container).toBeInTheDocument(); + }); expect(await axe(container)).toHaveNoViolations(); }); -test('should show start and end pagination buttons when hideStartEndPagination is not provided', () => { +test('should show start and end pagination buttons when hideStartEndPagination is not provided', async () => { render(); - expect(screen.getByText('First page')).toBeInTheDocument(); - expect(screen.getByText('Previous page')).toBeInTheDocument(); - expect(screen.getByText('Next page')).toBeInTheDocument(); - expect(screen.getByText('Last page')).toBeInTheDocument(); + const firstPage = await screen.findByText('First page'); + const previousPage = await screen.findByText('Previous page'); + const nextPage = await screen.findByText('Next page'); + const lastPage = await screen.findByText('Last page'); + + expect(firstPage).toBeInTheDocument(); + expect(previousPage).toBeInTheDocument(); + expect(nextPage).toBeInTheDocument(); + expect(lastPage).toBeInTheDocument(); }); -test('should hide start and end pagination buttons when hideStartEndPagination is true', () => { +test('should hide start and end pagination buttons when hideStartEndPagination is true', async () => { render(); - expect(screen.queryByText('First page')).not.toBeInTheDocument(); - expect(screen.queryByText('Last page')).not.toBeInTheDocument(); - expect(screen.getByText('Previous page')).toBeInTheDocument(); - expect(screen.getByText('Next page')).toBeInTheDocument(); + const firstPage = screen.queryByText('First page'); + const lastPage = screen.queryByText('Last page'); + const previousPage = await screen.findByText('Previous page'); + const nextPage = await screen.findByText('Next page'); + + expect(firstPage).not.toBeInTheDocument(); + expect(lastPage).not.toBeInTheDocument(); + expect(previousPage).toBeInTheDocument(); + expect(nextPage).toBeInTheDocument(); }); diff --git a/packages/react/src/components/Popover/index.test.tsx b/packages/react/src/components/Popover/index.test.tsx index fbbd74012..6de45744a 100644 --- a/packages/react/src/components/Popover/index.test.tsx +++ b/packages/react/src/components/Popover/index.test.tsx @@ -93,13 +93,13 @@ const WrapperPrompt = ({ test('renders without blowing up', async () => { render(); - expect(screen.getByText('Popover content')).toBeTruthy(); + expect(await screen.findByText('Popover content')).toBeTruthy(); }); test('should auto-generate id', async () => { render(); - const popover = screen.getByRole('dialog'); - const button = screen.getByText('Popover button'); + const popover = await screen.findByRole('dialog'); + const button = await screen.findByText('Popover button'); expect(popover).toBeTruthy(); const id = popover?.getAttribute('id'); expect(id).toBeTruthy(); @@ -126,7 +126,7 @@ test('should attach attribute aria-expanded correctly based on shown state', () test('should support adding className to tooltip', async () => { render(); - const popover = screen.getByRole('dialog'); + const popover = await screen.findByRole('dialog'); expect(popover).toBeTruthy(); expect(popover).toHaveClass('Popover'); expect(popover).toHaveClass('foo'); @@ -136,8 +136,8 @@ test('should not overwrite user provided id and aria-describedby', async () => { const buttonProps = { 'aria-describedby': 'foo popoverid' }; const tooltipProps = { id: 'popoverid' }; render(); - const popover = screen.getByRole('dialog'); - const button = screen.getByText('Popover button'); + const popover = await screen.findByRole('dialog'); + const button = await screen.findByText('Popover button'); expect(popover).toHaveAttribute('id', 'popoverid'); expect(button.getAttribute('aria-describedby')).toEqual('foo popoverid'); }); @@ -277,7 +277,7 @@ test('should have no axe violations for prompt variant', async () => { test('aria-labelledby should exist for variant="custom"', async () => { render(); - const popover = screen.getByRole('dialog'); + const popover = await screen.findByRole('dialog'); const ariaLabelledById = popover.getAttribute('aria-labelledby'); expect(ariaLabelledById).toBeTruthy(); diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx index 21e17097d..6e6492d7b 100644 --- a/packages/react/src/components/Popover/index.tsx +++ b/packages/react/src/components/Popover/index.tsx @@ -1,18 +1,16 @@ import React, { useState, useEffect, ReactNode, forwardRef, Ref } from 'react'; import { createPortal } from 'react-dom'; import { useId } from 'react-id-generator'; -import { Placement } from '@popperjs/core'; -import { usePopper } from 'react-popper'; import { isBrowser } from '../../utils/is-browser'; import { Cauldron } from '../../types'; import classnames from 'classnames'; +import AnchoredOverlay from '../AnchoredOverlay'; import ClickOutsideListener from '../ClickOutsideListener'; import Button from '../Button'; -import FocusTrap from 'focus-trap-react'; -import focusableSelector from '../../utils/focusable-selector'; import AriaIsolate from '../../utils/aria-isolate'; import useSharedRef from '../../utils/useSharedRef'; import useEscapeKey from '../../utils/useEscapeKey'; +import useFocusTrap from '../../utils/useFocusTrap'; export type PopoverVariant = 'prompt' | 'custom'; @@ -21,7 +19,7 @@ type BaseProps = React.HTMLAttributes & { variant?: PopoverVariant; show: boolean; onClose: () => void; - placement?: Placement; + placement?: React.ComponentProps['placement']; portal?: React.RefObject | HTMLElement; }; @@ -95,35 +93,12 @@ const Popover = forwardRef( ref: Ref ): React.JSX.Element | null => { const [id] = propId ? [propId] : useId(1, 'popover'); - const [targetElement, setTargetElement] = useState( null ); - const [isolator, setIsolator] = useState(null); - const popoverRef = useSharedRef(ref); - - const [arrowElement, setArrowElement] = useState(null); - - const { styles, attributes } = usePopper( - targetElement, - popoverRef?.current, - { - placement: initialPlacement, - modifiers: [ - { name: 'preventOverflow', options: { padding: 8 } }, - { name: 'flip' }, - { name: 'offset', options: { offset: [0, 8] } }, - { name: 'arrow', options: { padding: 5, element: arrowElement } } - ] - } - ); - - const placement: Placement = - (attributes.popper && - (attributes.popper['data-popper-placement'] as Placement)) || - initialPlacement; + const [placement, setPlacement] = useState(initialPlacement); const additionalProps = variant === 'prompt' && !props['aria-label'] @@ -162,18 +137,8 @@ const Popover = forwardRef( }, [popoverRef.current]); useEffect(() => { - if (show && popoverRef.current) { - // Find the first focusable element inside the container - const firstFocusableElement = - popoverRef.current.querySelector(focusableSelector); - - if (firstFocusableElement instanceof HTMLElement) { - firstFocusableElement.focus(); - } - } - targetElement?.setAttribute('aria-expanded', Boolean(show).toString()); - }, [show, popoverRef.current]); + }, [show]); useEffect(() => { const attrText = targetElement?.getAttribute('aria-controls'); @@ -218,55 +183,49 @@ const Popover = forwardRef( [show] ); + useFocusTrap(popoverRef, { disabled: !show, returnFocus: true }); + if (!show || !isBrowser()) return null; return createPortal( - - -
diff --git a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx index c79d89161..ff306830c 100644 --- a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx +++ b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx @@ -9,7 +9,7 @@ const sandbox = createSandbox(); beforeEach(() => { global.ResizeObserver = global.ResizeObserver || (() => null); sandbox.stub(global, 'ResizeObserver').callsFake((callback) => { - callback(); + callback([]); return { observe: sandbox.stub(), disconnect: sandbox.stub() @@ -52,7 +52,7 @@ test('should display tooltip with overflow', async () => { act(() => { button.focus(); }); - expect(screen.queryByRole('tooltip')).toBeInTheDocument(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); }); test('should not display tooltip with no multiline overflow', () => { @@ -68,7 +68,7 @@ test('should not display tooltip with no multiline overflow', () => { expect(screen.getByTestId('text-ellipsis')).not.toHaveAttribute('tabindex'); }); -test('should display tooltip with multiline overflow', () => { +test('should display tooltip with multiline overflow', async () => { sandbox.stub(global.HTMLDivElement.prototype, 'clientHeight').value(100); sandbox.stub(global.HTMLDivElement.prototype, 'scrollHeight').value(200); render(Hello World); @@ -81,7 +81,7 @@ test('should display tooltip with multiline overflow', () => { act(() => { button.focus(); }); - expect(screen.queryByRole('tooltip')).toBeInTheDocument(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); }); test('should support className prop', () => { diff --git a/packages/react/src/components/Toast/toast.test.tsx b/packages/react/src/components/Toast/toast.test.tsx index abc905798..2cf1c5ee3 100644 --- a/packages/react/src/components/Toast/toast.test.tsx +++ b/packages/react/src/components/Toast/toast.test.tsx @@ -98,13 +98,15 @@ Object.entries(toastTypes).forEach(([key, value]) => { ); - // wait for animation tiemouts / async setState calls - await setTimeout(undefined, () => { - expect(screen.getByTestId('toast')).toHaveClass( - 'Toast', - 'Toast--info', - 'is--hidden' - ); + // wait for animation timeouts / async setState calls + await waitFor(async () => { + await setTimeout(undefined, () => { + expect(screen.getByTestId('toast')).toHaveClass( + 'Toast', + 'Toast--info', + 'is--hidden' + ); + }); }); }); @@ -241,6 +243,9 @@ test('non-dismissible toast has no accessibility issues', async () => { ); + waitFor(() => { + expect(container).toBeInTheDocument(); + }); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/packages/react/src/components/Tooltip/Tooltip.test.tsx b/packages/react/src/components/Tooltip/Tooltip.test.tsx index 2c56f42bb..2236f7837 100644 --- a/packages/react/src/components/Tooltip/Tooltip.test.tsx +++ b/packages/react/src/components/Tooltip/Tooltip.test.tsx @@ -1,11 +1,11 @@ import React, { createRef } from 'react'; -import { setTimeout } from 'timers/promises'; import { render, screen, fireEvent, - getByRole, - waitFor + findByRole, + waitFor, + act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { spy } from 'sinon'; @@ -40,48 +40,58 @@ const renderTooltip = ({ ); }; -test('should render tooltip', () => { +test('should render tooltip', async () => { renderTooltip(); expect( - screen.getByRole('tooltip', { name: 'Hello Tooltip' }) + await screen.findByRole('tooltip', { name: 'Hello Tooltip' }) ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'button' }) + await screen.findByRole('button', { name: 'button' }) ).toHaveAccessibleDescription('Hello Tooltip'); }); -test('should auto generate ids', () => { +test('should auto generate ids', async () => { renderTooltip(); - const button = screen.getByRole('button'); - const tooltip = screen.getByRole('tooltip'); + const button = await screen.findByRole('button'); + const tooltip = await screen.findByRole('tooltip'); expect(tooltip.getAttribute('id')).toBeTruthy(); expect(tooltip.getAttribute('id')).toEqual( button.getAttribute('aria-describedby') ); }); -test('should not overwrite user provided ids', () => { +test('should not overwrite user provided ids', async () => { const buttonProps = { [`aria-describedby`]: 'foo tooltipid' }; const tooltipProps = { id: 'tooltipid' }; renderTooltip({ buttonProps, tooltipProps }); - expect(screen.getByRole('tooltip').getAttribute('id')).toEqual('tooltipid'); - expect(screen.getByRole('button').getAttribute('aria-describedby')).toEqual( - 'foo tooltipid' + expect((await screen.findByRole('tooltip')).getAttribute('id')).toEqual( + 'tooltipid' ); + expect( + (await screen.findByRole('button')).getAttribute('aria-describedby') + ).toEqual('foo tooltipid'); }); test('should show tooltip on target element focus', async () => { renderTooltip({ tooltipProps: { defaultShow: false } }); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); - await fireEvent.focusIn(screen.getByRole('button')); - expect(screen.queryByRole('tooltip')).toBeInTheDocument(); + await act(async () => { + await fireEvent.focusIn(screen.getByRole('button')); + }); + waitFor(() => { + expect(screen.queryByRole('tooltip')).toBeInTheDocument(); + }); }); test('should show tooltip on target element hover', async () => { renderTooltip({ tooltipProps: { defaultShow: false } }); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); - await fireEvent.mouseEnter(screen.getByRole('button')); - expect(screen.queryByRole('tooltip')).toBeInTheDocument(); + await act(async () => { + await fireEvent.mouseEnter(screen.getByRole('button')); + }); + waitFor(() => { + expect(screen.queryByRole('tooltip')).toBeInTheDocument(); + }); }); test('should hide tooltip on target element blur', async () => { @@ -111,9 +121,9 @@ test('should fire the "cauldron:tooltip:show" custom event when tooltip is shown const show = spy(); renderTooltip(); - const button = screen.getByRole('button'); + const button = await screen.findByRole('button'); button.addEventListener('cauldron:tooltip:show', show); - await fireEvent.focusIn(screen.getByRole('button')); + await fireEvent.focusIn(button); await waitFor(() => { expect(show.calledOnce).toBeTruthy(); @@ -124,28 +134,29 @@ test('should fire the "cauldron:tooltip:hide" custom event when tooltip is hidde const hide = spy(); renderTooltip(); - const button = screen.getByRole('button'); + const button = await screen.findByRole('button'); button.addEventListener('cauldron:tooltip:hide', hide); - await fireEvent.focusOut(screen.getByRole('button')); + await fireEvent.focusOut(button); await waitFor(() => { expect(hide.calledOnce).toBeTruthy(); }); }); -test('should support className prop', () => { +test('should support className prop', async () => { renderTooltip({ tooltipProps: { className: 'bananas' } }); - expect(screen.getByRole('tooltip')).toHaveClass('Tooltip', 'bananas'); + expect(await screen.findByRole('tooltip')).toHaveClass('Tooltip', 'bananas'); }); -test('should support portal prop', () => { +test('should support portal prop', async () => { const portal = document.createElement('div'); renderTooltip({ tooltipProps: { portal } }); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); - expect(getByRole(portal, 'tooltip')).toBeTruthy(); + const tooltipInPortal = await findByRole(portal, 'tooltip'); + expect(tooltipInPortal).toBeTruthy(); }); -test('should support show prop', () => { +test('should support show prop', async () => { const ShowTooltip = ({ show }: { show?: boolean }) => { const ref = createRef(); return ( @@ -161,23 +172,26 @@ test('should support show prop', () => { }; const { rerender } = render(); - expect(screen.queryByRole('tooltip')).toBeInTheDocument(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); rerender(); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); test('should support association prop', async () => { renderTooltip({ tooltipProps: { association: 'aria-labelledby' } }); - expect(screen.queryByRole('button')).toHaveAccessibleName('Hello Tooltip'); + expect(await screen.findByRole('button')).toHaveAccessibleName( + 'Hello Tooltip' + ); }); -test('should not add association when association is set to "none"', () => { +test('should not add association when association is set to "none"', async () => { renderTooltip({ tooltipProps: { association: 'none' } }); - expect(screen.queryByRole('button')).not.toHaveProperty('aria-describedby'); - expect(screen.queryByRole('button')).not.toHaveProperty('aria-labelledby'); + const button = await screen.findByRole('button'); + expect(button).not.toHaveProperty('aria-describedby'); + expect(button).not.toHaveProperty('aria-labelledby'); }); -test('should clean up association when tooltip is no longer rendered', () => { +test('should clean up association when tooltip is no longer rendered', async () => { const ShowTooltip = ({ show = true }: { show?: boolean }) => { const ref = createRef(); return ( @@ -192,23 +206,29 @@ test('should clean up association when tooltip is no longer rendered', () => { ); }; const { rerender } = render(); - expect(screen.getByRole('button').getAttribute('aria-describedby')).toContain( - 'tooltip' - ); + expect( + (await screen.findByRole('button')).getAttribute('aria-describedby') + ).toContain('tooltip'); rerender(); expect( - screen.getByRole('button').getAttribute('aria-describedby') + (await screen.findByRole('button')).getAttribute('aria-describedby') ).not.toContain('tooltip'); }); test('should return no axe violations with default variant', async () => { const { container } = renderTooltip(); + waitFor(() => { + expect(container).toBeInTheDocument(); + }); const results = await axe(container); expect(results).toHaveNoViolations(); }); test('should return no axe violations with info variant', async () => { const { container } = renderTooltip({ tooltipProps: { variant: 'info' } }); + waitFor(() => { + expect(container).toBeInTheDocument(); + }); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -223,6 +243,9 @@ test('should return no axe violations with big variant', async () => { const { container } = renderTooltip({ tooltipProps: { variant: 'big', children } }); + waitFor(() => { + expect(container).toBeInTheDocument(); + }); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/packages/react/src/components/Tooltip/index.tsx b/packages/react/src/components/Tooltip/index.tsx index 72a1b638e..5815d2623 100644 --- a/packages/react/src/components/Tooltip/index.tsx +++ b/packages/react/src/components/Tooltip/index.tsx @@ -2,11 +2,10 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import classnames from 'classnames'; import { createPortal } from 'react-dom'; import { useId } from 'react-id-generator'; -import { Placement } from '@popperjs/core'; -import { usePopper } from 'react-popper'; +import AnchoredOverlay from '../AnchoredOverlay'; import { isBrowser } from '../../utils/is-browser'; import { addIdRef, hasIdRef, removeIdRef } from '../../utils/idRefs'; -import useEscapeKey from '../../utils/useEscapeKey'; +import resolveElement from '../../utils/resolveElement'; const TIP_HIDE_DELAY = 100; @@ -18,7 +17,7 @@ export interface TooltipProps extends React.HTMLAttributes { association?: 'aria-labelledby' | 'aria-describedby' | 'none'; show?: boolean | undefined; defaultShow?: boolean; - placement?: Placement; + placement?: React.ComponentProps['placement']; portal?: React.RefObject | HTMLElement; hideElementOnHidden?: boolean; } @@ -57,52 +56,27 @@ export default function Tooltip({ const [id] = propId ? [propId] : useId(1, 'tooltip'); const hideTimeoutRef = useRef | null>(null); const [showTooltip, setShowTooltip] = useState(!!showProp || defaultShow); - const [targetElement, setTargetElement] = useState(null); const [tooltipElement, setTooltipElement] = useState( null ); - const [arrowElement, setArrowElement] = useState(null); + const [placement, setPlacement] = useState(initialPlacement); const hasAriaAssociation = association !== 'none'; - const { styles, attributes, update } = usePopper( - targetElement, - tooltipElement, - { - placement: initialPlacement, - modifiers: [ - { name: 'preventOverflow', options: { padding: 8 } }, - { - name: 'flip', - options: { fallbackPlacements: ['left', 'right', 'top', 'bottom'] } - }, - { name: 'offset', options: { offset: [0, 8] } }, - { name: 'arrow', options: { padding: 5, element: arrowElement } } - ] - } - ); - // Show the tooltip const show: EventListener = useCallback(async () => { + const targetElement = resolveElement(target); // Clear the hide timeout if there was one pending if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } - // Make sure we update the tooltip position when showing - // in case the target's position changed without popper knowing - if (update) { - await update(); - } setShowTooltip(true); fireCustomEvent(true, targetElement); - }, [ - targetElement, - // update starts off as null - update - ]); + }, [target]); // Hide the tooltip const hide: EventListener = useCallback(() => { + const targetElement = resolveElement(target); if (!hideTimeoutRef.current) { hideTimeoutRef.current = setTimeout(() => { hideTimeoutRef.current = null; @@ -114,13 +88,6 @@ export default function Tooltip({ return () => { clearTimeout(hideTimeoutRef.current as unknown as number); }; - }, [targetElement]); - - // Keep targetElement in sync with target prop - useEffect(() => { - const targetElement = - target && 'current' in target ? target.current : target; - setTargetElement(targetElement); }, [target]); useEffect(() => { @@ -129,27 +96,9 @@ export default function Tooltip({ } }, [showProp]); - // Get popper placement - const placement: Placement = - (attributes.popper && - (attributes.popper['data-popper-placement'] as Placement)) || - initialPlacement; - - // Only listen to key ups when the tooltip is visible - useEscapeKey( - { - callback: (event) => { - event.preventDefault(); - setShowTooltip(false); - }, - capture: true, - active: showTooltip && typeof showProp !== 'boolean' - }, - [setShowTooltip] - ); - // Handle hover and focus events for the targetElement useEffect(() => { + const targetElement = resolveElement(target); if (typeof showProp !== 'boolean') { targetElement?.addEventListener('mouseenter', show); targetElement?.addEventListener('mouseleave', hide); @@ -163,7 +112,7 @@ export default function Tooltip({ targetElement?.removeEventListener('focusin', show); targetElement?.removeEventListener('focusout', hide); }; - }, [targetElement, show, hide, showProp]); + }, [target, show, hide, showProp]); // Handle hover events for the tooltipElement useEffect(() => { @@ -180,6 +129,7 @@ export default function Tooltip({ // Keep the target's id in sync useEffect(() => { + const targetElement = resolveElement(target); if (hasAriaAssociation) { const idRefs = targetElement?.getAttribute(association); if (!hasIdRef(idRefs, id)) { @@ -193,14 +143,19 @@ export default function Tooltip({ targetElement.setAttribute(association, removeIdRef(idRefs, id)); } }; - }, [targetElement, id, association]); + }, [target, id, association]); return ( <> {(showTooltip || hideElementOnHidden) && isBrowser() ? createPortal( -