From 23db80ec7b135a2320eead5c5c5ca23023d1e46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Tue, 21 Feb 2023 17:03:19 +0100 Subject: [PATCH 01/12] Initial version of Tabs component --- src/components/tabs/Tabs.spec.tsx | 115 +++++++++++ src/components/tabs/Tabs.stories.mdx | 191 ++++++++++++++++++ src/components/tabs/Tabs.tsx | 116 +++++++++++ src/components/tabs/_tabs.scss | 83 ++++++++ .../tabs/components/ActiveIndicator.tsx | 51 +++++ src/components/tabs/components/Header.tsx | 25 +++ src/components/tabs/components/List.tsx | 16 ++ src/components/tabs/components/Panel.tsx | 36 ++++ src/components/tabs/components/Tab.tsx | 61 ++++++ src/components/tabs/components/index.tsx | 5 + src/components/tabs/hooks.ts | 12 ++ src/index.ts | 3 + src/sass/main.scss | 1 + 13 files changed, 715 insertions(+) create mode 100644 src/components/tabs/Tabs.spec.tsx create mode 100644 src/components/tabs/Tabs.stories.mdx create mode 100644 src/components/tabs/Tabs.tsx create mode 100644 src/components/tabs/_tabs.scss create mode 100644 src/components/tabs/components/ActiveIndicator.tsx create mode 100644 src/components/tabs/components/Header.tsx create mode 100644 src/components/tabs/components/List.tsx create mode 100644 src/components/tabs/components/Panel.tsx create mode 100644 src/components/tabs/components/Tab.tsx create mode 100644 src/components/tabs/components/index.tsx create mode 100644 src/components/tabs/hooks.ts diff --git a/src/components/tabs/Tabs.spec.tsx b/src/components/tabs/Tabs.spec.tsx new file mode 100644 index 000000000..9825e9877 --- /dev/null +++ b/src/components/tabs/Tabs.spec.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import {fireEvent, render, screen} from '@testing-library/react'; +import Tabs, {Tab} from './Tabs'; + +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('panel--hidden'); + }); + + it('shows panel corresponding to startIndex', () => { + render( + + + + First tab + Second tab + + + Content 1 + Content 2 + + ); + expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 2')).not.toHaveClass('panel--hidden'); + }); + + 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('panel--hidden'); + expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); + const secondTab = screen.getByText('Second tab'); + fireEvent.click(secondTab); + expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 2')).not.toHaveClass('panel--hidden'); + 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('panel--hidden'); + expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 3')).not.toHaveClass('panel--hidden'); + + const secondTab = screen.getByText('Second tab'); + fireEvent.click(secondTab); + + expect(mockOnChange).not.toHaveBeenCalled(); + expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 3')).not.toHaveClass('panel--hidden'); + + rerender( + + + + First tab + Second tab + Third tab + + + Content 1 + Content 2 + Content 3 + + ); + + expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 2')).not.toHaveClass('panel--hidden'); + expect(screen.getByText('Content 3')).toHaveClass('panel--hidden'); + }); +}); diff --git a/src/components/tabs/Tabs.stories.mdx b/src/components/tabs/Tabs.stories.mdx new file mode 100644 index 000000000..4bc80d071 --- /dev/null +++ b/src/components/tabs/Tabs.stories.mdx @@ -0,0 +1,191 @@ +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 {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'; + + + +Tabs + +## 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 + + + + ); + }} + + diff --git a/src/components/tabs/Tabs.tsx b/src/components/tabs/Tabs.tsx new file mode 100644 index 000000000..7a2530468 --- /dev/null +++ b/src/components/tabs/Tabs.tsx @@ -0,0 +1,116 @@ +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 = () => {}, + 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: tab => setTabs(previousTabs => [...previousTabs, tab]), + registerPanel: 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..bc30aca71 --- /dev/null +++ b/src/components/tabs/_tabs.scss @@ -0,0 +1,83 @@ +@use 'sass:map'; + +.sg-tabs { + &__header { + --tabHeaderBorderColor: var(--gray-20); + height: var(--list-height); + border-bottom: 2px solid var(--tabHeaderBorderColor); + } + + &__list { + margin: 0; + padding: 0; + position: relative; + } + + &__tab { + --tabTextColor: var(--text-gray-50); + --tabActiveTextColor: 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(--tabTextColor); + + &--active { + color: var(--tabActiveTextColor); + } + } + } + + &__panel { + &--hidden { + display: none; + } + } + + &__active-indicator { + /** + * [1] - We want indicator to cover header bottom border + * so we need manually to reposition it + */ + --tabActiveIndicatorColor: var(--black); + position: absolute; + height: 2px; + background-color: var(--tabActiveIndicatorColor); + left: 0; + width: 100px; + + &--bottom { + bottom: -2px; // [1] + } + + &--top { + top: -2px; // [1] + } + + &--inner { + 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..e24b592a8 --- /dev/null +++ b/src/components/tabs/components/ActiveIndicator.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import Transition, {TransitionEffectType} from '../../transition/Transition'; +import classnames from 'classnames'; +import {useTabsContext} from '../hooks'; + +export type ActiveIndicatorProps = React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement +> & { + position?: 'top' | 'bottom'; +}; + +export const ActiveIndicator = ({ + position = 'bottom', + className, + ...rest +}: ActiveIndicatorProps) => { + const {activeTab} = useTabsContext(); + + if (!activeTab) { + return null; + } + + const {x, width} = activeTab.getBoundingClientRect(); + const offsetParentBoundClientRect = + activeTab.offsetParent?.getBoundingClientRect(); + const offsetX = Number(offsetParentBoundClientRect?.x); + + const activeIndicatorEffect: {animate: TransitionEffectType['animate']} = { + animate: { + width, + transform: {translateX: x - offsetX, origin: 'left top'}, + 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..51ae6a3f1 --- /dev/null +++ b/src/components/tabs/components/Header.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import Flex, {FlexPropsType} from '../../flex/Flex'; +import classnames from 'classnames'; +import type {WithChildren} from './Tab'; + +export type TabHeaderProps = WithChildren & + FlexPropsType & { + height?: string; + }; + +export const Header = ({ + children, + height = '48px', + className, + ...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..b274c9c47 --- /dev/null +++ b/src/components/tabs/components/Panel.tsx @@ -0,0 +1,36 @@ +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); + } + }, []); + 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..4d580cd64 --- /dev/null +++ b/src/components/tabs/components/Tab.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import Text from '../../text/Text'; +import Flex, {FlexPropsType} from '../../flex/Flex'; +import {useCallback, useRef} from 'react'; +import classnames from 'classnames'; +import {useTabsContext} from '../hooks'; +import {Header} from './Header'; +import {List} from './List'; +import {ActiveIndicator} from './ActiveIndicator'; +import {Panel} from './Panel'; + +export type WithChildren = {children: React.ReactNode}; +export type TabElement = HTMLLIElement | undefined; +export type TabProps = WithChildren & FlexPropsType; + +export const Tab = ({children, className, disabled, ...rest}: TabProps) => { + const {activeTab, registerTab, setActiveIndex, a11yHelpers} = + useTabsContext(); + const tabRef = useRef(); + const callbackRef = useCallback((tab: TabElement | null) => { + if (tab) { + tabRef.current = tab; + registerTab(tab); + } + }, []); + + 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} + > + + {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..3693492ff --- /dev/null +++ b/src/components/tabs/hooks.ts @@ -0,0 +1,12 @@ +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/index.ts b/src/index.ts index ac6713d77..ec54b92d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,3 +169,6 @@ 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} from './components/tabs/Tabs'; +export {Tab} from './components/tabs/components/Tab'; 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'; From 4ef9562a632e1b51d06071a4fbfcf7a04a8dc121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Tue, 21 Feb 2023 20:49:21 +0100 Subject: [PATCH 02/12] Fix prettier error --- src/components/tabs/hooks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tabs/hooks.ts b/src/components/tabs/hooks.ts index 3693492ff..42af391bd 100644 --- a/src/components/tabs/hooks.ts +++ b/src/components/tabs/hooks.ts @@ -1,5 +1,5 @@ -import { createContext, useContext } from 'react'; -import type { Context } from './Tabs'; +import {createContext, useContext} from 'react'; +import type {Context} from './Tabs'; export const TabContext = createContext({} as Context); From 24894209a2e04032b7050e32c37e76cbc39e790e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Tue, 21 Feb 2023 21:10:44 +0100 Subject: [PATCH 03/12] Improve CSS variables naming --- src/components/tabs/_tabs.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/tabs/_tabs.scss b/src/components/tabs/_tabs.scss index bc30aca71..f4097746b 100644 --- a/src/components/tabs/_tabs.scss +++ b/src/components/tabs/_tabs.scss @@ -2,9 +2,9 @@ .sg-tabs { &__header { - --tabHeaderBorderColor: var(--gray-20); + --border-color: var(--gray-20); height: var(--list-height); - border-bottom: 2px solid var(--tabHeaderBorderColor); + border-bottom: 2px solid var(--border-color); } &__list { @@ -14,8 +14,8 @@ } &__tab { - --tabTextColor: var(--text-gray-50); - --tabActiveTextColor: var(--text-black); + --inactive-text-color: var(--text-gray-50); + --active-text-color: var(--text-black); padding: 0 map.get($sizesSetup, 's'); height: 100%; @@ -41,10 +41,10 @@ &-text { transition: color $durationModerate1 $easingRegular; - color: var(--tabTextColor); + color: var(--inactive-text-color); &--active { - color: var(--tabActiveTextColor); + color: var(--active-text-color); } } } @@ -60,10 +60,10 @@ * [1] - We want indicator to cover header bottom border * so we need manually to reposition it */ - --tabActiveIndicatorColor: var(--black); + --color: var(--black); position: absolute; height: 2px; - background-color: var(--tabActiveIndicatorColor); + background-color: var(--color); left: 0; width: 100px; From 0ed116fc3c88c7c560f71576a0249cfcf1537b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Tue, 21 Feb 2023 21:32:17 +0100 Subject: [PATCH 04/12] Add TS typings for css variables. This commit changes styling of active indicator as well. That's because of Transition component overwriting style attribute. --- src/components/tabs/_tabs.scss | 4 ++-- src/components/tabs/components/ActiveIndicator.tsx | 13 +++++++++++-- src/components/tabs/components/Header.tsx | 11 ++++++++++- src/components/tabs/components/Tab.tsx | 11 ++++++++++- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/components/tabs/_tabs.scss b/src/components/tabs/_tabs.scss index f4097746b..dd13c503e 100644 --- a/src/components/tabs/_tabs.scss +++ b/src/components/tabs/_tabs.scss @@ -60,10 +60,8 @@ * [1] - We want indicator to cover header bottom border * so we need manually to reposition it */ - --color: var(--black); position: absolute; height: 2px; - background-color: var(--color); left: 0; width: 100px; @@ -76,6 +74,8 @@ } &--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 index e24b592a8..cd8c55eb0 100644 --- a/src/components/tabs/components/ActiveIndicator.tsx +++ b/src/components/tabs/components/ActiveIndicator.tsx @@ -3,11 +3,17 @@ 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; }; export const ActiveIndicator = ({ @@ -40,12 +46,15 @@ export const ActiveIndicator = ({ effect={activeIndicatorEffect} active fillMode="forwards" - className={classnames('sg-tabs__active-indicator', className, { + className={classnames('sg-tabs__active-indicator', { 'sg-tabs__active-indicator--bottom': position === 'bottom', 'sg-tabs__active-indicator--top': position === 'top', })} > -
+
); }; diff --git a/src/components/tabs/components/Header.tsx b/src/components/tabs/components/Header.tsx index 51ae6a3f1..9d78b04a6 100644 --- a/src/components/tabs/components/Header.tsx +++ b/src/components/tabs/components/Header.tsx @@ -3,22 +3,31 @@ 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/Tab.tsx b/src/components/tabs/components/Tab.tsx index 4d580cd64..0d8672b8c 100644 --- a/src/components/tabs/components/Tab.tsx +++ b/src/components/tabs/components/Tab.tsx @@ -9,9 +9,18 @@ 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; +export type TabProps = WithChildren & FlexPropsType & StyleType; export const Tab = ({children, className, disabled, ...rest}: TabProps) => { const {activeTab, registerTab, setActiveIndex, a11yHelpers} = From 968a6fc8fe07d593647cc8867f4f367a66d89558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Wed, 22 Feb 2023 10:29:45 +0100 Subject: [PATCH 05/12] Merge exports declarations --- src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index ec54b92d1..c135efa75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -170,5 +170,4 @@ 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} from './components/tabs/Tabs'; -export {Tab} from './components/tabs/components/Tab'; +export {default as Tabs, Tab} from './components/tabs/Tabs'; From 4980a5dccb59803de6c0cb2c9ec25ede5ef12578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Wed, 22 Feb 2023 10:36:48 +0100 Subject: [PATCH 06/12] Suppress hydration warning for a11y attributes --- src/components/tabs/components/Panel.tsx | 1 + src/components/tabs/components/Tab.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/tabs/components/Panel.tsx b/src/components/tabs/components/Panel.tsx index b274c9c47..ef163c673 100644 --- a/src/components/tabs/components/Panel.tsx +++ b/src/components/tabs/components/Panel.tsx @@ -29,6 +29,7 @@ export const Panel = ({children, ...rest}: TabPanelProps) => { ref={callbackRef} role="tabpanel" aria-labelledby={labelledBy} + suppressHydrationWarning > {children} diff --git a/src/components/tabs/components/Tab.tsx b/src/components/tabs/components/Tab.tsx index 0d8672b8c..7ebd39f1b 100644 --- a/src/components/tabs/components/Tab.tsx +++ b/src/components/tabs/components/Tab.tsx @@ -50,6 +50,7 @@ export const Tab = ({children, className, disabled, ...rest}: TabProps) => { role="tab" aria-selected={isActive} aria-controls={controls} + suppressHydrationWarning > Date: Wed, 22 Feb 2023 10:54:01 +0100 Subject: [PATCH 07/12] Change animation from width to scaleX --- src/components/tabs/_tabs.scss | 2 +- .../tabs/components/ActiveIndicator.tsx | 20 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/tabs/_tabs.scss b/src/components/tabs/_tabs.scss index dd13c503e..ccf667d84 100644 --- a/src/components/tabs/_tabs.scss +++ b/src/components/tabs/_tabs.scss @@ -63,7 +63,7 @@ position: absolute; height: 2px; left: 0; - width: 100px; + width: 100%; &--bottom { bottom: -2px; // [1] diff --git a/src/components/tabs/components/ActiveIndicator.tsx b/src/components/tabs/components/ActiveIndicator.tsx index cd8c55eb0..0c48dbf18 100644 --- a/src/components/tabs/components/ActiveIndicator.tsx +++ b/src/components/tabs/components/ActiveIndicator.tsx @@ -27,15 +27,21 @@ export const ActiveIndicator = ({ return null; } - const {x, width} = activeTab.getBoundingClientRect(); - const offsetParentBoundClientRect = - activeTab.offsetParent?.getBoundingClientRect(); - const offsetX = Number(offsetParentBoundClientRect?.x); + const {x: activeTabX, width: activeTabWidth} = + activeTab.getBoundingClientRect(); + const {x: offsetX, width: containerWidth} = + activeTab.offsetParent.getBoundingClientRect(); - const activeIndicatorEffect: {animate: TransitionEffectType['animate']} = { + const activeTabToContainerRatio = + containerWidth > 0 ? activeTabWidth / containerWidth : 0; + + const activeIndicatorEffect: TransitionEffectType = { animate: { - width, - transform: {translateX: x - offsetX, origin: 'left top'}, + transform: { + translateX: activeTabX - offsetX, + origin: 'left top', + scaleX: activeTabToContainerRatio, + }, duration: 'moderate2', easing: 'entry', }, From 7f20c51892b3199acb17a6074b5ba54713e85fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Wed, 22 Feb 2023 10:58:38 +0100 Subject: [PATCH 08/12] Fix eslint issues --- src/components/tabs/Tabs.spec.tsx | 4 ++++ src/components/tabs/Tabs.tsx | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/tabs/Tabs.spec.tsx b/src/components/tabs/Tabs.spec.tsx index 9825e9877..115d93b82 100644 --- a/src/components/tabs/Tabs.spec.tsx +++ b/src/components/tabs/Tabs.spec.tsx @@ -41,6 +41,7 @@ describe('', () => { it('correctly handles tab change', () => { const mockOnChange = jest.fn(); + render( @@ -56,6 +57,7 @@ describe('', () => { expect(screen.getByText('Content 1')).not.toHaveClass('panel--hidden'); expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); const secondTab = screen.getByText('Second tab'); + fireEvent.click(secondTab); expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); expect(screen.getByText('Content 2')).not.toHaveClass('panel--hidden'); @@ -81,11 +83,13 @@ describe('', () => { Content 3 ); + expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); expect(screen.getByText('Content 3')).not.toHaveClass('panel--hidden'); const secondTab = screen.getByText('Second tab'); + fireEvent.click(secondTab); expect(mockOnChange).not.toHaveBeenCalled(); diff --git a/src/components/tabs/Tabs.tsx b/src/components/tabs/Tabs.tsx index 7a2530468..1cdcdc4fb 100644 --- a/src/components/tabs/Tabs.tsx +++ b/src/components/tabs/Tabs.tsx @@ -1,3 +1,8 @@ +/* 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'; @@ -53,7 +58,7 @@ function Tabs({ function Tabs({ children, - onTabChange = () => {}, + onTabChange = () => undefined, startIndex = 0, activeIndex, ...rest @@ -77,6 +82,7 @@ function Tabs({ const a11yHelpers = { getTabHelpers: (tab: TabElement) => { const currentTabIndex = tabs.indexOf(tab); + return { id: `tab-${currentTabIndex}`, controls: `panel-${currentTabIndex}`, @@ -84,6 +90,7 @@ function Tabs({ }, getPanelHelpers: (panel: PanelElement) => { const currentPanelIndex = panels.indexOf(panel); + return { id: `panel-${currentPanelIndex}`, labelledBy: `tab-${currentPanelIndex}`, From e36f05677562921e2ff97742cc0cca3f661f0d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Wed, 22 Feb 2023 12:08:22 +0100 Subject: [PATCH 09/12] Fix eslint errors pt2 --- src/components/tabs/Tabs.tsx | 11 ++++++++--- .../tabs/components/ActiveIndicator.tsx | 1 + src/components/tabs/components/Panel.tsx | 15 +++++++++------ src/components/tabs/components/Tab.tsx | 18 ++++++++++-------- src/components/tabs/hooks.ts | 1 + 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/components/tabs/Tabs.tsx b/src/components/tabs/Tabs.tsx index 1cdcdc4fb..134854960 100644 --- a/src/components/tabs/Tabs.tsx +++ b/src/components/tabs/Tabs.tsx @@ -102,9 +102,14 @@ function Tabs({ activeTab, activePanel, setActiveIndex, - registerTab: tab => setTabs(previousTabs => [...previousTabs, tab]), - registerPanel: panel => - setPanels(previousPanels => [...previousPanels, panel]), + registerTab: React.useCallback( + tab => setTabs(previousTabs => [...previousTabs, tab]), + [] + ), + registerPanel: React.useCallback( + panel => setPanels(previousPanels => [...previousPanels, panel]), + [] + ), a11yHelpers, }; diff --git a/src/components/tabs/components/ActiveIndicator.tsx b/src/components/tabs/components/ActiveIndicator.tsx index 0c48dbf18..0952ebbdf 100644 --- a/src/components/tabs/components/ActiveIndicator.tsx +++ b/src/components/tabs/components/ActiveIndicator.tsx @@ -14,6 +14,7 @@ export type ActiveIndicatorProps = React.DetailedHTMLProps< > & { position?: 'top' | 'bottom'; style?: StyleType; + className?: string; }; export const ActiveIndicator = ({ diff --git a/src/components/tabs/components/Panel.tsx b/src/components/tabs/components/Panel.tsx index ef163c673..d409b0438 100644 --- a/src/components/tabs/components/Panel.tsx +++ b/src/components/tabs/components/Panel.tsx @@ -11,12 +11,15 @@ 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); - } - }, []); + const callbackRef = React.useCallback( + (panel: PanelElement | null) => { + if (panel) { + panelRef.current = panel; + registerPanel(panel); + } + }, + [registerPanel] + ); const isActive = panelRef.current === activePanel; return ( diff --git a/src/components/tabs/components/Tab.tsx b/src/components/tabs/components/Tab.tsx index 7ebd39f1b..9a33eac2f 100644 --- a/src/components/tabs/components/Tab.tsx +++ b/src/components/tabs/components/Tab.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import Text from '../../text/Text'; import Flex, {FlexPropsType} from '../../flex/Flex'; -import {useCallback, useRef} from 'react'; import classnames from 'classnames'; import {useTabsContext} from '../hooks'; import {Header} from './Header'; @@ -25,13 +24,16 @@ export type TabProps = WithChildren & FlexPropsType & StyleType; export const Tab = ({children, className, disabled, ...rest}: TabProps) => { const {activeTab, registerTab, setActiveIndex, a11yHelpers} = useTabsContext(); - const tabRef = useRef(); - const callbackRef = useCallback((tab: TabElement | null) => { - if (tab) { - tabRef.current = tab; - registerTab(tab); - } - }, []); + 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; diff --git a/src/components/tabs/hooks.ts b/src/components/tabs/hooks.ts index 42af391bd..1a6528753 100644 --- a/src/components/tabs/hooks.ts +++ b/src/components/tabs/hooks.ts @@ -5,6 +5,7 @@ export const TabContext = createContext({} as Context); export const useTabsContext = () => { const tabsContext = useContext(TabContext); + if (!tabsContext) { throw new Error('useTabContext must be used within TabContextProvider'); } From 3a9af68e971600de6a61cce9f55eeb7849258341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zaj=C4=85c?= Date: Wed, 22 Feb 2023 12:41:48 +0100 Subject: [PATCH 10/12] Fix unit tests --- src/components/tabs/Tabs.spec.tsx | 34 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/components/tabs/Tabs.spec.tsx b/src/components/tabs/Tabs.spec.tsx index 115d93b82..6e23acd95 100644 --- a/src/components/tabs/Tabs.spec.tsx +++ b/src/components/tabs/Tabs.spec.tsx @@ -2,6 +2,8 @@ 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( @@ -19,7 +21,7 @@ describe('', () => { expect(screen.getByText('First tab')).toBeInTheDocument(); expect(screen.getByText('Second tab')).toBeInTheDocument(); expect(screen.getByText('Content 1')).toBeInTheDocument(); - expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass); }); it('shows panel corresponding to startIndex', () => { @@ -35,8 +37,8 @@ describe('', () => { Content 2 ); - expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); - expect(screen.getByText('Content 2')).not.toHaveClass('panel--hidden'); + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass); }); it('correctly handles tab change', () => { @@ -54,13 +56,13 @@ describe('', () => { Content 2 ); - expect(screen.getByText('Content 1')).not.toHaveClass('panel--hidden'); - expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); + 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('panel--hidden'); - expect(screen.getByText('Content 2')).not.toHaveClass('panel--hidden'); + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass); expect(mockOnChange).toBeCalledWith( screen.getByText('Second tab').parentElement ); @@ -84,18 +86,18 @@ describe('', () => { ); - expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); - expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); - expect(screen.getByText('Content 3')).not.toHaveClass('panel--hidden'); + 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('panel--hidden'); - expect(screen.getByText('Content 2')).toHaveClass('panel--hidden'); - expect(screen.getByText('Content 3')).not.toHaveClass('panel--hidden'); + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 3')).not.toHaveClass(hiddenPanelClass); rerender( @@ -112,8 +114,8 @@ describe('', () => { ); - expect(screen.getByText('Content 1')).toHaveClass('panel--hidden'); - expect(screen.getByText('Content 2')).not.toHaveClass('panel--hidden'); - expect(screen.getByText('Content 3')).toHaveClass('panel--hidden'); + expect(screen.getByText('Content 1')).toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 2')).not.toHaveClass(hiddenPanelClass); + expect(screen.getByText('Content 3')).toHaveClass(hiddenPanelClass); }); }); From 57c323b074c473c48288bd5fc6d4a30532b7fc70 Mon Sep 17 00:00:00 2001 From: katarzynatobis Date: Tue, 28 Feb 2023 13:29:45 +0100 Subject: [PATCH 11/12] improve Tabs stories structure --- src/components/tabs/Tabs.stories.mdx | 17 +++++++++++++++++ src/components/tabs/stories/Tabs.a11y.mdx | 9 +++++++++ src/components/tabs/stories/rules.a11y.ts | 11 +++++++++++ 3 files changed, 37 insertions(+) create mode 100644 src/components/tabs/stories/Tabs.a11y.mdx create mode 100644 src/components/tabs/stories/rules.a11y.ts diff --git a/src/components/tabs/Tabs.stories.mdx b/src/components/tabs/Tabs.stories.mdx index 4bc80d071..53eeea790 100644 --- a/src/components/tabs/Tabs.stories.mdx +++ b/src/components/tabs/Tabs.stories.mdx @@ -3,15 +3,23 @@ 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 @@ -87,6 +98,8 @@ import {ArgsTable, Meta, Story, Canvas} from '@storybook/addon-docs'; + + ## Stories ### External Control @@ -189,3 +202,7 @@ import {ArgsTable, Meta, Story, Canvas} from '@storybook/addon-docs'; }} + +## Accessibility + + diff --git a/src/components/tabs/stories/Tabs.a11y.mdx b/src/components/tabs/stories/Tabs.a11y.mdx new file mode 100644 index 000000000..cebb0749e --- /dev/null +++ b/src/components/tabs/stories/Tabs.a11y.mdx @@ -0,0 +1,9 @@ +import rules from './rules.a11y'; + +### Rules + + + +### 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..7e990b59e --- /dev/null +++ b/src/components/tabs/stories/rules.a11y.ts @@ -0,0 +1,11 @@ +const rules = [ + { + pattern: 'Should have an accessible name.', + comment: + 'Can be named by setting a value for title prop (defaults to icon type).', + status: 'DONE', + tests: 'DONE', + }, +]; + +export default rules; From e85e70c87927e34327257151304490fe23be81f1 Mon Sep 17 00:00:00 2001 From: katarzynatobis Date: Wed, 1 Mar 2023 14:10:44 +0100 Subject: [PATCH 12/12] add rules for Tabs and subcomponents --- src/components/tabs/Tabs.stories.mdx | 1 + src/components/tabs/stories/Tabs.a11y.mdx | 30 +++- src/components/tabs/stories/rules.a11y.ts | 159 +++++++++++++++++++++- 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/components/tabs/Tabs.stories.mdx b/src/components/tabs/Tabs.stories.mdx index 53eeea790..90db519af 100644 --- a/src/components/tabs/Tabs.stories.mdx +++ b/src/components/tabs/Tabs.stories.mdx @@ -15,6 +15,7 @@ import TabsA11y from './stories/Tabs.a11y.mdx'; title="Components/Tabs" component={Tabs} subcomponents={{ + Tab, 'Tab.Panel': Tab.Panel, 'Tab.Header': Tab.Header, 'Tab.List': Tab.List, diff --git a/src/components/tabs/stories/Tabs.a11y.mdx b/src/components/tabs/stories/Tabs.a11y.mdx index cebb0749e..af00592ab 100644 --- a/src/components/tabs/stories/Tabs.a11y.mdx +++ b/src/components/tabs/stories/Tabs.a11y.mdx @@ -1,9 +1,37 @@ -import rules from './rules.a11y'; +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 index 7e990b59e..5c14c8848 100644 --- a/src/components/tabs/stories/rules.a11y.ts +++ b/src/components/tabs/stories/rules.a11y.ts @@ -1,10 +1,163 @@ const rules = [ { - pattern: 'Should have an accessible name.', + pattern: 'Can have an accessible name.', comment: 'Can be named by setting a value for title prop (defaults to icon type).', - status: 'DONE', - tests: 'DONE', + 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: '', }, ];