diff --git a/package-lock.json b/package-lock.json index 8e179e47f07..c80bb5295b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15963,7 +15963,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.56.1", + "version": "1.77.8", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", + "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15975,7 +15977,7 @@ "sass": "sass.js" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/sass-loader": { diff --git a/pages/app-layout/utils/drawer-ids.ts b/pages/app-layout/utils/drawer-ids.ts new file mode 100644 index 00000000000..cdffe627d9f --- /dev/null +++ b/pages/app-layout/utils/drawer-ids.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export const drawerIds = { + security: 'security', + proHelp: 'pro-help', + links: 'links', + test1: 'test-1', + test2: 'test-2', + test3: 'test-3', + test4: 'test-4', + test5: 'test-5', + test6: 'test-6', +}; diff --git a/pages/app-layout/utils/drawers.tsx b/pages/app-layout/utils/drawers.tsx index 8b7de8a7f0d..0eaa8bdf578 100644 --- a/pages/app-layout/utils/drawers.tsx +++ b/pages/app-layout/utils/drawers.tsx @@ -4,6 +4,8 @@ import React from 'react'; import { AppLayoutProps, Drawer, SpaceBetween } from '~components'; +import { drawerIds } from './drawer-ids'; + import styles from '../styles.scss'; const getAriaLabels = (title: string, badge: boolean) => { @@ -31,7 +33,7 @@ export const drawerItems: Array = [ { ariaLabels: getAriaLabels('Security', false), content: , - id: 'security', + id: drawerIds.security, resizable: true, onResize: (event: any) => { // A drawer implementer may choose to listen to THEIR drawer's @@ -48,7 +50,7 @@ export const drawerItems: Array = [ content: Pro help}>Pro help., badge: true, defaultSize: 600, - id: 'pro-help', + id: drawerIds.proHelp, trigger: { iconName: 'contact', }, @@ -58,7 +60,7 @@ export const drawerItems: Array = [ resizable: true, defaultSize: 500, content: Links}>Links., - id: 'links', + id: drawerIds.links, trigger: { iconName: 'share', }, @@ -67,7 +69,7 @@ export const drawerItems: Array = [ ariaLabels: getAriaLabels('Test 1', true), content: Test 1}>Test 1., badge: true, - id: 'test-1', + id: drawerIds.test1, trigger: { iconName: 'contact', }, @@ -77,7 +79,7 @@ export const drawerItems: Array = [ resizable: true, defaultSize: 500, content: Test 2}>Test 2., - id: 'test-2', + id: drawerIds.test2, trigger: { iconName: 'share', }, @@ -86,7 +88,7 @@ export const drawerItems: Array = [ ariaLabels: getAriaLabels('Test 3', true), content: Test 3}>Test 3., badge: true, - id: 'test-3', + id: drawerIds.test3, trigger: { iconName: 'contact', }, @@ -96,7 +98,7 @@ export const drawerItems: Array = [ resizable: true, defaultSize: 500, content: Test 4}>Test 4., - id: 'test-4', + id: drawerIds.test4, trigger: { iconName: 'edit', }, @@ -106,7 +108,7 @@ export const drawerItems: Array = [ resizable: true, defaultSize: 500, content: Test 5}>Test 5., - id: 'test-5', + id: drawerIds.test5, trigger: { iconName: 'add-plus', }, @@ -116,7 +118,7 @@ export const drawerItems: Array = [ resizable: true, defaultSize: 500, content: Test 6}>Test 6., - id: 'test-6', + id: drawerIds.test6, trigger: { iconName: 'call', }, diff --git a/src/app-layout/__integ__/app-layout-toolbar-tooltips.test.ts b/src/app-layout/__integ__/app-layout-toolbar-tooltips.test.ts new file mode 100644 index 00000000000..a1aed0af078 --- /dev/null +++ b/src/app-layout/__integ__/app-layout-toolbar-tooltips.test.ts @@ -0,0 +1,337 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { drawerIds as drawerIdObj } from '../../../lib/dev-pages/pages/app-layout/utils/drawer-ids'; +import { viewports } from './constants'; + +import visualRefreshStyles from '../../../lib/components/app-layout/visual-refresh/styles.selectors.js'; + +const wrapper = createWrapper().findAppLayout(); +class AppLayoutDrawersPage extends BasePageObject { + async openFirstDrawer() { + await this.click(wrapper.findDrawersTriggers().get(1).toSelector()); + } + + async getElementCenter(selector: string) { + const targetRect = await this.getBoundingBox(selector); + const x = Math.round(targetRect.left + targetRect.width / 2); + const y = Math.round(targetRect.top + targetRect.height / 2); + return { x, y }; + } + + async pointerDown(selector: string) { + const center = await this.getElementCenter(selector); + await (await this.browser.$(selector)).moveTo(); + await this.browser.performActions([ + { + type: 'pointer', + id: 'event', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 0, origin: 'pointer', ...center }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 100 }, + ], + }, + ]); + } + + async pointerUp() { + await this.browser.performActions([ + { + type: 'pointer', + id: 'event', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: 100 }, + ], + }, + ]); + } +} + +interface SetupTestOptions { + splitPanelPosition?: string; + size?: 'desktop' | 'mobile'; + disableContentPaddings?: string; + visualRefresh?: string; +} + +const drawerIds = Object.values(drawerIdObj); +const VISIBLE_MOBILE_TOOLBAR_TRIGGERS_LIMIT = 2; //must match the number in '../../../lib/components/app-layout/visual-refresh/drawers'; +const mobileDrawerTriggerIds = drawerIds.slice(0, VISIBLE_MOBILE_TOOLBAR_TRIGGERS_LIMIT); + +const setupTest = ( + { + splitPanelPosition = 'bottom', + size = 'desktop', + disableContentPaddings = 'false', + visualRefresh = 'false', + }: SetupTestOptions, + testFn: (page: AppLayoutDrawersPage) => Promise +) => + useBrowser(size === 'desktop' ? viewports.desktop : viewports.mobile, async browser => { + const page = new AppLayoutDrawersPage(browser); + const params = new URLSearchParams({ + visualRefresh, + splitPanelPosition, + disableContentPaddings, + }).toString(); + await browser.url(`#/light/app-layout/with-drawers?${params}`); + await page.waitForVisible(wrapper.findContentRegion().toSelector()); + await testFn(page); + }); + +describe(`theme='visual-refresh'`, () => { + describe(`desktop`, () => { + const size = 'desktop'; + + test( + 'Shows tooltip correctly for mouse interactions on desktop', + setupTest({ disableContentPaddings: 'true', visualRefresh: 'true', size }, async page => { + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect( + page.isExisting(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`) + ).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['drawers-trigger-overflow']}`)).resolves.toBeFalsy(); + await page.hoverElement(wrapper.findDrawerTriggerById(drawerIds[0]).toSelector()); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + + for (const drawerId of drawerIds) { + async () => { + await page.hoverElement(wrapper.findDrawerTriggerById(drawerId).toSelector()); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + await page.hoverElement(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await page.click(`button[data-testid='awsui-app-layout-trigger-${drawerId}']`); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeTruthy(); + await page.hoverElement(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeTruthy(); + + for (const nestedDrawerId of drawerIds) { + async () => { + await page.hoverElement(wrapper.findDrawerTriggerById(nestedDrawerId).toSelector()); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + await page.hoverElement(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + }; + } + + await page.click(wrapper.findActiveDrawerCloseButton().toSelector()); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeFalsy(); + await expect(page.isFocused(wrapper.findDrawerTriggerById(drawerId).toSelector())).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + }; + } + }) + ); + + test( + 'tooltip shows on focus and hides on blur events', + setupTest({ disableContentPaddings: 'true', visualRefresh: 'true', size }, async page => { + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect( + page.isExisting(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`) + ).resolves.toBeTruthy(); + await expect( + page.isExisting(`.${visualRefreshStyles['drawers-desktop-triggers-container']}`) + ).resolves.toBeTruthy(); + + for (const drawerId of drawerIds) { + async () => { + await page.hoverElement(wrapper.findDrawerTriggerById(drawerId).toSelector()); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + await page.hoverElement(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await page.click(`button[data-testid='awsui-app-layout-trigger-${drawerId}']`); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeTruthy(); + await page.hoverElement(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeTruthy(); + + for (const nestedDrawerId of drawerIds) { + async () => { + await page.hoverElement(wrapper.findDrawerTriggerById(nestedDrawerId).toSelector()); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + await page.hoverElement(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + }; + } + + await page.click(wrapper.findActiveDrawerCloseButton().toSelector()); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeFalsy(); + await expect(page.isFocused(wrapper.findDrawerTriggerById(drawerId).toSelector())).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + }; + } + }) + ); + + test( + 'Shows tooltip correctly for keyboard (tab) interactions on desktop', + setupTest({ disableContentPaddings: 'true', visualRefresh: 'true', size }, async page => { + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect( + page.isExisting(`.${visualRefreshStyles[`drawers-desktop-triggers-container`]}`) + ).resolves.toBeTruthy(); + await expect( + page.isExisting(`.${visualRefreshStyles['drawers-desktop-triggers-container']}`) + ).resolves.toBeTruthy(); + + for (const drawerId of drawerIds) { + async () => { + //best way to avoid tab navigation errors is to start with a click to open then close the drawer, asserting button is focuses + await page.click(`button[data-testid='awsui-app-layout-trigger-${drawerId}']`); //opens + await page.click(`button[data-testid='awsui-app-layout-trigger-${drawerId}']`); //close drawer + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + await page.keys('Space'); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeTruthy(); + await page.keys('Tab'); //navigate to close button + await expect( + page.isFocused( + wrapper.findActiveDrawer().findByClassName(visualRefreshStyles['drawer-close-button']).toSelector() + ) + ).resolves.toBeTruthy(); + + //jump back to toolbar and navigate down the triggers + for (const nestedDrawerId of drawerIds) { + async () => { + await page.keys('Tab'); //navigate to next button + await expect( + page.isFocused(wrapper.findDrawerTriggerById(nestedDrawerId).toSelector()) + ).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + }; + } + + //now navigate back up + for (const reverseNestedDrawerId of [...drawerIds.reverse().slice(1)]) { + async () => { + await page.keys('Shift+Tab'); //navigate to last button + await expect( + page.isFocused(wrapper.findDrawerTriggerById(reverseNestedDrawerId).toSelector()) + ).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + }; + } + + await page.keys('Shift+Tab'); + await expect( + page.isFocused( + wrapper.findActiveDrawer().findByClassName(visualRefreshStyles['drawer-close-button']).toSelector() + ) + ).resolves.toBeTruthy(); + await page.keys('Space'); //close drawer + + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeFalsy(); + await expect(page.isFocused(wrapper.findDrawerTriggerById(drawerId).toSelector())).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + }; + } + }) + ); + }); + + describe(`mobile`, () => { + const size = 'mobile'; + + test( + 'Shows tooltip correctly for pointer interactions on mobile', + setupTest({ disableContentPaddings: 'true', visualRefresh: 'true', size }, async page => { + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await page.pause(100); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect( + page.isExisting(`.${visualRefreshStyles[`drawers-mobile-triggers-container`]}`) + ).resolves.toBeTruthy(); + + for (const drawerId of mobileDrawerTriggerIds) { + async () => { + await page.pause(100); + await expect(page.isExisting(wrapper.findDrawerTriggerById(drawerId).toSelector())).resolves.toBeTruthy(); + await page.pointerDown(wrapper.findDrawerTriggerById(drawerId).toSelector()); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect( + page.getElementsCount(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip']).toSelector()) + ).resolves.toBe(1); + await page.pointerUp(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await page.click(`button[data-testid='awsui-app-layout-trigger-${drawerId}']`); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeTruthy(); + await page.click(wrapper.findActiveDrawerCloseButton().toSelector()); + await expect(page.isExisting(wrapper.findActiveDrawer().toSelector())).resolves.toBeFalsy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + }; + } + }) + ); + + test( + 'Shows tooltip correctly for key interactions on mobile', + setupTest({ disableContentPaddings: 'true', visualRefresh: 'true', size }, async page => { + //open via hamburger menu o set focus in the toolbar + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await expect( + page.isExisting(`.${visualRefreshStyles[`drawers-mobile-triggers-container`]}`) + ).resolves.toBeTruthy(); + await page.click(wrapper.findNavigationToggle().toSelector()); //opens navigation drawer + await page.click(wrapper.findNavigationClose().toSelector()); //close drawer + await expect(page.isFocused(wrapper.findNavigationToggle().toSelector())).resolves.toBeTruthy(); + await page.keys([ + 'Tab', //first and only breadcrumb + 'Tab', //first button + ]); + await expect( + page.isFocused(wrapper.findDrawerTriggerById(mobileDrawerTriggerIds[0]).toSelector()) + ).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect(page.getElementsCount(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBe(1); + await page.keys('Escape'); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await page.keys('Tab'); + await expect( + page.isFocused(wrapper.findDrawerTriggerById(mobileDrawerTriggerIds[1]).toSelector()) + ).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect(page.getElementsCount(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBe(1); + await page.keys('Escape'); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + await page.keys(['Shift', 'Tab', 'Shift']); + await expect( + page.isFocused(wrapper.findDrawerTriggerById(mobileDrawerTriggerIds[0]).toSelector()) + ).resolves.toBeTruthy(); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeTruthy(); + await expect(page.getElementsCount(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBe(1); + await page.keys('Enter'); + await expect(page.isExisting(`.${visualRefreshStyles['trigger-tooltip']}`)).resolves.toBeFalsy(); + }) + ); + }); +}); diff --git a/src/app-layout/__tests__/drawers.test.tsx b/src/app-layout/__tests__/drawers.test.tsx index 9be6473929b..0862c0e9118 100644 --- a/src/app-layout/__tests__/drawers.test.tsx +++ b/src/app-layout/__tests__/drawers.test.tsx @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 /* eslint simple-import-sort/imports: 0 */ import React from 'react'; +import { act, render, waitFor, fireEvent } from '@testing-library/react'; +import { KeyCode } from '@cloudscape-design/test-utils-core/utils.js'; +import createWrapper from '../../../lib/components/test-utils/dom'; + import { describeEachAppLayout, renderComponent, @@ -11,9 +15,9 @@ import { findActiveDrawerLandmark, } from './utils'; -import { act } from '@testing-library/react'; import AppLayout, { AppLayoutProps } from '../../../lib/components/app-layout'; -import visualRefreshStyles from '../../../lib/components/app-layout/visual-refresh/styles.css.js'; +import tooltipStyles from '../../../lib/components/internal/components/tooltip/styles.selectors.js'; +import visualRefreshStyles from '../../../lib/components/app-layout/visual-refresh/styles.selectors.js'; import toolbarTriggerButtonStyles from '../../../lib/components/app-layout/visual-refresh-toolbar/toolbar/trigger-button/styles.css.js'; jest.mock('../../../lib/components/internal/hooks/use-mobile', () => ({ @@ -22,6 +26,12 @@ jest.mock('../../../lib/components/internal/hooks/use-mobile', () => ({ const testIf = (condition: boolean) => (condition ? test : test.skip); +const mockEventBubble = { + bubbles: true, + isTrusted: true, + relatedTarget: null, +}; + jest.mock('@cloudscape-design/component-toolkit', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit'), useContainerQuery: () => [100, () => {}], @@ -33,6 +43,7 @@ describeEachAppLayout(({ size, theme }) => { expect(wrapper.findDrawersTriggers()).toHaveLength(1); rerender(); expect(wrapper.findDrawersTriggers()).toHaveLength(0); + expect(wrapper!.findByClassName(tooltipStyles.root)).toBeNull(); }); test('should not apply drawers treatment to the tools if the drawers array is empty', () => { @@ -184,4 +195,120 @@ describeEachAppLayout(({ size, theme }) => { drawerTrigger.click(); expect(drawerTrigger!.getElement()).not.toHaveClass(selectedClass); }); + + testIf(theme === 'refresh')('tooltip renders correctly on focus, blur, and escape key press events', async () => { + const mockDrawers = [testDrawer]; + const result = render(); + const wrapper = createWrapper(result.container); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + + const triggerButtonContainer = wrapper.findByClassName( + visualRefreshStyles[`drawers-${size === 'mobile' ? 'mobile' : 'desktop'}-triggers-container`] + ); + expect(triggerButtonContainer).not.toBeNull(); + const items = triggerButtonContainer?.findAllByClassName(visualRefreshStyles['trigger-wrapper']); + expect(items?.length).toEqual(mockDrawers.length); + + fireEvent.focus(items![0].getElement()); + + await waitFor(() => { + const tooltipWrapper = result.getByTestId(testDrawer.ariaLabels.drawerName); + expect(tooltipWrapper.classList.contains(tooltipStyles.root)).toBeTruthy(); + expect(tooltipWrapper.classList.contains(visualRefreshStyles['trigger-tooltip'])).toBeTruthy(); + expect(result.getByText(testDrawer.ariaLabels.drawerName)).toBeTruthy(); + }); + + fireEvent.blur(items![0].getElement()); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + + fireEvent.focus(items![0].getElement()); + + await waitFor(() => { + const tooltipWrapper = result.getByTestId(testDrawer.ariaLabels.drawerName); + expect(tooltipWrapper.classList.contains(tooltipStyles.root)).toBeTruthy(); + expect(tooltipWrapper.classList.contains(visualRefreshStyles['trigger-tooltip'])).toBeTruthy(); + expect(result.getByText(testDrawer.ariaLabels.drawerName)).toBeTruthy(); + }); + + fireEvent.keyDown(items![0].getElement(), { + ...mockEventBubble, + key: 'Escape', + code: KeyCode.escape, + }); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + }); + + testIf(theme === 'refresh')( + 'tooltip renders correctly on pointer events and is removed on escape key press', + async () => { + const mockDrawers = [testDrawer]; + const result = render(); + const wrapper = createWrapper(result.container); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + + const triggerButtonContainer = wrapper.findByClassName( + visualRefreshStyles[`drawers-${size === 'mobile' ? 'mobile' : 'desktop'}-triggers-container`] + ); + expect(triggerButtonContainer).not.toBeNull(); + const items = triggerButtonContainer?.findAllByClassName(visualRefreshStyles['trigger-wrapper']); + expect(items?.length).toEqual(mockDrawers.length); + + fireEvent.pointerEnter(items![0].getElement()); + + await waitFor(() => { + const tooltipWrapper = result.getByTestId(testDrawer.ariaLabels.drawerName); + expect(tooltipWrapper.classList.contains(tooltipStyles.root)).toBeTruthy(); + expect(tooltipWrapper.classList.contains(visualRefreshStyles['trigger-tooltip'])).toBeTruthy(); + expect(result.getByText(testDrawer.ariaLabels.drawerName)).toBeTruthy(); + }); + + fireEvent.pointerLeave(items![0].getElement()); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + + fireEvent.pointerEnter(items![0].getElement()); + + await waitFor(() => { + const tooltipWrapper = result.getByTestId(testDrawer.ariaLabels.drawerName); + expect(tooltipWrapper.classList.contains(tooltipStyles.root)).toBeTruthy(); + expect(tooltipWrapper.classList.contains(visualRefreshStyles['trigger-tooltip'])).toBeTruthy(); + expect(result.getByText(testDrawer.ariaLabels.drawerName)).toBeTruthy(); + }); + + fireEvent.keyDown(items![0].getElement(), { + ...mockEventBubble, + key: 'Escape', + code: KeyCode.escape, + }); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + } + ); + + testIf(theme === 'refresh')('tooltip does not render on trigger focus via close button', async () => { + const mockDrawers = [testDrawer]; + const result = render(); + const wrapper = createWrapper(result.container); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + + const triggerButtonContainer = wrapper.findByClassName( + visualRefreshStyles[`drawers-${size === 'mobile' ? 'mobile' : 'desktop'}-triggers-container`] + ); + expect(triggerButtonContainer).not.toBeNull(); + const drawerTrigger = triggerButtonContainer!.find( + `button[data-testid="awsui-app-layout-trigger-${testDrawer.id}"]` + ); + drawerTrigger?.click(); + + await waitFor(() => { + expect(result.getByText('Security')).toBeTruthy(); + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + }); + + const closeButton = wrapper.findDrawer()?.find(`button[title="${testDrawer.ariaLabels.drawerName}-close-button"]`); + expect(closeButton).not.toBeNull(); + closeButton?.click(); + + await waitFor(() => { + expect(() => result.getByTestId(testDrawer.ariaLabels.drawerName)).toThrow(); + }); + }); }); diff --git a/src/app-layout/__tests__/trigger-button.test.tsx b/src/app-layout/__tests__/trigger-button.test.tsx index ad8a50d05c8..92300922655 100644 --- a/src/app-layout/__tests__/trigger-button.test.tsx +++ b/src/app-layout/__tests__/trigger-button.test.tsx @@ -1,7 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/dist/utils.js'; import * as AllContext from '../../../lib/components/app-layout/visual-refresh/context.js'; import VisualRefreshTriggerButton, { @@ -21,12 +23,16 @@ const MockUseAppLayoutInternals = jest.spyOn(AllContext, 'useAppLayoutInternals' const mockDrawerId = 'mock-drawer-id'; const mockTestId = `awsui-app-layout-trigger-${mockDrawerId}`; +const mockTooltipText = 'Mock Tooltip'; const mockProps = { ariaLabel: 'Aria label', className: 'class-from-props', iconName: 'bug', testId: mockTestId, + tooltipText: mockTooltipText, + hasTooltip: false, + isForPreviousActiveDrawer: false, }; const mockOtherEl = { class: 'other-el-class', @@ -57,7 +63,7 @@ const testIf = (condition: boolean) => (condition ? test : test.skip); const renderVisualRefreshTriggerButton = ( props: Partial = {}, useAppLayoutInternalsValues: Partial = {}, - ref: React.Ref + ref: React.Ref = null ) => { MockUseAppLayoutInternals.mockReturnValue({ ...mockUseLayoutInternalValues, @@ -76,7 +82,7 @@ const renderVisualRefreshTriggerButton = ( const renderVisualRefreshToolbarTriggerButton = ( props: Partial = {}, - ref: React.Ref + ref: React.Ref = null ) => { const renderProps = { ...mockProps, ...props }; const { container, rerender, getByTestId, getByText } = render( @@ -95,7 +101,6 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => describe.each([true, false])('Toolbar trigger-button with isMobile=%s', isMobile => { describe.each([true, false])('AppLayoutInternals with hasOpenDrawer=%s', hasOpenDrawer => { testIf(!isMobile)('applies the correct class when selected', () => { - const ref: React.MutableRefObject = React.createRef(); const { wrapper } = renderVisualRefreshTriggerButton( { selected: true, @@ -103,8 +108,7 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => { isMobile, hasOpenDrawer, - }, - ref + } ); expect(wrapper).not.toBeNull(); const seletedButton = wrapper.findByClassName(visualRefreshStyles.selected); @@ -112,7 +116,6 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => }); test('renders correctly with wit badge', () => { - const ref: React.MutableRefObject = React.createRef(); const { wrapper, getByTestId } = renderVisualRefreshTriggerButton( { badge: false, @@ -120,8 +123,7 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => { isMobile, hasOpenDrawer, - }, - ref + } ); expect(wrapper).not.toBeNull(); @@ -132,8 +134,7 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => expect(wrapper.findBadge()).toBeNull(); }); - test('renders correctly with aria controls adn aria label', () => { - const ref: React.MutableRefObject = React.createRef(); + test('renders correctly with aria controls and aria label', () => { const mockAriaControls = 'mock-aria-control'; const { wrapper, getByTestId } = renderVisualRefreshTriggerButton( { @@ -142,8 +143,7 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => { isMobile, hasOpenDrawer, - }, - ref + } ); expect(wrapper).not.toBeNull(); @@ -181,7 +181,6 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => }); test.each([true, false])('Disables click events when disabled prop is %s', disabledValue => { - const ref: React.MutableRefObject = React.createRef(); const mockClickSpy = jest.fn(); const { wrapper, getByTestId } = renderVisualRefreshTriggerButton( { @@ -191,8 +190,7 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => { isMobile, hasOpenDrawer, - }, - ref + } ); expect(wrapper).not.toBeNull(); const button = wrapper.find('button')!; @@ -202,7 +200,6 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => }); test('renders an empty button when no iconName and iconSVG prop', () => { - const ref: React.MutableRefObject = React.createRef(); const { wrapper } = renderVisualRefreshTriggerButton( { iconName: '' as IconProps.Name, @@ -211,8 +208,7 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => { isMobile, hasOpenDrawer, - }, - ref + } ); expect(wrapper).not.toBeNull(); const button = wrapper.find('button'); @@ -223,15 +219,13 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => }); test('renders correctly with badge using dot class on desktop', () => { - const ref: React.MutableRefObject = React.createRef(); const { wrapper, getByTestId } = renderVisualRefreshTriggerButton( { badge: true, }, { isMobile: false, - }, - ref + } ); expect(wrapper).not.toBeNull(); @@ -242,22 +236,163 @@ describe('Visual refresh trigger-button (not in appLayoutWidget toolbar)', () => expect(wrapper.findByClassName(visualRefreshStyles.dot)).toBeTruthy(); }); - test.each([true, false] as const)('Is focusable using the forwarded ref with mobile is %s', isMobile => { - const ref: React.MutableRefObject = React.createRef(); - const { wrapper, getByTestId } = renderVisualRefreshTriggerButton( - {}, - { - isMobile, - }, - ref + describe('Shared trigger wrapper events', () => { + test.each([true, false] as const)( + 'Is focusable using the forwarded ref with no tooltip with mobile is %s', + isMobile => { + const ref: React.MutableRefObject = React.createRef(); + const { wrapper, getByTestId, getByText } = renderVisualRefreshTriggerButton( + { + hasTooltip: true, + tooltipText: mockTooltipText, + }, + { + isMobile, + }, + ref + ); + expect(getByTestId(mockTestId)).toBeTruthy(); + const button = wrapper!.find('button'); + expect(getByTestId(mockTestId)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper!.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + expect(() => getByText(mockTooltipText)).toThrow(); + expect(button).toBeTruthy(); + expect(document.activeElement).not.toBe(button!.getElement()); + (ref.current as any)?.focus(mockEventBubbleWithShiftFocus); + expect(document.activeElement).toBe(button!.getElement()); + expect(getByTestId(mockTestId)).toBeTruthy(); + expect(wrapper!.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + } ); - expect(getByTestId(mockTestId)).toBeTruthy(); - const button = wrapper!.find('button'); - expect(getByTestId(mockTestId)).toBeTruthy(); - expect(button).toBeTruthy(); - expect(document.activeElement).not.toBe(button!.getElement()); - (ref.current as any)?.focus(mockEventBubbleWithShiftFocus); - expect(document.activeElement).toBe(button!.getElement()); + + test.each([true, false] as const)( + 'Does not show tooltip on pointerEnter when hasTooltip is %s', + async hasTooltip => { + const { wrapper, getByText, getByTestId } = await renderVisualRefreshTriggerButton({ + hasTooltip, + tooltipText: mockTooltipText, + }); + expect(getByTestId(mockTestId)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper!.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + expect(() => getByText(mockTooltipText)).toThrow(); + fireEvent.pointerEnter(wrapper!.getElement()); + if (hasTooltip) { + expect(getByText(mockTooltipText)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + true + ); + //trigger event again to assert the tooltip remains + fireEvent.pointerDown(wrapper!.getElement()); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + true + ); + } else { + expect(() => getByText(mockTooltipText)).toThrow(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + } + fireEvent.pointerLeave(wrapper!.getElement(), mockEventBubble); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(() => getByText(mockTooltipText)).toThrow(); + } + ); + + test('Shows tooltip on focus and removes on key escape when drawer is open on mobile', async () => { + const { wrapper, getByText, getByTestId } = await renderVisualRefreshTriggerButton({ + hasTooltip: true, + tooltipText: mockTooltipText, + }); + expect(getByTestId(mockTestId)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper!.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + expect(() => getByText(mockTooltipText)).toThrow(); + fireEvent.focus(wrapper!.getElement()); + expect(getByText(mockTooltipText)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + true + ); + + fireEvent.keyDown(wrapper!.getElement(), { + ...mockEventBubble, + key: 'Escape', + code: KeyCode.escape, + }); + + expect(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(() => getByText(mockTooltipText)).toThrow(); + }); + + test('Does not show tooltip on pointerEnter when there is no arialLabel nor tooltipText', async () => { + const { wrapper, getByTestId } = await renderVisualRefreshTriggerButton({ + ariaLabel: '', + tooltipText: '', + }); + expect(getByTestId(mockTestId)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper!.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + + fireEvent.pointerEnter(wrapper!.getElement()); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + }); + + test('Does not show tooltip on focus when no ariaLabel nor tooltipText', async () => { + const { wrapper, getByTestId } = await renderVisualRefreshTriggerButton({ + ariaLabel: '', + tooltipText: '', + }); + expect(getByTestId(mockTestId)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper!.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + fireEvent.focus(wrapper!.getElement()); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + }); + + test('Does not show tooltip if mobile and hasOpenDrawer', async () => { + const { wrapper, getByTestId } = await renderVisualRefreshTriggerButton( + { + hasTooltip: true, + tooltipText: mockTooltipText, + }, + { + isMobile: true, + hasOpenDrawer: true, + } + ); + expect(getByTestId(mockTestId)).toBeTruthy(); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper!.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + fireEvent.focus(wrapper!.getElement()); + expect(wrapper!.getElement().classList.contains(visualRefreshStyles['trigger-wrapper-tooltip-visible'])).toBe( + false + ); + expect(wrapper.findByClassName(visualRefreshStyles['trigger-tooltip'])).toBeNull(); + }); }); }); @@ -265,13 +400,9 @@ describe('Visual Refresh Toolbar trigger-button', () => { beforeEach(() => jest.clearAllMocks()); test('renders correctly with badge using dot class', () => { - const ref: React.MutableRefObject = React.createRef(); - const { wrapper, getByTestId } = renderVisualRefreshToolbarTriggerButton( - { - badge: true, - }, - ref - ); + const { wrapper, getByTestId } = renderVisualRefreshToolbarTriggerButton({ + badge: true, + }); expect(wrapper).not.toBeNull(); const button = wrapper.find('button'); @@ -282,13 +413,9 @@ describe('Visual Refresh Toolbar trigger-button', () => { }); test('applies the correct class when selected', () => { - const ref: React.MutableRefObject = React.createRef(); - const { wrapper } = renderVisualRefreshToolbarTriggerButton( - { - selected: true, - }, - ref - ); + const { wrapper } = renderVisualRefreshToolbarTriggerButton({ + selected: true, + }); expect(wrapper).not.toBeNull(); const seletedButton = wrapper.findByClassName(toolbarTriggerButtonStyles.selected); expect(seletedButton).toBeTruthy(); @@ -317,15 +444,11 @@ describe('Visual Refresh Toolbar trigger-button', () => { }); test.each([true, false])('click events work as expected when disabled is %s', disabledValue => { - const ref: React.MutableRefObject = React.createRef(); const mockClickSpy = jest.fn(); - const { wrapper, getByTestId } = renderVisualRefreshToolbarTriggerButton( - { - disabled: disabledValue, - onClick: mockClickSpy, - }, - ref - ); + const { wrapper, getByTestId } = renderVisualRefreshToolbarTriggerButton({ + disabled: disabledValue, + onClick: mockClickSpy, + }); expect(wrapper).not.toBeNull(); const button = wrapper.find('button')!; expect(getByTestId(mockTestId)).toBeTruthy(); @@ -334,13 +457,9 @@ describe('Visual Refresh Toolbar trigger-button', () => { }); test('renders an empty button when no iconName and iconSVG prop', () => { - const ref: React.MutableRefObject = React.createRef(); - const { wrapper } = renderVisualRefreshToolbarTriggerButton( - { - iconName: '' as IconProps.Name, - }, - ref - ); + const { wrapper } = renderVisualRefreshToolbarTriggerButton({ + iconName: '' as IconProps.Name, + }); expect(wrapper).not.toBeNull(); const button = wrapper.find('button'); expect(button).toBeTruthy(); diff --git a/src/app-layout/visual-refresh/drawers.tsx b/src/app-layout/visual-refresh/drawers.tsx index 2a40686a551..0bcc387a416 100644 --- a/src/app-layout/visual-refresh/drawers.tsx +++ b/src/app-layout/visual-refresh/drawers.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; import { useContainerQuery } from '@cloudscape-design/component-toolkit'; @@ -139,6 +139,7 @@ function ActiveDrawer() { }} ref={drawersRefs.close} variant="icon" + data-shift-focus="last-opened-toolbar-trigger-button" /> {toolsContent && ( @@ -225,6 +226,12 @@ function DesktopTriggers() { const { visibleItems, overflowItems } = splitItems(drawers ?? undefined, getIndexOfOverflowItem(), activeDrawerId); const overflowMenuHasBadge = !!overflowItems.find(item => item.badge); + useEffect(() => { + if (activeDrawerId === null && previousActiveDrawerId && previousActiveDrawerId.current) { + drawersRefs.toggle.current?.focus(); + } + }, [activeDrawerId, previousActiveDrawerId, drawersRefs]); + return (