From 4fccce34ab58df9698f659366a004eaf88b3a7d6 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 17 Feb 2025 19:43:35 +0100 Subject: [PATCH] feat: Allow manual tab activation (#3271) Co-authored-by: Andrei Zhaleznichenka --- pages/tabs/responsive-integ.page.tsx | 28 ++- .../__snapshots__/documenter.test.ts.snap | 21 ++ src/tabs/__integ__/tabs.test.ts | 103 +++++++++- src/tabs/__tests__/tabs.test.tsx | 182 ++++++++++++------ src/tabs/index.tsx | 4 +- src/tabs/interfaces.ts | 10 + src/tabs/tab-header-bar.tsx | 18 +- 7 files changed, 302 insertions(+), 64 deletions(-) diff --git a/pages/tabs/responsive-integ.page.tsx b/pages/tabs/responsive-integ.page.tsx index 04f47b6d14..aa9a0ff5cd 100644 --- a/pages/tabs/responsive-integ.page.tsx +++ b/pages/tabs/responsive-integ.page.tsx @@ -1,13 +1,26 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; -import { ButtonDropdown } from '~components'; +import { ButtonDropdown, Select } from '~components'; import Tabs, { TabsProps } from '~components/tabs'; +import AppContext, { AppContextType } from '../app/app-context'; + import styles from './responsive.scss'; +type TabsContext = React.Context< + AppContextType<{ + keyboardActivationMode: TabsProps['keyboardActivationMode']; + }> +>; + export default function TabsDemoPage() { + const { + urlParams: { keyboardActivationMode = 'automatic' }, + setUrlParams, + } = useContext(AppContext as TabsContext); + const defaultTabs: Array = [ { label: 'First tab', @@ -18,6 +31,7 @@ export default function TabsDemoPage() { { label: 'Second tab', id: 'second', + href: '/second', content: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', }, @@ -135,6 +149,14 @@ export default function TabsDemoPage() { return (

Tabs

+
setSelectedTab(event.detail.activeTabId)} + keyboardActivationMode={keyboardActivationMode} i18nStrings={{ scrollLeftAriaLabel: 'Scroll left', scrollRightAriaLabel: 'Scroll right' }} /> @@ -156,6 +179,7 @@ export default function TabsDemoPage() { id="dismiss-tabs" ariaLabel="Dismissible Tabs" tabs={tabsDismissibles} + keyboardActivationMode={keyboardActivationMode} i18nStrings={{ scrollLeftAriaLabel: 'Scroll left (Dismissible Tabs)', scrollRightAriaLabel: 'Scroll right (Dismissible Tabs)', diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index d9325f2887..e78ed63bce 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -16619,6 +16619,27 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "optional": true, "type": "string", }, + { + "defaultValue": ""automatic"", + "description": "Determines how the active tab is switched when navigating using +the keyboard. The options are: +- 'automatic' (default): the active tab is switched using the arrow keys. +- 'manual': a tab must be explicitly activated using the enter/space key. +We recommend using 'automatic' in most situations to provide consistent +and quick switching between tabs. Use 'manual' only if there is a specific +need to introduce friction to the switching of tabs.", + "inlineType": { + "name": "", + "type": "union", + "values": [ + "automatic", + "manual", + ], + }, + "name": "keyboardActivationMode", + "optional": true, + "type": "string", + }, { "description": "Specifies the tabs to display. Each tab object has the following properties: - \`id\` (string) - The tab identifier. This value needs to be passed to the Tabs component as \`activeTabId\` to select this tab. diff --git a/src/tabs/__integ__/tabs.test.ts b/src/tabs/__integ__/tabs.test.ts index a76a06e5f6..0af8e38a7c 100644 --- a/src/tabs/__integ__/tabs.test.ts +++ b/src/tabs/__integ__/tabs.test.ts @@ -68,11 +68,17 @@ class TabsPage extends BasePageObject { const setupTest = ( testFn: (page: TabsPage) => Promise, - { smallViewport = false, pagePath = 'responsive-integ' }: { smallViewport?: boolean; pagePath?: string } = {} + { + smallViewport = false, + pagePath = 'responsive-integ', + keyboardActivationMode, + }: { smallViewport?: boolean; pagePath?: string; keyboardActivationMode?: 'manual' | 'automatic' } = {} ) => { return useBrowser(async browser => { const page = new TabsPage(browser); - await browser.url(`#/light/tabs/${pagePath}`); + await browser.url( + `#/light/tabs/${pagePath}${keyboardActivationMode ? `?keyboardActivationMode=${keyboardActivationMode}` : ''}` + ); await page.waitForVisible(wrapper.findTabContent().toSelector()); if (smallViewport) { await page.setWindowSize({ width: 400, height: 1000 }); @@ -249,7 +255,7 @@ test( test( 'tab selection does not cause vertical scroll', setupTest(async page => { - await page.setWindowSize({ width: 600, height: 300 }); + await page.setWindowSize({ width: 600, height: 320 }); const { top: initialTopScrollPosition } = await page.getWindowScroll(); await page.click(wrapper.findTabLinkByIndex(3).toSelector()); const { top: currentTopScrollPosition } = await page.getWindowScroll(); @@ -512,3 +518,94 @@ test( await expect(page.isFocused(wrapper.findTabLinkByIndex(3).toSelector())).resolves.toBe(true); }) ); + +describe('activation mode', () => { + test( + 'automatic mode - activates tab on arrow navigation', + setupTest( + async page => { + await page.focusTabHeader(); + await expect(page.findActiveTabIndex()).resolves.toBe(0); + + await page.keys(['ArrowRight']); + await expect(page.findActiveTabIndex()).resolves.toBe(1); + + await page.keys(['ArrowLeft']); + await expect(page.findActiveTabIndex()).resolves.toBe(0); + }, + { keyboardActivationMode: 'automatic' } + ) + ); + + test( + 'manual mode - does not activate tab on arrow navigation', + setupTest( + async page => { + await page.focusTabHeader(); + const initialActiveTab = await page.findActiveTabIndex(); + + await page.keys(['ArrowRight']); + await expect(page.findActiveTabIndex()).resolves.toBe(initialActiveTab); + + await page.keys(['ArrowLeft']); + await expect(page.findActiveTabIndex()).resolves.toBe(initialActiveTab); + }, + { keyboardActivationMode: 'manual' } + ) + ); + + test.each(['Space', 'Enter'])('manual mode - activates tab on %s press', key => + setupTest( + async page => { + await page.focusTabHeader(); + await expect(page.findActiveTabIndex()).resolves.toBe(0); + + await page.keys(['ArrowRight']); + await page.keys([key]); + await expect(page.findActiveTabIndex()).resolves.toBe(1); + + await page.keys(['ArrowRight']); + await page.keys([key]); + await expect(page.findActiveTabIndex()).resolves.toBe(2); + }, + { keyboardActivationMode: 'manual' } + )() + ); + + test( + 'manual mode - allows full keyboard navigation without activation', + setupTest( + async page => { + await page.focusTabHeader(); + const initialActiveTab = await page.findActiveTabIndex(); + + await page.keys(['End']); + await expect(page.findActiveTabIndex()).resolves.toBe(initialActiveTab); + + await page.keys(['Home']); + await expect(page.findActiveTabIndex()).resolves.toBe(initialActiveTab); + + await page.navigateTabList(3); + await expect(page.findActiveTabIndex()).resolves.toBe(initialActiveTab); + }, + { keyboardActivationMode: 'manual' } + ) + ); + + test( + 'automatic mode - activates tab on Home/End keys', + setupTest( + async page => { + await page.focusTabHeader(); + + await page.keys(['End']); + await page.navigateTabList(-2); + await expect(page.findActiveTabIndex()).resolves.toBe(5); + + await page.keys(['Home']); + await expect(page.findActiveTabIndex()).resolves.toBe(0); + }, + { keyboardActivationMode: 'automatic' } + ) + ); +}); diff --git a/src/tabs/__tests__/tabs.test.tsx b/src/tabs/__tests__/tabs.test.tsx index 58fafbdf27..64125c4136 100644 --- a/src/tabs/__tests__/tabs.test.tsx +++ b/src/tabs/__tests__/tabs.test.tsx @@ -43,6 +43,10 @@ function pressLeft(wrapper: TabsWrapper) { wrapper.findActiveTab()!.keydown(KeyCode.left); } +function pressSpace(wrapper: TabsWrapper) { + wrapper.findActiveTab()!.keydown(KeyCode.space); +} + function pressHome(wrapper: TabsWrapper) { wrapper.findActiveTab()!.keydown(KeyCode.home); } @@ -341,73 +345,139 @@ describe('Tabs', () => { expect(changeSpy).not.toHaveBeenCalled(); }); - test('fires a change event on right arrow key press', () => { - const changeSpy = jest.fn(); - const wrapper = renderTabs().wrapper; - wrapper.findActiveTab()!.getElement().focus(); + describe.each(['automatic', 'manual'] as TabsProps['keyboardActivationMode'][])( + 'keyboard events with %s activation', + keyboardActivationMode => { + const isManual = keyboardActivationMode === 'manual'; + test('navigating using right arrow', () => { + const changeSpy = jest.fn(); + const wrapper = renderTabs( + + ).wrapper; + wrapper.findActiveTab()!.getElement().focus(); - expect(changeSpy).not.toHaveBeenCalled(); + expect(changeSpy).not.toHaveBeenCalled(); - pressRight(wrapper); + pressRight(wrapper); - expect(changeSpy).toHaveBeenCalledTimes(1); - expect(changeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - detail: { - activeTabId: 'second', - activeTabHref: '#second', - }, - }) - ); - }); + if (isManual) { + expect(changeSpy).not.toHaveBeenCalled(); + // We only test using space here, because in real browsers pressing enter triggers a click: + // this behavior can't easily be replicated in jsdom. But we test enter behavior in integ tests :) + pressSpace(wrapper); + } + + expect(changeSpy).toHaveBeenCalledTimes(1); + expect(changeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + activeTabId: 'second', + activeTabHref: '#second', + }, + }) + ); + }); - test('fires a change event on left arrow key press', () => { - const changeSpy = jest.fn(); - const wrapper = renderTabs().wrapper; - expect(changeSpy).not.toHaveBeenCalled(); + test('navigating using left arrow', () => { + const changeSpy = jest.fn(); + const wrapper = renderTabs( + + ).wrapper; + expect(changeSpy).not.toHaveBeenCalled(); - pressLeft(wrapper); + pressLeft(wrapper); - expect(changeSpy).toHaveBeenCalledTimes(1); - expect(changeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - detail: { - activeTabId: 'fourth', - activeTabHref: undefined, - }, - }) - ); - }); + if (isManual) { + expect(changeSpy).not.toHaveBeenCalled(); + pressSpace(wrapper); + } + + expect(changeSpy).toHaveBeenCalledTimes(1); + expect(changeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + activeTabId: 'fourth', + activeTabHref: undefined, + }, + }) + ); + }); - test('fires a change event on home and end key press', () => { - const changeSpy = jest.fn(); - const wrapper = renderTabs().wrapper; - expect(changeSpy).not.toHaveBeenCalled(); + test('navigating using home and end key press', () => { + const changeSpy = jest.fn(); + const wrapper = renderTabs( + + ).wrapper; + expect(changeSpy).not.toHaveBeenCalled(); - pressEnd(wrapper); + pressEnd(wrapper); - expect(changeSpy).toHaveBeenCalledTimes(1); - expect(changeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - detail: { - activeTabId: 'fourth', - activeTabHref: undefined, - }, - }) - ); + if (isManual) { + expect(changeSpy).not.toHaveBeenCalled(); + pressSpace(wrapper); + } + + expect(changeSpy).toHaveBeenCalledTimes(1); + expect(changeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + activeTabId: 'fourth', + activeTabHref: undefined, + }, + }) + ); - pressHome(wrapper); + pressHome(wrapper); - expect(changeSpy).toHaveBeenCalledTimes(2); - expect(changeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - detail: { - activeTabId: 'first', - activeTabHref: '#first', - }, - }) - ); - }); + if (isManual) { + expect(changeSpy).toHaveBeenCalledTimes(1); + pressSpace(wrapper); + } + + expect(changeSpy).toHaveBeenCalledTimes(2); + expect(changeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + activeTabId: 'first', + activeTabHref: '#first', + }, + }) + ); + }); + + test('does not call change when re-activating current tag', () => { + const changeSpy = jest.fn(); + const wrapper = renderTabs( + + ).wrapper; + wrapper.findActiveTab()!.getElement().focus(); + + pressSpace(wrapper); + + expect(changeSpy).not.toHaveBeenCalled(); + }); + } + ); test('does not fire an event on arrow navigation when a modifier key is used', () => { const changeSpy = jest.fn(); diff --git a/src/tabs/index.tsx b/src/tabs/index.tsx index 4a08062650..1e2e393684 100644 --- a/src/tabs/index.tsx +++ b/src/tabs/index.tsx @@ -52,6 +52,7 @@ export default function Tabs({ disableContentPaddings = false, i18nStrings, fitHeight, + keyboardActivationMode = 'automatic', actions, ...rest }: TabsProps) { @@ -59,7 +60,7 @@ export default function Tabs({ checkSafeUrl('Tabs', tab.href); } const { __internalRootRef } = useBaseComponent('Tabs', { - props: { disableContentPaddings, variant, fitHeight }, + props: { disableContentPaddings, variant, fitHeight, keyboardActivationMode }, metadata: { hasActions: tabs.some(tab => !!tab.action), hasHeaderActions: !!actions, @@ -151,6 +152,7 @@ export default function Tabs({ fireNonCancelableEvent(onChange, changeDetail); }} i18nStrings={i18nStrings} + keyboardActivationMode={keyboardActivationMode} /> ); diff --git a/src/tabs/interfaces.ts b/src/tabs/interfaces.ts index 5c90d28ee6..1b97af2735 100644 --- a/src/tabs/interfaces.ts +++ b/src/tabs/interfaces.ts @@ -90,6 +90,16 @@ export interface TabsProps extends BaseComponentProps { * If the tab content is too short, it will stretch. If the tab content is too long, a vertical scrollbar will be shown. */ fitHeight?: boolean; + /** + * Determines how the active tab is switched when navigating using + * the keyboard. The options are: + * - 'automatic' (default): the active tab is switched using the arrow keys. + * - 'manual': a tab must be explicitly activated using the enter/space key. + * We recommend using 'automatic' in most situations to provide consistent + * and quick switching between tabs. Use 'manual' only if there is a specific + * need to introduce friction to the switching of tabs. + */ + keyboardActivationMode?: 'automatic' | 'manual'; } export namespace TabsProps { export type Variant = 'default' | 'container' | 'stacked'; diff --git a/src/tabs/tab-header-bar.tsx b/src/tabs/tab-header-bar.tsx index ecad7eac66..fbd843ce44 100644 --- a/src/tabs/tab-header-bar.tsx +++ b/src/tabs/tab-header-bar.tsx @@ -79,6 +79,7 @@ interface TabHeaderBarProps { ariaLabel?: string; ariaLabelledby?: string; i18nStrings?: TabsProps.I18nStrings; + keyboardActivationMode: Required; actions?: TabsProps['actions']; } @@ -91,6 +92,7 @@ export function TabHeaderBar({ ariaLabel, ariaLabelledby, i18nStrings, + keyboardActivationMode, actions, }: TabHeaderBarProps) { const headerBarRef = useRef(null); @@ -224,7 +226,15 @@ export function TabHeaderBar({ function onKeyDown(event: React.KeyboardEvent) { const focusTarget = document.activeElement; - const specialKeys = [KeyCode.right, KeyCode.left, KeyCode.end, KeyCode.home, KeyCode.pageUp, KeyCode.pageDown]; + const specialKeys = [ + KeyCode.right, + KeyCode.left, + KeyCode.end, + KeyCode.home, + KeyCode.pageUp, + KeyCode.pageDown, + KeyCode.space, + ]; const isActionOpen = document.querySelector(`.${styles['tabs-tab-action']} [aria-expanded="true"]`); const isDismissOrActionFocused = !focusTarget?.classList.contains(styles['tabs-tab-link']); @@ -254,6 +264,10 @@ export function TabHeaderBar({ onInlineEnd: () => focusElement(focusables[circleIndex(activeIndex + 1, [0, focusables.length - 1])]), onPageDown: () => inlineEndOverflow && onPaginationClick(headerBarRef, 'forward'), onPageUp: () => inlineStartOverflow && onPaginationClick(headerBarRef, 'backward'), + onActivate: () => + focusedTabId && + focusedTabId !== activeTabId && + onChange({ activeTabId: focusedTabId, activeTabHref: tabs.find(tab => tab.id === focusedTabId)?.href }), }); } function focusElement(element: HTMLElement) { @@ -266,7 +280,7 @@ export function TabHeaderBar({ setPreviousActiveTabId(tabId); setFocusedTabId(tabId); - if (!tabsById.get(tabId)?.disabled) { + if (!tabsById.get(tabId)?.disabled && keyboardActivationMode === 'automatic') { onChange({ activeTabId: tabId, activeTabHref: tabsById.get(tabId)?.href }); } break;