diff --git a/web/packages/design/src/SlideTabs/SlideTabs.story.tsx b/web/packages/design/src/SlideTabs/SlideTabs.story.tsx index 46ff166c5cef7..fa2ce7dbafa2b 100644 --- a/web/packages/design/src/SlideTabs/SlideTabs.story.tsx +++ b/web/packages/design/src/SlideTabs/SlideTabs.story.tsx @@ -18,17 +18,40 @@ import React, { useState } from 'react'; +import * as Icon from 'design/Icon'; +import Flex from 'design/Flex'; + import { SlideTabs } from './SlideTabs'; export default { title: 'Design/SlideTabs', }; +const threeSimpleTabs = [ + { key: 'aws', title: 'aws' }, + { key: 'automatically', title: 'automatically' }, + { key: 'manually', title: 'manually' }, +]; + +const fiveSimpleTabs = [ + { key: 'step1', title: 'step1' }, + { key: 'step2', title: 'step2' }, + { key: 'step3', title: 'step3' }, + { key: 'step4', title: 'step4' }, + { key: 'step5', title: 'step5' }, +]; + +const titlesWithIcons = [ + { key: 'alarm', icon: Icon.AlarmRing, title: 'Clocks' }, + { key: 'bots', icon: Icon.Bots, title: 'Bots' }, + { key: 'check', icon: Icon.Check, title: 'Checkmarks' }, +]; + export const ThreeTabs = () => { const [activeIndex, setActiveIndex] = useState(0); return ( @@ -39,7 +62,7 @@ export const FiveTabs = () => { const [activeIndex, setActiveIndex] = useState(0); return ( @@ -47,34 +70,94 @@ export const FiveTabs = () => { }; export const Round = () => { - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex1, setActiveIndex1] = useState(0); + const [activeIndex2, setActiveIndex2] = useState(0); return ( - + + + + ); }; export const Medium = () => { - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex1, setActiveIndex1] = useState(0); + const [activeIndex2, setActiveIndex2] = useState(0); return ( - + + + + + ); +}; + +export const Small = () => { + const [activeIndex1, setActiveIndex1] = useState(0); + const [activeIndex2, setActiveIndex2] = useState(0); + return ( + + + + + ); }; export const LoadingTab = () => { return ( null} activeIndex={1} isProcessing={true} @@ -85,7 +168,7 @@ export const LoadingTab = () => { export const DisabledTab = () => { return ( null} activeIndex={1} disabled={true} diff --git a/web/packages/design/src/SlideTabs/SlideTabs.test.tsx b/web/packages/design/src/SlideTabs/SlideTabs.test.tsx index 12034f7b5f8aa..4636655d25700 100644 --- a/web/packages/design/src/SlideTabs/SlideTabs.test.tsx +++ b/web/packages/design/src/SlideTabs/SlideTabs.test.tsx @@ -21,13 +21,17 @@ import { screen } from '@testing-library/react'; import { render, userEvent } from 'design/utils/testing'; -import { SlideTabs } from './SlideTabs'; +import { SlideTabs, SlideTabsProps } from './SlideTabs'; describe('design/SlideTabs', () => { it('renders the supplied number of tabs(3)', () => { render( {}} activeIndex={0} /> @@ -35,15 +39,21 @@ describe('design/SlideTabs', () => { expect(screen.getAllByRole('tab')).toHaveLength(3); - expect(screen.getByLabelText('aws')).toBeInTheDocument(); - expect(screen.getByLabelText('automatically')).toBeInTheDocument(); - expect(screen.getByLabelText('manually')).toBeInTheDocument(); + expect(getTab('aws')).toBeInTheDocument(); + expect(getTab('automatically')).toBeInTheDocument(); + expect(getTab('manually')).toBeInTheDocument(); }); it('renders the supplied number of tabs(5)', () => { render( {}} activeIndex={0} /> @@ -51,44 +61,70 @@ describe('design/SlideTabs', () => { expect(screen.getAllByRole('tab')).toHaveLength(5); - expect(screen.getByLabelText('aws')).toBeInTheDocument(); - expect(screen.getByLabelText('automatically')).toBeInTheDocument(); - expect(screen.getByLabelText('manually')).toBeInTheDocument(); - expect(screen.getByLabelText('apple')).toBeInTheDocument(); - expect(screen.getByLabelText('purple')).toBeInTheDocument(); - }); - - it('respects a custom form name', () => { - const { container } = render( - {}} - activeIndex={0} - /> - ); - - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - expect(container.querySelectorAll('input[name=pineapple]')).toHaveLength(3); + expect(getTab('aws')).toBeInTheDocument(); + expect(getTab('automatically')).toBeInTheDocument(); + expect(getTab('manually')).toBeInTheDocument(); + expect(getTab('apple')).toBeInTheDocument(); + expect(getTab('purple')).toBeInTheDocument(); }); test('onChange highlights the tab clicked', async () => { render(); // First tab is selected by default. - expect(screen.getByRole('tab', { name: 'first' })).toHaveClass('selected'); + expect(getTab('first')).toHaveClass('selected'); // Select the second tab. await userEvent.click(screen.getByText('second')); - expect(screen.getByRole('tab', { name: 'second' })).toHaveClass('selected'); + expect(getTab('second')).toHaveClass('selected'); + + expect(getTab('first')).not.toHaveClass('selected'); + }); - expect(screen.getByRole('tab', { name: 'first' })).not.toHaveClass( - 'selected' + test('keyboard navigation and accessibility', async () => { + const user = userEvent.setup(); + render( + ); + expect(getTab('first')).not.toHaveFocus(); + expect(getTab('second')).not.toHaveFocus(); + + getTab('first').focus(); + expect(getTab('first')).toHaveAttribute('aria-selected', 'true'); + expect(getTab('first')).toHaveAttribute('aria-controls', 'tabpanel-1'); + expect(getTab('second')).toHaveAttribute('aria-selected', 'false'); + expect(getTab('second')).toHaveAttribute('aria-controls', 'tabpanel-2'); + + await user.keyboard('{Right}'); + expect(getTab('first')).toHaveAttribute('aria-selected', 'false'); + expect(getTab('second')).toHaveAttribute('aria-selected', 'true'); + expect(getTab('second')).toHaveFocus(); + + // Should be a no-op. + await user.keyboard('{Right}'); + expect(getTab('first')).toHaveAttribute('aria-selected', 'false'); + expect(getTab('second')).toHaveAttribute('aria-selected', 'true'); + expect(getTab('second')).toHaveFocus(); + + await user.keyboard('{Left}'); + expect(getTab('first')).toHaveAttribute('aria-selected', 'true'); + expect(getTab('second')).toHaveAttribute('aria-selected', 'false'); + expect(getTab('first')).toHaveFocus(); + + // Should be a no-op. + await user.keyboard('{Left}'); + expect(getTab('first')).toHaveAttribute('aria-selected', 'true'); + expect(getTab('second')).toHaveAttribute('aria-selected', 'false'); + expect(getTab('first')).toHaveFocus(); }); }); -const Component = () => { +const Component = (props: Partial) => { const [activeIndex, setActiveIndex] = useState(0); return ( @@ -96,6 +132,9 @@ const Component = () => { onChange={setActiveIndex} tabs={['first', 'second']} activeIndex={activeIndex} + {...props} /> ); }; + +const getTab = (name: string) => screen.getByRole('tab', { name }); diff --git a/web/packages/design/src/SlideTabs/SlideTabs.tsx b/web/packages/design/src/SlideTabs/SlideTabs.tsx index 59f636fb8f0f7..6b92c5803fdf0 100644 --- a/web/packages/design/src/SlideTabs/SlideTabs.tsx +++ b/web/packages/design/src/SlideTabs/SlideTabs.tsx @@ -16,65 +16,122 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; -import { Indicator, Text } from '..'; +import { Flex, Indicator } from 'design'; +import { IconProps } from 'design/Icon/Icon'; export function SlideTabs({ appearance = 'square', activeIndex = 0, - name = 'slide-tab', onChange, - size = 'xlarge', + size = 'large', tabs, isProcessing = false, disabled = false, -}: props) { + fitContent = false, +}: SlideTabsProps) { + const activeTab = useRef(null); + const tabContainer = useRef(null); + + useEffect(() => { + // Note: this is important for accessibility; the screen reader may ignore + // tab changing if we focus the tab list, and not the tab. + if (tabContainer.current?.contains?.(document.activeElement)) { + activeTab.current?.focus(); + } + }, [activeIndex]); + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'ArrowRight' && activeIndex < tabs.length - 1) { + onChange(activeIndex + 1); + } + if (e.key === 'ArrowLeft' && activeIndex > 0) { + onChange(activeIndex - 1); + } + } + + // The component structure was designed according to + // https://www.w3.org/WAI/ARIA/apg/patterns/tabs/examples/tabs-automatic/. return ( - - - {tabs.map((tabName, tabIndex) => { + // A container that displays background and sets up padding for the slider + // area. It's separate from the tab list itself, since we need to + // absolutely position the slider relative to this container's content box, + // and not its padding box. So we set up padding if needed on this one, and + // then position the slider against the tab list. + + + {tabs.map((tabSpec, tabIndex) => { const selected = tabIndex === activeIndex; - let onClick; + const { + key, + title, + icon: Icon, + ariaLabel, + controls, + } = toFullTabSpec(tabSpec, tabIndex); + + let onClick = undefined; if (!disabled && !isProcessing) { - onClick = (e: React.MouseEvent) => { + onClick = (e: React.MouseEvent) => { e.preventDefault(); onChange(tabIndex); }; } + return ( - - + {/* We need a separate tab content component, since the spinner, + when displayed, shouldn't take up space to prevent layout + jumping. TabContent serves as a positioning anchor whose left + edge is the left edge of the content (not the tab button, + which can be much wider). */} + {selected && isProcessing && } - {tabName} - - - + {Icon && } + {title} + + ); })} - - + {/* The tab slider is positioned absolutely and appears below the + actual tab button. The outer component is responsible for + establishing the part of parent's width where the slider appears, + and the internal slider may (or may not, depending on the control + size) include additional padding that separates tabs. */} + + + + ); } -type props = { +export type SlideTabsProps = { /** * The style to render the selector in. */ @@ -83,11 +140,6 @@ type props = { * The index that you'd like to select on the initial render. */ activeIndex: number; - /** - * The name you'd like to use for the form if using multiple tabs on the page. - * Default: "slide-tab" - */ - name?: string; /** * To be notified when the selected tab changes supply it with this fn. */ @@ -95,11 +147,11 @@ type props = { /** * The size to render the selector in. */ - size?: 'xlarge' | 'medium'; + size?: Size; /** - * A list of tab names that you'd like displayed in the list of tabs. + * A list of tab specs that you'd like displayed in the list of tabs. */ - tabs: string[]; + tabs: TabSpec[]; /** * If true, renders a spinner and disables clicking on the tabs. * @@ -112,77 +164,189 @@ type props = { * If true, disables pointer events. */ disabled?: boolean; + /** + * If true, the control doesn't take as much horizontal space as possible, + * but instead wraps its contents. + */ + fitContent?: boolean; }; -export type TabComponent = { - name: string; - component: React.ReactNode; +/** + * Definition of a tab. If it's a string, it denotes a title displayed on the + * tab. It's recommended to use a full object with tab panel ID for better + * accessibility. + * + * TODO(bl-nero): remove the string option once Enterprise is migrated to + * simplify it a bit. + */ +type TabSpec = string | FullTabSpec; + +type FullTabSpec = TabContentSpec & { + /** Iteration key for the tab. */ + key: React.Key; + /** + * ID of the controlled element for accessibility, perhaps autogenerated + * with an `useId()` hook. The indicated element should have a `role` + * attribute set to "tabpanel". + */ + controls?: string; }; -const Wrapper = styled.div` +/** + * Tab content. Either an icon with a mandatory accessible label, or a + * decorative icon with accompanying text. + */ +type TabContentSpec = + | { + /** Title displayed on the tab. */ + title: string; + ariaLabel?: never; + /** Icon displayed on the tab. */ + icon?: React.ComponentType; + } + | { + title?: never; + /** Accessible label for the tab. */ + ariaLabel: string; + /** Icon displayed on the tab. */ + icon: React.ComponentType; + }; + +function toFullTabSpec(spec: TabSpec, index: number): FullTabSpec { + if (typeof spec !== 'string') return spec; + return { + key: index, + title: spec, + }; +} + +const TabSliderInner = styled.div<{ appearance: Appearance }>` + height: 100%; + background-color: ${({ theme }) => theme.colors.brand}; + border-radius: ${props => (props.appearance === 'square' ? '8px' : '60px')}; +`; + +const Wrapper = styled.div<{ + fitContent: boolean; + size: Size; + appearance: Appearance; +}>` position: relative; + ${props => (props.fitContent ? 'width: fit-content;' : '')} + /* + * For the small size, we don't use paddings between tab buttons. Therefore, + * the area of tab list is evenly divided into segments, and we anchor the + * slider relative to the box with horizontal padding. With larger sizes, we + * expect to have some distance between the tab buttons. It means that the + * positions of the slider, expressed as relative to what the padding box + * would be, are no longer proportional to the tab index (there is distance + * between the tabs, but no distance on the left of the first tab and on the + * right of the last tab). Therefore, to calculate the position of slider as + * a percentage of its container's width, we set the wrapper's horizontal + * padding to 0, thus giving us a couple of pixels of breathing room; now we + * can go back to using a linear formula to calculate the slider position. + * This lack of padding will be then compensated for by adjusting tab button + * margins appropriately. + */ + padding: ${props => (props.size === 'small' ? '4px 4px' : '8px 0')}; + background-color: ${props => props.theme.colors.interactive.tonal.neutral[0]}; + border-radius: ${props => (props.appearance === 'square' ? '8px' : '60px')}; + + &:has(:focus-visible) ${TabSliderInner} { + outline: 2px solid ${props => props.theme.colors.brand}; + outline-offset: 1px; + } `; -const TabLabel = styled.label<{ - itemCount: number; +const tabButtonHeight = ({ size }: { size: Size }) => { + switch (size) { + case 'large': + return { height: '40px' }; + case 'medium': + return { height: '36px' }; + case 'small': + return { height: '32px' }; + } +}; + +const TabButton = styled.button<{ processing?: boolean; disabled?: boolean; + selected?: boolean; + size: Size; }>` + /* Reset the button styles. */ + font-family: inherit; + text-decoration: inherit; + outline: none; + border: none; + background: transparent; + padding: ${props => (props.size === 'small' ? '8px 8px' : '8px 16px')}; + + ${props => props.theme.typography.body2} + cursor: ${p => (p.processing || p.disabled ? 'default' : 'pointer')}; display: flex; + align-items: center; justify-content: center; - padding: 10px; - width: ${props => 100 / props.itemCount}%; z-index: 1; /* Ensures that the label is above the background slider. */ opacity: ${p => (p.processing || p.disabled ? 0.5 : 1)}; -`; + ${tabButtonHeight} + /* + * Using similar logic as with wrapper padding, we compensate for the lack of + * thereof with button margins if needed. + */ + margin: 0 ${props => (props.size === 'small' ? '0' : '8px')}; + color: ${props => + props.selected + ? props.theme.colors.text.primaryInverse + : props.theme.colors.text.main}; -const TabInput = styled.input` - display: none; + transition: color 0.2s ease-in 0s; `; type Appearance = 'square' | 'round'; -type Size = 'xlarge' | 'medium'; +type Size = 'large' | 'medium' | 'small'; const TabSlider = styled.div<{ - appearance: Appearance; itemCount: number; size: Size; activeIndex: number; }>` - background-color: ${({ theme }) => theme.colors.brand}; - border-radius: ${props => (props.appearance === 'square' ? '8px' : '60px')}; - box-shadow: 0px 2px 6px rgba(12, 12, 14, 0.1); - height: ${props => (props.size === 'xlarge' ? '56px' : '40px')}; - left: calc(${props => (100 / props.itemCount) * props.activeIndex}% + 8px); - margin: ${props => - props.size === 'xlarge' ? '12px 12px 12px 0' : '4px 4px 4px 0'}; + box-sizing: border-box; position: absolute; + left: ${props => (100 / props.itemCount) * props.activeIndex}%; top: 0; - transition: all 0.3s ease; - width: calc(${props => 100 / props.itemCount}% - 16px); + bottom: 0; + width: ${props => 100 / props.itemCount}%; + padding: 0 ${props => (props.size === 'small' ? '0' : '8px')}; + transition: + all 0.3s ease, + outline 0s, + outline-offset 0s; `; -const TabNav = styled.nav<{ appearance: Appearance; size: Size }>` +const TabList = styled.div<{ itemCount: number }>` + position: relative; align-items: center; - background-color: ${props => props.theme.colors.spotBackground[0]}; - border-radius: ${props => (props.appearance === 'square' ? '8px' : '60px')}; - display: flex; - height: ${props => (props.size === 'xlarge' ? '80px' : '47px')}; + /* + * Grid display allows us to allocate equal amount of space for every tab + * (which is important for calculating the slider position) and at the same + * time support the "fit content" mode. (It's impossible to do in the flex + * layout.) + */ + display: grid; + grid-template-columns: repeat(${props => props.itemCount}, 1fr); justify-content: space-around; color: ${props => props.theme.colors.text.main}; - .selected { - color: ${props => props.theme.colors.text.primaryInverse}; - transition: color 0.2s ease-in 0s; - } `; const Spinner = styled(Indicator)` color: ${p => p.theme.colors.levels.deep}; position: absolute; - left: -${p => p.theme.space[4]}px; + left: -${p => p.theme.space[5]}px; `; -const Box = styled.div` +const TabContent = styled(Flex)` position: relative; `;