diff --git a/e2e/components/ProgressIndicator/ProgressIndicator-test.avt.e2e.js b/e2e/components/ProgressIndicator/ProgressIndicator-test.avt.e2e.js new file mode 100644 index 000000000000..f81463351fbb --- /dev/null +++ b/e2e/components/ProgressIndicator/ProgressIndicator-test.avt.e2e.js @@ -0,0 +1,141 @@ +/** + * Copyright IBM Corp. 2016, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const { expect, test } = require('@playwright/test'); +const { visitStory } = require('../../test-utils/storybook'); + +test.describe('ProgressIndicator', () => { + test('accessibility-checker @avt', async ({ page }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--default', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('ProgressIndicator'); + }); + + test('accessibility-checker interactive progressindicator @avt', async ({ + page, + }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--interactive', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('ProgressIndicator-interactive'); + }); + + test('accessibility-checker skeleton progressindicator @avt', async ({ + page, + }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--skeleton', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('ProgressIndicator-skeleton'); + }); + + test('accessibility-checker - onHover @avt', async ({ page }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--default', + globals: { + theme: 'white', + }, + }); + + await expect(page.getByText('First step')).toBeVisible(); + + page.getByText('First step').hover(); + + await expect(page).toHaveNoACViolations('ProgressIndicator-onhover'); + }); + + test('accessibility-checker - complete @avt', async ({ page }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--default', + globals: { + theme: 'white', + }, + }); + + // Checking if the 'complete' prop is adding the correct class + expect(page.locator('.cds--progress-step--complete')).toBeTruthy(); + }); + + test('accessibility-checker - current @avt', async ({ page }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--default', + globals: { + theme: 'white', + }, + }); + + // Checking if the 'current' prop is adding the correct class + expect(page.locator('.cds--progress-step--current')).toBeTruthy(); + }); + + test('accessibility-checker - interactive onHover @avt', async ({ page }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--interactive', + globals: { + theme: 'white', + }, + }); + + await expect(page.getByText('Click me')).toBeVisible(); + + page.getByText('Click me').hover(); + + await expect(page).toHaveNoACViolations( + 'ProgressIndicator-interactive-onhover' + ); + }); + + test('progress indicator - keyboard nav', async ({ page }) => { + await visitStory(page, { + component: 'ProgressIndicator', + id: 'components-progressindicator--interactive', + globals: { + theme: 'white', + }, + }); + // Testing the first element interaction + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Click me' })).toBeVisible(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Click me' })).toBeFocused(); + + await page.keyboard.press('Enter'); + await page.keyboard.press('Escape'); + + // Testing the third element interaction + await page.keyboard.press('Tab'); + await expect( + page.getByRole('button', { name: 'Third step' }) + ).toBeFocused(); + + await page.keyboard.press('Enter'); + await page.keyboard.press('Escape'); + + await expect( + page.getByRole('button', { name: 'Third step' }) + ).toBeFocused(); + }); +}); diff --git a/e2e/components/ProgressIndicator/ProgressIndicator-test.e2e.js b/e2e/components/ProgressIndicator/ProgressIndicator-test.e2e.js index 6a9ba21bf9b8..17a6ab6c400e 100644 --- a/e2e/components/ProgressIndicator/ProgressIndicator-test.e2e.js +++ b/e2e/components/ProgressIndicator/ProgressIndicator-test.e2e.js @@ -7,9 +7,9 @@ 'use strict'; -const { expect, test } = require('@playwright/test'); +const { test } = require('@playwright/test'); const { themes } = require('../../test-utils/env'); -const { snapshotStory, visitStory } = require('../../test-utils/storybook'); +const { snapshotStory } = require('../../test-utils/storybook'); test.describe('ProgressIndicator', () => { themes.forEach((theme) => { @@ -31,15 +31,4 @@ test.describe('ProgressIndicator', () => { }); }); }); - - test('accessibility-checker @avt', async ({ page }) => { - await visitStory(page, { - component: 'ProgressIndicator', - id: 'components-progressindicator--default', - globals: { - theme: 'white', - }, - }); - await expect(page).toHaveNoACViolations('ProgressIndicator'); - }); }); diff --git a/packages/react/examples/custom-css-prefix/yarn.lock b/packages/react/examples/custom-css-prefix/yarn.lock index 9a6c87881de1..1a96340d26c7 100644 --- a/packages/react/examples/custom-css-prefix/yarn.lock +++ b/packages/react/examples/custom-css-prefix/yarn.lock @@ -4175,4 +4175,4 @@ yargs@^12.0.2: string-width "^2.0.0" which-module "^2.0.0" y18n "^3.2.1 || ^4.0.0" - yargs-parser "^11.1.1" + yargs-parser "^11.1.1" \ No newline at end of file diff --git a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap index 29ef8b3b353c..6c2b45d94c35 100644 --- a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap +++ b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap @@ -484,6 +484,7 @@ exports[`DataTable behaves as expected selection should render and match snapsho >
@@ -890,6 +890,7 @@ exports[`DataTable renders as expected - Component API should render and match s >
diff --git a/packages/react/src/components/DataTable/__tests__/__snapshots__/TableToolbarSearch-test.js.snap b/packages/react/src/components/DataTable/__tests__/__snapshots__/TableToolbarSearch-test.js.snap index 53d9d2aaace3..6eb7f7836dd1 100644 --- a/packages/react/src/components/DataTable/__tests__/__snapshots__/TableToolbarSearch-test.js.snap +++ b/packages/react/src/components/DataTable/__tests__/__snapshots__/TableToolbarSearch-test.js.snap @@ -9,6 +9,7 @@ exports[`TableToolbarSearch renders as expected - Component API should render 1` >
diff --git a/packages/react/src/components/ExpandableSearch/ExpandableSearch-test.js b/packages/react/src/components/ExpandableSearch/ExpandableSearch-test.js index 2128a0e60019..628f82bfa7e8 100644 --- a/packages/react/src/components/ExpandableSearch/ExpandableSearch-test.js +++ b/packages/react/src/components/ExpandableSearch/ExpandableSearch-test.js @@ -58,6 +58,30 @@ describe('ExpandableSearch', () => { expect(container.firstChild).toHaveClass(`${prefix}--search--expanded`); }); + it('expands on enter', async () => { + const { container } = render( + + ); + + await screen.getAllByRole('button')[0].focus(); + + await userEvent.keyboard('[Enter]'); + + expect(container.firstChild).toHaveClass(`${prefix}--search--expanded`); + }); + + it('expands on space', async () => { + const { container } = render( + + ); + + await screen.getAllByRole('button')[0].focus(); + + await userEvent.keyboard('[Space]'); + + expect(container.firstChild).toHaveClass(`${prefix}--search--expanded`); + }); + it('places focus on the input after expansion', async () => { render(); @@ -107,5 +131,31 @@ describe('ExpandableSearch', () => { expect(container.firstChild).toHaveClass(`${prefix}--search--expanded`); }); + + it('closes and clears value on escape', async () => { + const { container } = render( + + ); + + await userEvent.click(screen.getAllByRole('button')[0]); + + expect(container.firstChild).toHaveClass(`${prefix}--search--expanded`); + + await userEvent.type(screen.getByRole('searchbox'), 'test-value'); + + expect(screen.getByRole('searchbox')).toHaveValue('test-value'); + + await userEvent.keyboard('[Escape]'); + + expect(screen.getByRole('searchbox')).not.toHaveValue('test-value'); + + expect(container.firstChild).toHaveClass(`${prefix}--search--expanded`); + + await userEvent.keyboard('[Escape]'); + + expect(container.firstChild).not.toHaveClass( + `${prefix}--search--expanded` + ); + }); }); }); diff --git a/packages/react/src/components/ExpandableSearch/ExpandableSearch.tsx b/packages/react/src/components/ExpandableSearch/ExpandableSearch.tsx index d97f7d46b177..412451f747b6 100644 --- a/packages/react/src/components/ExpandableSearch/ExpandableSearch.tsx +++ b/packages/react/src/components/ExpandableSearch/ExpandableSearch.tsx @@ -10,12 +10,13 @@ import classnames from 'classnames'; import Search, { type SearchProps } from '../Search'; import { usePrefix } from '../../internal/usePrefix'; import { composeEventHandlers } from '../../tools/events'; +import { match, keys } from '../../internal/keyboard'; function ExpandableSearch({ onBlur, onChange, onExpand, - onFocus, + onKeyDown, defaultValue, isExpanded, ...props @@ -25,12 +26,6 @@ function ExpandableSearch({ const searchRef = useRef(null); const prefix = usePrefix(); - function handleFocus() { - if (!expanded) { - setExpanded(true); - } - } - function handleBlur(evt) { const relatedTargetIsAllowed = evt.relatedTarget && @@ -46,9 +41,21 @@ function ExpandableSearch({ } function handleExpand() { + setExpanded(true); searchRef.current?.focus?.(); } + function handleKeyDown(evt) { + if (expanded && match(evt, keys.Escape)) { + evt.stopPropagation(); + + // escape key only clears if the input is empty, otherwise it clears the input + if (!evt.target?.value) { + setExpanded(false); + } + } + } + const classes = classnames( `${prefix}--search--expandable`, { @@ -64,10 +71,10 @@ function ExpandableSearch({ isExpanded={expanded} ref={searchRef} className={classes} - onFocus={composeEventHandlers([onFocus, handleFocus])} onBlur={composeEventHandlers([onBlur, handleBlur])} onChange={composeEventHandlers([onChange, handleChange])} onExpand={composeEventHandlers([onExpand, handleExpand])} + onKeyDown={composeEventHandlers([onKeyDown, handleKeyDown])} /> ); } diff --git a/packages/react/src/components/Search/Search-test.js b/packages/react/src/components/Search/Search-test.js index 3dd7fb323b17..0a4b16ccf745 100644 --- a/packages/react/src/components/Search/Search-test.js +++ b/packages/react/src/components/Search/Search-test.js @@ -103,6 +103,18 @@ describe('Search', () => { await userEvent.click(screen.getAllByRole('button')[0]); expect(onExpand).toHaveBeenCalled(); + + await screen.getAllByRole('button')[0].focus(); + + await userEvent.keyboard('[Space]'); + + expect(onExpand).toHaveBeenCalledTimes(2); + + await screen.getAllByRole('button')[0].focus(); + + await userEvent.keyboard('[Enter]'); + + expect(onExpand).toHaveBeenCalledTimes(3); }); it('should call onKeyDown when expected', async () => { @@ -114,6 +126,42 @@ describe('Search', () => { expect(onKeyDown).toHaveBeenCalled(); }); + it('should call focus expand button on Escape when expanded', async () => { + render( + {}} isExpanded={true} /> + ); + + await screen.getByRole('searchbox').focus(); + + await userEvent.keyboard('[Escape]'); + + expect(screen.getAllByRole('button')[0]).toHaveFocus(); + }); + + it('should have tabbable button and untabbable input if expandable and not expanded', async () => { + render( + {}} + isExpanded={false} + /> + ); + + expect(screen.getAllByRole('button')[0]).toHaveAttribute('tabIndex', '1'); + expect(screen.getByRole('searchbox')).toHaveAttribute('tabIndex', '-1'); + }); + + it('should have tabbable input and untabbable button if not expandable', async () => { + render(); + + // will only have 1 button which is the close button + expect(screen.getAllByRole('button').length).toBe(1); + expect(screen.getByRole('searchbox')).not.toHaveAttribute( + 'tabIndex', + '-1' + ); + }); + it('should respect placeholder prop', () => { render(); diff --git a/packages/react/src/components/Search/Search.tsx b/packages/react/src/components/Search/Search.tsx index ad0be45a7d38..ad9e10bbaa14 100644 --- a/packages/react/src/components/Search/Search.tsx +++ b/packages/react/src/components/Search/Search.tsx @@ -17,6 +17,7 @@ import React, { type KeyboardEvent, type ComponentType, type FunctionComponent, + type MouseEvent, } from 'react'; import { focus } from '../../internal/focus'; import { keys, match } from '../../internal/keyboard'; @@ -83,7 +84,9 @@ export interface SearchProps extends InputPropsBase { /** * Optional callback called when the magnifier icon is clicked in ExpandableSearch. */ - onExpand?(): void; + onExpand?( + e: MouseEvent | KeyboardEvent + ): void; /** * Provide an optional placeholder text for the Search. @@ -150,6 +153,7 @@ const Search = React.forwardRef(function Search( const { isFluid } = useContext(FormContext); const inputRef = useRef(null); const ref = useMergedRefs([forwardRef, inputRef]); + const expandButtonRef = useRef(null); const inputId = useId('search-input'); const uniqueId = id || inputId; const searchId = `${uniqueId}-search`; @@ -199,7 +203,22 @@ const Search = React.forwardRef(function Search( function handleKeyDown(event: KeyboardEvent) { if (match(event, keys.Escape)) { event.stopPropagation(); - clearInput(); + if (inputRef.current?.value) { + clearInput(); + } + // ExpandableSearch closes on escape when isExpanded, focus search activation button + else if (onExpand && isExpanded) { + expandButtonRef.current?.focus(); + } + } + } + + function handleExpandButtonKeyDown(event: KeyboardEvent) { + if (match(event, keys.Enter) || match(event, keys.Space)) { + event.stopPropagation(); + if (onExpand) { + onExpand(event); + } } } @@ -212,7 +231,12 @@ const Search = React.forwardRef(function Search( aria-labelledby={onExpand ? uniqueId : undefined} role={onExpand ? 'button' : undefined} className={`${prefix}--search-magnifier`} - onClick={onExpand}> + onClick={onExpand} + onKeyDown={handleExpandButtonKeyDown} + tabIndex={onExpand && !isExpanded ? 1 : -1} + ref={expandButtonRef} + aria-expanded={onExpand && isExpanded ? true : undefined} + aria-controls={onExpand ? uniqueId : undefined}>