From 97d5b25ba9e372aac477a16a7bbe322ee8dd2c2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:12:42 +0000 Subject: [PATCH 01/12] chore: Generate a VPAT (#1758) Co-authored-by: scurker <1062039+scurker@users.noreply.github.com> --- vpats/2024-12-04-cauldron.md | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 vpats/2024-12-04-cauldron.md diff --git a/vpats/2024-12-04-cauldron.md b/vpats/2024-12-04-cauldron.md new file mode 100644 index 000000000..83746e717 --- /dev/null +++ b/vpats/2024-12-04-cauldron.md @@ -0,0 +1,65 @@ +# Cauldron Accessibility Conformance Report WCAG Edition + +**Name of Product**: Cauldron + +**Report Date**: 2024-12-04 + +## Table 1: Success Criteria, Level A + +| Criteria | Conformance Level | Remarks and Explanations | +| --- | --- | --- | +| [1.1.1 Non-text Content](http://www.w3.org/TR/WCAG20/#text-equiv-all) (Level A) | Supports | | +| [1.2.1 Audio-only and Video-only (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-av-only-alt) (Level A) | Supports | | +| [1.2.2 Captions (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-captions) (Level A) | Supports | | +| [1.2.3 Audio Description or Media Alternative (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-audio-desc) (Level A) | Supports | | +| [1.3.1 Info and Relationships](http://www.w3.org/TR/WCAG20/#content-structure-separation-programmatic) (Level A) | Supports | | +| [1.3.2 Meaningful Sequence](http://www.w3.org/TR/WCAG20/#content-structure-separation-sequence) (Level A) | Supports | | +| [1.3.3 Sensory Characteristics](http://www.w3.org/TR/WCAG20/#content-structure-separation-understanding) (Level A) | Supports | | +| [1.4.1 Use of Color](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-without-color) (Level A) | Supports | | +| [1.4.2 Audio Control](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-dis-audio) (Level A) | Supports | | +| [2.1.1 Keyboard](http://www.w3.org/TR/WCAG20/#keyboard-operation-keyboard-operable) (Level A) | Supports | | +| [2.1.2 No Keyboard Trap](http://www.w3.org/TR/WCAG20/#keyboard-operation-trapping) (Level A) | Supports | | +| [2.1.4 Character Key Shortcuts](http://www.w3.org/TR/WCAG20/#keyboard-operation-keyboard-operable) (Level A) | Supports | | +| [2.2.1 Timing Adjustable](http://www.w3.org/TR/WCAG20/#time-limits-required-behaviors) (Level A) | Supports | | +| [2.2.2 Pause, Stop, Hide](http://www.w3.org/TR/WCAG20/#time-limits-pause) (Level A) | Supports | | +| [2.3.1 Three Flashes or Below Threshold](http://www.w3.org/TR/WCAG20/#seizure-does-not-violate) (Level A) | Supports | | +| [2.4.1 Bypass Blocks](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-skip) (Level A) | Supports | | +| [2.4.2 Page Titled](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-title) (Level A) | Supports | | +| [2.4.3 Focus Order](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-focus-order) (Level A) | Supports | | +| [2.4.4 Link Purpose (In Context)](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-refs) (Level A) | Supports | | +| [2.5.1 Pointer Gestures](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level A) | Supports | | +| [2.5.2 Pointer Cancellation](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level A) | Supports | | +| [2.5.3 Label in Name](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-descriptive) (Level A) | Supports | | +| [2.5.4 Motion Actuation](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-motion-actuation) (Level A) | Supports | | +| [3.1.1 Language of Page](http://www.w3.org/TR/WCAG20/#meaning-doc-lang-id) (Level A) | Supports | | +| [3.2.1 On Focus](http://www.w3.org/TR/WCAG20/#consistent-behavior-receive-focus) (Level A) | Supports | | +| [3.2.2 On Input](http://www.w3.org/TR/WCAG20/#consistent-behavior-unpredictable-change) (Level A) | Supports | | +| [3.3.1 Error Identification](http://www.w3.org/TR/WCAG20/#minimize-error-identified) (Level A) | Supports | | +| [3.3.2 Labels or Instructions](http://www.w3.org/TR/WCAG20/#minimize-error-cues) (Level A) | Supports | | +| [4.1.1 Parsing](http://www.w3.org/TR/WCAG20/#ensure-compat-parses) (Level A) | Supports | | +| [4.1.2 Name, Role, Value](http://www.w3.org/TR/WCAG20/#ensure-compat-rsv) (Level A) | Supports | | + +## Table 2: Success Criteria, Level AA + +| Criteria | Conformance Level | Remarks and Explanations | +| --- | --- | --- | +| [1.2.4 Captions (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-captions) (Level AA) | Supports | | +| [1.2.5 Audio Description or Media Alternative (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-audio-desc) (Level AA) | Supports | | +| [1.3.4 Orientation](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-orientation) (Level AA) | Supports | | +| [1.3.5 Identify Input Purpose](http://www.w3.org/TR/WCAG20/#input-purposes) (Level AA) | Supports | | +| [1.4.3 Contrast (Minimum)](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast) (Level AA) | Supports | | +| [1.4.4 Resize text](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-scale) (Level AA) | Supports | | +| [1.4.5 Images of Text](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-text-presentation) (Level AA) | Supports | | +| [1.4.10 Reflow](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-scale) (Level AA) | Supports | | +| [1.4.11 Non-text Contrast](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast) (Level AA) | Supports | | +| [1.4.12 Text Spacing](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-spacing) (Level AA) | Supports | | +| [1.4.13 Content on Hover or Focus](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-dis-audio) (Level AA) | Supports | | +| [2.4.5 Multiple Ways](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level AA) | Supports | | +| [2.4.6 Headings and Labels](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-descriptive) (Level AA) | Partially Supports | | +| [2.4.7 Focus Visible](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-focus-visible) (Level AA) | Supports | | +| [3.1.2 Language of Parts](http://www.w3.org/TR/WCAG20/#meaning-doc-lang-id) (Level AA) | Supports | | +| [3.2.3 Consistent Navigation](http://www.w3.org/TR/WCAG20/#consistent-behavior-consistent-locations) (Level AA) | Supports | | +| [3.2.4 Consistent Identification](http://www.w3.org/TR/WCAG20/#consistent-behavior-consistent-functionality) (Level AA) | Supports | | +| [3.3.3 Error Suggestion](http://www.w3.org/TR/WCAG20/#minimize-error-suggestions) (Level AA) | Supports | | +| [3.3.4 Error Prevention (Legal, Financial, Data)](http://www.w3.org/TR/WCAG20/#minimize-error-reversible) (Level AA) | Supports | | +| [4.1.3 Status Messages](http://www.w3.org/TR/WCAG20/#ensure-compat-rsv) (Level AA) | Supports | | \ No newline at end of file From eb5def134fcae7ec53e00aa6a47d2837c6af2783 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:15:26 -0600 Subject: [PATCH 02/12] chore: bump cross-spawn from 7.0.3 to 7.0.6 in /packages/react (#1753) Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6. - [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md) - [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6) --- updated-dependencies: - dependency-name: cross-spawn dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jason --- packages/react/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/yarn.lock b/packages/react/yarn.lock index c91915b79..4e4c14a58 100644 --- a/packages/react/yarn.lock +++ b/packages/react/yarn.lock @@ -2688,9 +2688,9 @@ create-require@^1.1.0: integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== cross-spawn@^7.0.0, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" From 4505ef2e01a31aceeaa4a5e728e6b418645158c4 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 13 Dec 2024 15:51:06 -0600 Subject: [PATCH 03/12] test: fix act errors in react tests (#1761) --- .../BottomSheet/BottomSheet.test.tsx | 98 ++++++---- .../src/components/Checkbox/Checkbox.test.tsx | 14 +- .../src/components/Drawer/Drawer.test.tsx | 4 +- .../components/IconButton/IconButton.test.tsx | 82 +++++---- .../src/components/MenuItem/index.test.tsx | 6 +- .../src/components/Pagination/index.test.tsx | 173 +++++++++++------- .../src/components/Popover/index.test.tsx | 14 +- .../components/RadioGroup/RadioGroup.test.tsx | 12 +- .../react/src/components/Scrim/index.test.tsx | 3 + .../SearchField/SearchField.test.tsx | 19 +- .../TextEllipsis/TextEllipsis.test.tsx | 6 +- .../react/src/components/Toast/toast.test.tsx | 19 +- .../src/components/Tooltip/Tooltip.test.tsx | 95 ++++++---- .../components/TooltipTabstop/index.test.tsx | 12 +- .../TwoColumnPanel/TwoColumnPanel.test.tsx | 44 +++-- packages/react/src/contexts/theme.test.tsx | 3 +- 16 files changed, 367 insertions(+), 237 deletions(-) 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/Drawer/Drawer.test.tsx b/packages/react/src/components/Drawer/Drawer.test.tsx index 2dcd86eb9..2fc52750e 100644 --- a/packages/react/src/components/Drawer/Drawer.test.tsx +++ b/packages/react/src/components/Drawer/Drawer.test.tsx @@ -320,7 +320,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 +331,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/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/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/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/RadioGroup/RadioGroup.test.tsx b/packages/react/src/components/RadioGroup/RadioGroup.test.tsx index 5cdc1db6b..e46bf28f7 100644 --- a/packages/react/src/components/RadioGroup/RadioGroup.test.tsx +++ b/packages/react/src/components/RadioGroup/RadioGroup.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'; @@ -167,7 +167,7 @@ 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] = renderRadioGroup({ onBlur }); const radioIcon = input.parentElement!.querySelector( @@ -176,9 +176,11 @@ test('should handle blur correctly', () => { expect(radioIcon).not.toHaveClass('.Radio__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(radioIcon).not.toHaveClass('Radio__overlay--focused'); expect(onBlur.calledOnce).toBeTruthy(); }); diff --git a/packages/react/src/components/Scrim/index.test.tsx b/packages/react/src/components/Scrim/index.test.tsx index 4467b3a1e..8b2ddb122 100644 --- a/packages/react/src/components/Scrim/index.test.tsx +++ b/packages/react/src/components/Scrim/index.test.tsx @@ -52,5 +52,8 @@ test('should return null when given a falsey show prop', () => { test('returns no axe violations', async () => { const { container } = render(); + await waitFor(() => { + expect(container).toBeInTheDocument(); + }); expect(await axe(container)).toHaveNoViolations(); }); diff --git a/packages/react/src/components/SearchField/SearchField.test.tsx b/packages/react/src/components/SearchField/SearchField.test.tsx index 951b2c568..db19edb34 100644 --- a/packages/react/src/components/SearchField/SearchField.test.tsx +++ b/packages/react/src/components/SearchField/SearchField.test.tsx @@ -1,5 +1,9 @@ import React, { createRef, ComponentProps, useState } from 'react'; -import { render as testingRender, screen } from '@testing-library/react'; +import { + render as testingRender, + screen, + waitFor +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { spy } from 'sinon'; import { axe } from 'jest-axe'; @@ -193,13 +197,17 @@ test('SearchField should render trailingChildren as a component', async () => { ) }); + waitFor(() => { + expect(input).toBeInTheDocument(); + }); + expect( input.parentElement!.contains( screen.getByRole('button', { name: 'go to previous match' }) ) - ).toBeTruthy; + ).toBeTruthy(); }); test('SearchField should render trailingChildren as a string', async () => { @@ -207,8 +215,9 @@ test('SearchField should render trailingChildren as a string', async () => { trailingChildren: 'I am a string' }); - expect(input.parentElement!.contains(screen.getByText('I am a string'))) - .toBeTruthy; + expect( + input.parentElement!.contains(screen.getByText('I am a string')) + ).toBeTruthy(); }); test('SearchField should render trailingChildren as an element', async () => { @@ -222,7 +231,7 @@ test('SearchField should render trailingChildren as an element', async () => { name: 'I am a button' }) ) - ).toBeTruthy; + ).toBeTruthy(); }); test('SearchField should have no axe violations with default params', async () => { diff --git a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx index c79d89161..0807eda57 100644 --- a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx +++ b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx @@ -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/TooltipTabstop/index.test.tsx b/packages/react/src/components/TooltipTabstop/index.test.tsx index 1cc43b564..fee26b07a 100644 --- a/packages/react/src/components/TooltipTabstop/index.test.tsx +++ b/packages/react/src/components/TooltipTabstop/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, act } from '@testing-library/react'; import TooltipTabstop from './'; import axe from '../../axe'; @@ -12,9 +12,13 @@ test('should display tooltip on hover', async () => { render(Hello); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); - await fireEvent.focusIn(screen.getByRole('button')); - expect(screen.queryByRole('tooltip')).toBeInTheDocument(); - expect(screen.getByRole('button')).toHaveAccessibleDescription('World'); + act(() => { + fireEvent.focusIn(screen.getByRole('button')); + }); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + expect(await screen.findByRole('button')).toHaveAccessibleDescription( + 'World' + ); }); test('should return no axe violations', async () => { diff --git a/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx b/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx index ec246b1c6..102aa8dd5 100644 --- a/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx +++ b/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx @@ -8,7 +8,7 @@ import { } from './'; import SkipLink from '../SkipLink'; import axe from '../../axe'; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, within, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const sandbox = createSandbox(); @@ -36,7 +36,7 @@ afterEach(() => { sandbox.restore(); }); -test('should render TwoColumnPanel', () => { +test('should render TwoColumnPanel', async () => { render( { name: /test-hide-panel/i }); - expect(columnRightToggleButton).toBeInTheDocument(); - expect(columnRightToggleButton).toHaveAttribute('aria-expanded', 'true'); - expect(columnRightToggleButton).toHaveAttribute( - 'aria-controls', - 'column-left-id' - ); + waitFor(() => { + expect(columnRightToggleButton).toBeInTheDocument(); + expect(columnRightToggleButton).toHaveAttribute('aria-expanded', 'true'); + expect(columnRightToggleButton).toHaveAttribute( + 'aria-controls', + 'column-left-id' + ); + }); }); -test('should render collapsed TwoColumnPanel', () => { +test('should render collapsed TwoColumnPanel', async () => { matchMediaStub.withArgs('(max-width: 45rem)').returns({ matches: true, addEventListener: noop, @@ -148,7 +150,9 @@ test('should render collapsed TwoColumnPanel', () => { name: /test-hide-panel/i }); - expect(columnRightToggleButton).not.toBeInTheDocument(); + waitFor(() => { + expect(columnRightToggleButton).not.toBeInTheDocument(); + }); }); test('should collapse panel when prefers-reduced-motion: reduce is set', async () => { @@ -199,10 +203,12 @@ test('should collapse panel when prefers-reduced-motion: reduce is set', async ( }) ); - expect(screen.queryByTestId('column-left')).not.toBeInTheDocument(); + waitFor(() => { + expect(screen.queryByTestId('column-left')).not.toBeInTheDocument(); + }); }); -test('should render configurable collapsed TwoColumnPanel', () => { +test('should render configurable collapsed TwoColumnPanel', async () => { matchMediaStub.withArgs('(max-width: 999rem)').returns({ matches: true, addEventListener: noop, @@ -250,7 +256,9 @@ test('should render configurable collapsed TwoColumnPanel', () => { expect(screen.queryByTestId('column-left')).not.toBeInTheDocument(); const columnRight = screen.getByTestId('column-right'); - expect(columnRight).toBeInTheDocument(); + waitFor(() => { + expect(columnRight).toBeInTheDocument(); + }); expect( within(columnRight).getByRole('button', { @@ -259,7 +267,7 @@ test('should render configurable collapsed TwoColumnPanel', () => { ).toHaveAttribute('aria-expanded', 'false'); }); -test('should accept a skip link', () => { +test('should accept a skip link', async () => { render( { ); - screen.getByRole('link', { name: /Test skip to Test content/i }); + expect( + await screen.findByRole('link', { name: /Test skip to Test content/i }) + ).toBeInTheDocument(); }); test('should return no axe violations', async () => { @@ -344,7 +354,9 @@ test('should return no axe violations', async () => { ); - + await waitFor(() => { + expect(container).toBeInTheDocument(); + }); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/packages/react/src/contexts/theme.test.tsx b/packages/react/src/contexts/theme.test.tsx index 186563f5e..c0415fa82 100644 --- a/packages/react/src/contexts/theme.test.tsx +++ b/packages/react/src/contexts/theme.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; import { ThemeProvider, useThemeContext } from './theme'; -import { render } from '@testing-library/react'; +import { render, act } from '@testing-library/react'; let theme: string, toggleTheme: () => void, From b598281344e0db5f2578b16fc14855dec058c523 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 13 Dec 2024 15:51:26 -0600 Subject: [PATCH 04/12] test: remove unnecessary console messages in theme test (#1765) --- packages/react/src/contexts/theme.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react/src/contexts/theme.test.tsx b/packages/react/src/contexts/theme.test.tsx index c0415fa82..db96f0f37 100644 --- a/packages/react/src/contexts/theme.test.tsx +++ b/packages/react/src/contexts/theme.test.tsx @@ -42,6 +42,10 @@ const renderProvider = (themeProviderProps = {}) => { ); }; +afterEach(() => { + jest.restoreAllMocks(); +}); + test('it exposes the current theme (defaulting to light)', () => { renderProvider(); expect(theme).toBe('light'); @@ -94,6 +98,10 @@ test('disconnects mutation observer when unmounted', () => { }); test('throw an exception, without provider', () => { + // react will log an error when the context throws, but we don't want it + // to show up in our test output + jest.spyOn(console, 'error').mockImplementation(jest.fn()); + const Component = () => { const { toggleTheme } = useThemeContext(); toggleTheme(); From e4a3441d8497f656dbec6afa35a989a139d430e7 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 13 Dec 2024 15:51:43 -0600 Subject: [PATCH 05/12] fix(react): remove defaultProps from sidebar, use default parameters instead (#1764) --- packages/react/src/components/SideBar/SideBarItem.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/react/src/components/SideBar/SideBarItem.tsx b/packages/react/src/components/SideBar/SideBarItem.tsx index 87fa4e8a1..318b467bc 100644 --- a/packages/react/src/components/SideBar/SideBarItem.tsx +++ b/packages/react/src/components/SideBar/SideBarItem.tsx @@ -9,7 +9,7 @@ export interface SideBarItemProps extends React.HTMLAttributes { const SideBarItem: React.ComponentType< React.PropsWithChildren -> = ({ children, autoClickLink, ...other }: SideBarItemProps) => { +> = ({ children, autoClickLink = true, ...other }: SideBarItemProps) => { const onClick = (e: React.MouseEvent) => { if (!autoClickLink) { return; @@ -27,8 +27,5 @@ const SideBarItem: React.ComponentType< }; SideBarItem.displayName = 'SideBarItem'; -SideBarItem.defaultProps = { - autoClickLink: true -}; export default SideBarItem; From df7ce75829ca452195e6fc0dff628746707196a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:02:53 -0600 Subject: [PATCH 06/12] chore: bump nanoid from 3.3.7 to 3.3.8 (#1767) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index fdbe0195b..cf1645c72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8559,9 +8559,9 @@ nan@^2.12.1: integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== nanoid@^3.3.7: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== nanomatch@^1.2.9: version "1.2.13" From b64aa4e46d415caa5f7253fcc3e3116e5f4fb836 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 17 Dec 2024 16:28:29 -0600 Subject: [PATCH 07/12] ci: fix issue where puppeteer is failing on ubuntu-latest (#1769) --- .github/workflows/tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From afa8fb4f89d43e453eccfa2c39587e4fee0bc9cf Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 18 Dec 2024 13:52:46 -0600 Subject: [PATCH 08/12] feat(react): add multiselect to Listbox component (#1763) --- docs/pages/components/Listbox.mdx | 26 ++- .../components/Combobox/ComboboxOption.tsx | 7 +- .../react/src/components/Listbox/Listbox.tsx | 137 +++++++++--- .../src/components/Listbox/ListboxContext.tsx | 8 +- .../src/components/Listbox/ListboxOption.tsx | 14 +- .../src/components/Listbox/index.test.tsx | 205 +++++++++++++++++- 6 files changed, 355 insertions(+), 42 deletions(-) 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/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/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 From 67739754b0c6368ec1db878ace917730db102797 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 18 Dec 2024 14:09:28 -0600 Subject: [PATCH 09/12] feat(react,style): add AnchoredOverlay component, refactor Tooltip and Popover to use AnchoredOverlay (#1760) --- docs/pages/components/AnchoredOverlay.mdx | 189 ++++++++++++++++++ docs/pages/components/Popover.mdx | 2 +- docs/pages/components/Tooltip.mdx | 2 +- packages/react/package.json | 3 +- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 162 +++++++++++++++ .../src/components/AnchoredOverlay/index.tsx | 118 +++++++++++ .../react/src/components/Code/Code.test.tsx | 2 +- .../src/components/Pagination/Pagination.tsx | 4 +- .../react/src/components/Popover/index.tsx | 47 +---- .../TextEllipsis/TextEllipsis.test.tsx | 2 +- .../react/src/components/Tooltip/index.tsx | 92 ++------- packages/react/src/index.ts | 1 + packages/react/yarn.lock | 54 ++--- packages/styles/popover.css | 5 + packages/styles/tooltip.css | 5 + 15 files changed, 546 insertions(+), 142 deletions(-) create mode 100644 docs/pages/components/AnchoredOverlay.mdx create mode 100644 packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx create mode 100644 packages/react/src/components/AnchoredOverlay/index.tsx 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/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/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/packages/react/package.json b/packages/react/package.json index 333ff7361..30f1589b3 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -23,13 +23,12 @@ "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/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/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/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx index 21e17097d..abefd4b2b 100644 --- a/packages/react/src/components/Popover/index.tsx +++ b/packages/react/src/components/Popover/index.tsx @@ -1,11 +1,10 @@ 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'; @@ -21,7 +20,7 @@ type BaseProps = React.HTMLAttributes & { variant?: PopoverVariant; show: boolean; onClose: () => void; - placement?: Placement; + placement?: React.ComponentProps['placement']; portal?: React.RefObject | HTMLElement; }; @@ -95,35 +94,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'] @@ -228,7 +204,7 @@ const Popover = forwardRef( }} > -
( )} ref={popoverRef} role="dialog" - style={styles.popper} - {...attributes.popper} + target={target} + open={show} + placement={initialPlacement} + onPlacementChange={setPlacement} + offset={8} {...additionalProps} {...props} > -
+
{variant === 'prompt' ? ( ( ) : ( children )} -
+ , (portal && 'current' in portal ? portal.current : portal) || diff --git a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx index 0807eda57..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() 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( -