diff --git a/src/components/tabs/Tabs.spec.tsx b/src/components/tabs/Tabs.spec.tsx new file mode 100644 index 000000000..6e23acd95 --- /dev/null +++ b/src/components/tabs/Tabs.spec.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import {fireEvent, render, screen} from '@testing-library/react'; +import Tabs, {Tab} from './Tabs'; + +const hiddenPanelClass = 'sg-tabs__panel--hidden'; + +describe('', () => { + it('shows by default all tabs and only first panel', () => { + render( + + + + First tab + Second tab + + + Content 1 + Content 2 + + ); + expect(screen.getByText('First tab')).toBeInTheDocument(); + expect(screen.getByText('Second tab')).toBeInTheDocument(); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass); + }); + + it('shows panel corresponding to startIndex', () => { + render( + + + + First tab + Second tab + + + Content 1 + Content 2 + + ); + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass); + }); + + it('correctly handles tab change', () => { + const mockOnChange = jest.fn(); + + render( + + + + First tab + Second tab + + + Content 1 + Content 2 + + ); + expect(screen.getByText('Content 1')).not.toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass); + const secondTab = screen.getByText('Second tab'); + + fireEvent.click(secondTab); + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass); + expect(mockOnChange).toBeCalledWith( + screen.getByText('Second tab').parentElement + ); + }); + + it('makes component controlled if correct props is passed', () => { + const mockActiveIndex = 2; + const mockOnChange = jest.fn(); + const {rerender} = render( + + + + First tab + Second tab + Third tab + + + Content 1 + Content 2 + Content 3 + + ); + + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 3')).not.toHaveClass(hiddenPanelClass); + + const secondTab = screen.getByText('Second tab'); + + fireEvent.click(secondTab); + + expect(mockOnChange).not.toHaveBeenCalled(); + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 3')).not.toHaveClass(hiddenPanelClass); + + rerender( + + + + First tab + Second tab + Third tab + + + Content 1 + Content 2 + Content 3 + + ); + + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 3')).toHaveClass(hiddenPanelClass); + }); +}); diff --git a/src/components/tabs/Tabs.stories.mdx b/src/components/tabs/Tabs.stories.mdx new file mode 100644 index 000000000..90db519af --- /dev/null +++ b/src/components/tabs/Tabs.stories.mdx @@ -0,0 +1,209 @@ +import Button from '../buttons/Button'; +import Flex from '../flex/Flex'; +import Icon from '../icons/Icon'; +import Text from '../text/Text'; +import Checkbox from '../form-elements/checkbox/Checkbox'; +import PageHeader from 'blocks/PageHeader'; +import {useState} from 'react'; +import classnames from 'classnames'; +import {TabHeaderProps} from './components'; +import Tabs, {Tab, TabsProps} from './Tabs.tsx'; +import {ArgsTable, Meta, Story, Canvas} from '@storybook/addon-docs'; +import TabsA11y from './stories/Tabs.a11y.mdx'; + + + +Tabs + +- [Stories](#stories) +- [Accessibility](#accessibility) + +## Overview + + + + {args => ( + + + + Tab name + Tab name 2 + Tab name with a very very very long name + Tab + + + + + Content 1 + + + Content 2 + + + Content 3 + + + Content 4 + + + )} + + + + + +## Stories + +### External Control + + + + {args => { + const [activeIndex, setActiveIndex] = useState(0); + return ( + <> + + + + + + Content 1 + + + Content 2 + + + Content 3 + + + Content 4 + + + + ); + }} + + + +## Accessibility + + diff --git a/src/components/tabs/Tabs.tsx b/src/components/tabs/Tabs.tsx new file mode 100644 index 000000000..134854960 --- /dev/null +++ b/src/components/tabs/Tabs.tsx @@ -0,0 +1,128 @@ +/* eslint-disable no-redeclare */ +/** + * We need to disable no-redeclare as redeclaration allows us to use + * TS overloading to type different usages scenarios + */ +import * as React from 'react'; +import type {PanelElement, TabElement, WithChildren} from './components'; +import {TabContext} from './hooks'; + +export type TabsProps = WithChildren & { + onTabChange?: (currentActiveTab: TabElement) => void; + startIndex?: number; + activeIndex?: number; +}; + +export type Context = { + activeTab: TabElement; + activePanel: PanelElement; + setActiveIndex: (tab: TabElement) => void; + registerTab: (tab: TabElement) => void; + registerPanel: (panel: PanelElement) => void; + a11yHelpers: { + getTabHelpers: (tab: TabElement) => { + id: string; + controls: string; + }; + getPanelHelpers: (panel: PanelElement) => { + id: string; + labelledBy: string; + }; + }; +}; + +/** + * Providing activeIndex turns Tabs into controlled component. + * Because of that providing `startIndex` doesn't make sense + * as component is controlled externally. + */ +function Tabs({ + children, + onTabChange, + activeIndex, + ...rest +}: Omit): JSX.Element; + +/** + * `startIndex` is exclusive to uncontrolled component, as + * by default component handles tab change itself. + * Because of that providing `activeIndex` to this component + * is a mistake. + */ +function Tabs({ + children, + onTabChange, + startIndex, + ...rest +}: Omit): JSX.Element; + +function Tabs({ + children, + onTabChange = () => undefined, + startIndex = 0, + activeIndex, + ...rest +}: TabsProps) { + const isControlledComponent = activeIndex !== undefined; + const [tabs, setTabs] = React.useState([]); + const [panels, setPanels] = React.useState([]); + const [currentSelectedIndex, setCurrentSelectedIndex] = + React.useState(startIndex); + + const activeTab = tabs[currentSelectedIndex]; + const activePanel = panels[currentSelectedIndex]; + const setActiveIndex = (tab: TabElement) => { + if (isControlledComponent) { + return; + } + setCurrentSelectedIndex(tabs.indexOf(tab)); + onTabChange(tab); + }; + + const a11yHelpers = { + getTabHelpers: (tab: TabElement) => { + const currentTabIndex = tabs.indexOf(tab); + + return { + id: `tab-${currentTabIndex}`, + controls: `panel-${currentTabIndex}`, + }; + }, + getPanelHelpers: (panel: PanelElement) => { + const currentPanelIndex = panels.indexOf(panel); + + return { + id: `panel-${currentPanelIndex}`, + labelledBy: `tab-${currentPanelIndex}`, + }; + }, + }; + + const initialContextState: Context = { + activeTab, + activePanel, + setActiveIndex, + registerTab: React.useCallback( + tab => setTabs(previousTabs => [...previousTabs, tab]), + [] + ), + registerPanel: React.useCallback( + panel => setPanels(previousPanels => [...previousPanels, panel]), + [] + ), + a11yHelpers, + }; + + if (isControlledComponent && currentSelectedIndex !== activeIndex) { + setCurrentSelectedIndex(activeIndex); + } + + return ( + +
{children}
+
+ ); +} + +export {Tabs as default}; +export {Tab} from './components'; diff --git a/src/components/tabs/_tabs.scss b/src/components/tabs/_tabs.scss new file mode 100644 index 000000000..ccf667d84 --- /dev/null +++ b/src/components/tabs/_tabs.scss @@ -0,0 +1,83 @@ +@use 'sass:map'; + +.sg-tabs { + &__header { + --border-color: var(--gray-20); + height: var(--list-height); + border-bottom: 2px solid var(--border-color); + } + + &__list { + margin: 0; + padding: 0; + position: relative; + } + + &__tab { + --inactive-text-color: var(--text-gray-50); + --active-text-color: var(--text-black); + + padding: 0 map.get($sizesSetup, 's'); + height: 100%; + border-radius: 1px; + + &:hover { + cursor: pointer; + .tabText { + opacity: 0.7; + } + } + + &:focus-visible { + box-shadow: 0px 0px 0px 2px var(--white), + 0px 0px 0px 4px var(--focusColor), + 0px 0px 0px 6px rgba(109, 131, 243, 0.3); + } + + &--disabled { + pointer-events: none; + opacity: 0.45; + } + + &-text { + transition: color $durationModerate1 $easingRegular; + color: var(--inactive-text-color); + + &--active { + color: var(--active-text-color); + } + } + } + + &__panel { + &--hidden { + display: none; + } + } + + &__active-indicator { + /** + * [1] - We want indicator to cover header bottom border + * so we need manually to reposition it + */ + position: absolute; + height: 2px; + left: 0; + width: 100%; + + &--bottom { + bottom: -2px; // [1] + } + + &--top { + top: -2px; // [1] + } + + &--inner { + --color: var(--black); + background-color: var(--color); + width: 100%; + height: 100%; + } + } +} diff --git a/src/components/tabs/components/ActiveIndicator.tsx b/src/components/tabs/components/ActiveIndicator.tsx new file mode 100644 index 000000000..0952ebbdf --- /dev/null +++ b/src/components/tabs/components/ActiveIndicator.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import Transition, {TransitionEffectType} from '../../transition/Transition'; +import classnames from 'classnames'; +import {useTabsContext} from '../hooks'; + +type StyleType = Partial< + React.CSSProperties & { + '--color': string; + } +>; +export type ActiveIndicatorProps = React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement +> & { + position?: 'top' | 'bottom'; + style?: StyleType; + className?: string; +}; + +export const ActiveIndicator = ({ + position = 'bottom', + className, + ...rest +}: ActiveIndicatorProps) => { + const {activeTab} = useTabsContext(); + + if (!activeTab) { + return null; + } + + const {x: activeTabX, width: activeTabWidth} = + activeTab.getBoundingClientRect(); + const {x: offsetX, width: containerWidth} = + activeTab.offsetParent.getBoundingClientRect(); + + const activeTabToContainerRatio = + containerWidth > 0 ? activeTabWidth / containerWidth : 0; + + const activeIndicatorEffect: TransitionEffectType = { + animate: { + transform: { + translateX: activeTabX - offsetX, + origin: 'left top', + scaleX: activeTabToContainerRatio, + }, + duration: 'moderate2', + easing: 'entry', + }, + }; + + return ( + +
+ + ); +}; diff --git a/src/components/tabs/components/Header.tsx b/src/components/tabs/components/Header.tsx new file mode 100644 index 000000000..9d78b04a6 --- /dev/null +++ b/src/components/tabs/components/Header.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import Flex, {FlexPropsType} from '../../flex/Flex'; +import classnames from 'classnames'; +import type {WithChildren} from './Tab'; + +type StyleType = Partial< + React.CSSProperties & { + '--list-height': string; + '--border-color': string; + } +>; + +export type TabHeaderProps = WithChildren & + FlexPropsType & { + height?: string; + style?: StyleType; + }; + +export const Header = ({ + children, + height = '48px', + className, + style = {'--list-height': height}, + ...rest +}: TabHeaderProps) => ( + + {children} + +); diff --git a/src/components/tabs/components/List.tsx b/src/components/tabs/components/List.tsx new file mode 100644 index 000000000..888b30583 --- /dev/null +++ b/src/components/tabs/components/List.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import Flex, {FlexPropsType} from '../../flex/Flex'; +import classnames from 'classnames'; +import type {WithChildren} from './Tab'; + +export type TabListProps = WithChildren & FlexPropsType; +export const List = ({children, className, ...rest}: TabListProps) => ( + + {children} + +); diff --git a/src/components/tabs/components/Panel.tsx b/src/components/tabs/components/Panel.tsx new file mode 100644 index 000000000..d409b0438 --- /dev/null +++ b/src/components/tabs/components/Panel.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import Flex, {FlexPropsType} from '../../flex/Flex'; +import classnames from 'classnames'; +import {useTabsContext} from '../hooks'; +import type {WithChildren} from './Tab'; + +export type PanelElement = HTMLDivElement | undefined; +export type TabPanelProps = WithChildren & FlexPropsType; + +export const Panel = ({children, ...rest}: TabPanelProps) => { + const {activePanel, registerPanel, a11yHelpers} = useTabsContext(); + const panelRef = React.useRef(); + const {id, labelledBy} = a11yHelpers.getPanelHelpers(panelRef.current); + const callbackRef = React.useCallback( + (panel: PanelElement | null) => { + if (panel) { + panelRef.current = panel; + registerPanel(panel); + } + }, + [registerPanel] + ); + const isActive = panelRef.current === activePanel; + + return ( + + {children} + + ); +}; diff --git a/src/components/tabs/components/Tab.tsx b/src/components/tabs/components/Tab.tsx new file mode 100644 index 000000000..9a33eac2f --- /dev/null +++ b/src/components/tabs/components/Tab.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import Text from '../../text/Text'; +import Flex, {FlexPropsType} from '../../flex/Flex'; +import classnames from 'classnames'; +import {useTabsContext} from '../hooks'; +import {Header} from './Header'; +import {List} from './List'; +import {ActiveIndicator} from './ActiveIndicator'; +import {Panel} from './Panel'; + +type StyleType = { + style?: Partial< + React.CSSProperties & { + '--inactive-text-color': string; + '--active-text-color': string; + } + >; +}; + +export type WithChildren = {children: React.ReactNode}; +export type TabElement = HTMLLIElement | undefined; +export type TabProps = WithChildren & FlexPropsType & StyleType; + +export const Tab = ({children, className, disabled, ...rest}: TabProps) => { + const {activeTab, registerTab, setActiveIndex, a11yHelpers} = + useTabsContext(); + const tabRef = React.useRef(); + const callbackRef = React.useCallback( + (tab: TabElement | null) => { + if (tab) { + tabRef.current = tab; + registerTab(tab); + } + }, + [registerTab] + ); + + const {id, controls} = a11yHelpers.getTabHelpers(tabRef.current); + const isActive = activeTab !== undefined && tabRef.current === activeTab; + + return ( + setActiveIndex(tabRef.current)} + ref={callbackRef} + className={classnames('sg-tabs__tab', className, { + 'sg-tabs__tab--disabled': disabled && !isActive, + })} + alignItems="center" + role="tab" + aria-selected={isActive} + aria-controls={controls} + suppressHydrationWarning + > + + {children} + + + ); +}; + +Tab.Header = Header; +Tab.List = List; +Tab.ActiveIndicator = ActiveIndicator; +Tab.Panel = Panel; diff --git a/src/components/tabs/components/index.tsx b/src/components/tabs/components/index.tsx new file mode 100644 index 000000000..b5e6e279e --- /dev/null +++ b/src/components/tabs/components/index.tsx @@ -0,0 +1,5 @@ +export * from './ActiveIndicator'; +export * from './Header'; +export * from './List'; +export * from './Panel'; +export * from './Tab'; diff --git a/src/components/tabs/hooks.ts b/src/components/tabs/hooks.ts new file mode 100644 index 000000000..1a6528753 --- /dev/null +++ b/src/components/tabs/hooks.ts @@ -0,0 +1,13 @@ +import {createContext, useContext} from 'react'; +import type {Context} from './Tabs'; + +export const TabContext = createContext({} as Context); + +export const useTabsContext = () => { + const tabsContext = useContext(TabContext); + + if (!tabsContext) { + throw new Error('useTabContext must be used within TabContextProvider'); + } + return tabsContext; +}; diff --git a/src/components/tabs/stories/Tabs.a11y.mdx b/src/components/tabs/stories/Tabs.a11y.mdx new file mode 100644 index 000000000..af00592ab --- /dev/null +++ b/src/components/tabs/stories/Tabs.a11y.mdx @@ -0,0 +1,37 @@ +import rules, { + tabRules, + tabActiveIndicatorRules, + tabListRules, + tabPanelRules, + tabHeaderRules, +} from './rules.a11y'; + +### Rules + +#### Tabs + + + +#### Tab + + + +#### Tab.ActiveIndicator + + + +#### Tab.List + + + +#### Tab.Panel + + + +#### Tab.Header + + + +### Usage + +#### Code examples diff --git a/src/components/tabs/stories/rules.a11y.ts b/src/components/tabs/stories/rules.a11y.ts new file mode 100644 index 000000000..5c14c8848 --- /dev/null +++ b/src/components/tabs/stories/rules.a11y.ts @@ -0,0 +1,164 @@ +const rules = [ + { + pattern: 'Can have an accessible name.', + comment: + 'Can be named by setting a value for title prop (defaults to icon type).', + status: '', + tests: '', + }, +]; + +export const tabRules = [ + { + pattern: 'Should have an accessible name.', + comment: `Name should be meaningful. aria-label can be used to provide a name.`, + status: '', + tests: '', + }, + { + pattern: 'Should have a role tab.', + status: '', + tests: '', + }, + { + pattern: 'Should have an associated tab panel.', + status: '', + tests: '', + }, + { + pattern: + 'Should be tabbable and focusable when associated tab panel is active.', + status: '', + tests: '', + }, + { + pattern: + 'Should have a color indicator with 4.5:1 contrast ratio to the background.', + comment: 'gray-50 against white: 4.37:1.', + status: '', + tests: '', + }, + { + pattern: + 'Should have a color active indicator with 3:1 contrast ratio to the surrounding background.', + comment: 'Tab.ActiveIndicator should be used.', + status: '', + tests: '', + }, + { + pattern: 'Should have cursor: default.', + status: '', + tests: '', + }, + { + pattern: + 'Should be activated on Space/Enter press and mouse click.', + status: '', + tests: '', + }, + { + pattern: + 'Should be contained in, or owned by, an element with the role tablist.', + status: '', + tests: '', + }, + { + pattern: + 'Should have the aria-selected attribute set to true when associated tab panel is active.', + status: '', + tests: '', + }, + { + pattern: + 'Can have the aria-controls attribute to reference the associated tab panel.', + status: '', + tests: '', + }, +]; + +export const tabActiveIndicatorRules = [ + { + pattern: + 'Should have a color active indicator with 3:1 contrast ratio to the surrounding background.', + status: '', + tests: '', + }, + { + pattern: 'Should be hidden from accessibility tree.', + status: '', + tests: '', + }, + { + pattern: 'Should respect prefers-reduced-motion.', + comment: `prefers-reduced-motion system setting is respected.`, + status: '', + tests: '', + }, +]; + +export const tabListRules = [ + { + pattern: 'Should have a role tablist.', + status: '', + tests: '', + }, + { + pattern: 'Should contain element with tab role.', + status: '', + tests: '', + }, + { + pattern: + 'Should have aria-orientation set to horizontal.', + status: '', + tests: '', + }, + { + pattern: 'Should have an accessible name.', + comment: `Name should be meaningful. aria-label and aria-labelledby can be used to provide a name.`, + status: '', + tests: '', + }, + { + pattern: + 'Should move focus between tabs on Arrow Left and Arrow Right press.', + status: '', + tests: '', + }, +]; + +export const tabPanelRules = [ + { + pattern: 'Should have a role tabpanel.', + status: '', + tests: '', + }, + { + pattern: + 'Should have an accessible name connected with associated tab.', + comment: `aria-labelledby can be used to provide a name.`, + status: '', + tests: '', + }, + { + pattern: + 'Should be focusable when the it does not contain any focusable elements.', + status: '', + tests: '', + }, + { + pattern: 'Should have an associated tab.', + status: '', + tests: '', + }, +]; + +export const tabHeaderRules = [ + { + pattern: 'Should be only presentational.', + status: '', + tests: '', + }, +]; + +export default rules; diff --git a/src/index.ts b/src/index.ts index ac6713d77..c135efa75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,3 +169,5 @@ export type {SkipLinkPropsType} from './components/skip-link/SkipLink'; export {default as SkipLink} from './components/skip-link/SkipLink'; export type {ProgressBarPropsType} from './components/progress-bar/ProgressBar'; export {default as ProgressBar} from './components/progress-bar/ProgressBar'; +export type {TabsProps} from './components/tabs/Tabs'; +export {default as Tabs, Tab} from './components/tabs/Tabs'; diff --git a/src/sass/main.scss b/src/sass/main.scss index aeab08ef1..9c191c3fc 100644 --- a/src/sass/main.scss +++ b/src/sass/main.scss @@ -67,3 +67,4 @@ $sgFontsPath: 'fonts/' !default; @import '../components/transition/transition'; @import '../components/skip-link/skip-link'; @import '../components/progress-bar/progress-bar'; +@import '../components/tabs/tabs';