diff --git a/CODEOWNERS b/CODEOWNERS index aa9079d933..da5f8d879a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -50,7 +50,7 @@ /src/components/Stories @DarkGenius /src/components/Switch @zamkovskaya /src/components/Table @Raubzeug -/src/components/Tabs @sofiushko +/src/components/tabs @sofiushko /src/components/Text @IsaevAlexandr /src/components/TreeList @IsaevAlexandr /src/components/TreeSelect @IsaevAlexandr diff --git a/src/components/index.ts b/src/components/index.ts index d1deaff651..a57ff4d473 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -51,7 +51,7 @@ export * from './Spin'; export * from './Switch'; export * from './Table'; export * from './TableColumnSetup'; -export * from './Tabs'; +export * from './tabs'; export * from './Text'; export * from './Toaster'; export * from './Toc'; diff --git a/src/components/tabs/README.md b/src/components/tabs/README.md new file mode 100644 index 0000000000..c89d84caba --- /dev/null +++ b/src/components/tabs/README.md @@ -0,0 +1,5 @@ + + +# tabs + + diff --git a/src/components/tabs/Tab.tsx b/src/components/tabs/Tab.tsx new file mode 100644 index 0000000000..fa6efdce80 --- /dev/null +++ b/src/components/tabs/Tab.tsx @@ -0,0 +1,93 @@ +'use client'; + +import React from 'react'; + +import {KeyCode} from '../../constants'; +import {Label} from '../Label'; +import type {LabelProps} from '../Label'; +import type {AriaLabelingProps, DOMProps, QAProps} from '../types'; +import {filterDOMProps} from '../utils/filterDOMProps'; + +import {bTabList} from './constants'; +import {TabInnerContext} from './contexts/TabInnerContext'; +import type {TabTriggerProps} from './types'; + +export interface TabProps extends AriaLabelingProps, DOMProps, QAProps, TabTriggerProps { + value: string; + title?: string; + icon?: React.ReactNode; + counter?: number | string; + href?: string; + label?: { + content: React.ReactNode; + theme?: LabelProps['theme']; + }; + disabled?: boolean; + children?: React.ReactNode; +} + +export const Tab = React.forwardRef((props, ref) => { + const {value, className, icon, counter, label, disabled, href, style, children, title, qa} = + props; + + const {activeTabId, onUpdate} = React.useContext(TabInnerContext); + const isActive = activeTabId === value; + + const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => { + if (disabled) { + event.preventDefault(); + return; + } + onUpdate?.(value); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === KeyCode.SPACEBAR) { + onUpdate?.(value); + } + }; + + const tabProps = { + 'aria-selected': isActive, + 'aria-disabled': disabled === true, + 'aria-controls': props['aria-controls'], + ...filterDOMProps(props, {labelable: true}), + role: 'tab', + style, + title, + onClick: handleClick, + onKeyDown: handleKeyDown, + id: props.id, + 'data-qa': qa, + className: bTabList('item', {active: isActive, disabled}, className), + }; + + const content = ( +
+ {icon &&
{icon}
} +
{children || value}
+ {counter !== undefined &&
{counter}
} + {label && ( + + )} +
+ ); + + if (href) { + return ( + }> + {content} + + ); + } + + return ( +
}> + {content} +
+ ); +}); + +Tab.displayName = 'Tab'; diff --git a/src/components/tabs/TabList.scss b/src/components/tabs/TabList.scss new file mode 100644 index 0000000000..b2e5710c9f --- /dev/null +++ b/src/components/tabs/TabList.scss @@ -0,0 +1,142 @@ +@use '../variables'; +@use '../../../styles/mixins'; + +$block: '.#{variables.$ns}tab-list'; + +#{$block} { + --_--vertical-item-padding: var(--g-tabs-vertical-item-padding, 6px 20px); + --_--vertical-item-height: var(--g-tabs-vertical-item-height, 18px); + + &_size { + &_m { + --_--item-height: 36px; + --_--item-gap: 24px; + --_--item-border-width: 2px; + + #{$block}__item-title, + #{$block}__item-counter { + @include mixins.text-body-1(); + } + } + + &_l { + --_--item-height: 40px; + --_--item-gap: 28px; + --_--item-border-width: 2px; + + #{$block}__item-title, + #{$block}__item-counter { + @include mixins.text-body-2(); + } + } + + &_xl { + --_--item-height: 44px; + --_--item-gap: 32px; + --_--item-border-width: 3px; + + #{$block}__item-title, + #{$block}__item-counter { + @include mixins.text-subheader-3(); + } + } + } + + &__item { + cursor: pointer; + user-select: none; + outline: none; + color: inherit; + text-decoration: none; + display: flex; + align-items: center; + box-sizing: border-box; + height: var(--g-tabs-item-height, var(--_--item-height)); + border-block-end: var(--g-tabs-item-border-width, var(--_--item-border-width)) solid + transparent; + padding-block-start: var(--_--item-border-width); + + &-content { + display: flex; + align-items: center; + border-radius: var(--g-focus-border-radius); + min-width: 0; + height: 100%; + } + + &-icon { + margin-inline-end: 8px; + } + + &-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &-counter, + &-label { + margin-inline-start: 8px; + } + + &-icon > svg { + display: block; + } + + &:focus-visible { + #{$block}__item-content { + outline: 2px solid var(--g-color-line-focus); + outline-offset: -2px; + } + } + + &-title { + color: var(--g-color-text-secondary); + } + + &-icon, + &-counter { + color: var(--g-color-text-hint); + } + + &_active, + &:hover, + &:focus-visible { + #{$block}__item-title { + color: var(--g-color-text-primary); + } + + #{$block}__item-icon, + #{$block}__item-counter { + color: var(--g-color-text-secondary); + } + } + + &_active, + &_active:hover, + &_active:focus-visible { + border-color: var(--g-color-line-brand); + } + + &_disabled { + pointer-events: none; + + #{$block}__item-title { + color: var(--g-color-text-hint); + } + } + } + + &__tabs { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + box-shadow: inset 0 calc(var(--g-tabs-border-width, 1px) * -1) 0 0 + var(--g-color-line-generic); + overflow: hidden; + + > :not(:last-child) { + margin-inline-end: var(--g-tabs-item-gap, var(--_--item-gap)); + } + } +} diff --git a/src/components/tabs/TabList.tsx b/src/components/tabs/TabList.tsx new file mode 100644 index 0000000000..ba07d9ce51 --- /dev/null +++ b/src/components/tabs/TabList.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React from 'react'; + +import type {QAProps} from '../types'; + +import {bTabList} from './constants'; +import {TabContext} from './contexts/TabContext'; +import {TabInnerContext} from './contexts/TabInnerContext'; +import type {TabSize} from './types'; + +export interface TabListProps extends QAProps { + onUpdate?: (value: string) => void; + value?: string; + size?: TabSize; + contentOverflow?: 'wrap'; + className?: string; + children?: React.ReactNode; +} + +export const TabList = React.forwardRef( + ({size = 'm', value, children, className, onUpdate, qa}, ref) => { + const activeTabId = React.useContext(TabContext).activeTabId || value; + + const tabInnerContextValue = React.useMemo( + () => ({onUpdate, activeTabId}), + [onUpdate, activeTabId], + ); + + return ( +
+
+ + {children} + +
+
+ ); + }, +); + +TabList.displayName = 'TabList'; diff --git a/src/components/tabs/TabPanel.tsx b/src/components/tabs/TabPanel.tsx new file mode 100644 index 0000000000..d1bb5c8453 --- /dev/null +++ b/src/components/tabs/TabPanel.tsx @@ -0,0 +1,32 @@ +'use client'; + +import React from 'react'; + +import type {AriaLabelingProps, QAProps} from '../types'; +import {filterDOMProps} from '../utils/filterDOMProps'; + +import {bTabList} from './constants'; +import {TabContext} from './contexts/TabContext'; + +export interface TabPanelProps extends QAProps, AriaLabelingProps { + id?: string; + value: string; + children: React.ReactNode; +} + +export const TabPanel = (props: TabPanelProps) => { + const {children, value, qa, id} = props; + const {activeTabId} = React.useContext(TabContext); + + return ( +
+ {activeTabId === value ? children : null} +
+ ); +}; diff --git a/src/components/tabs/TabProvider.tsx b/src/components/tabs/TabProvider.tsx new file mode 100644 index 0000000000..007c8d8b8b --- /dev/null +++ b/src/components/tabs/TabProvider.tsx @@ -0,0 +1,14 @@ +'use client'; + +import React from 'react'; + +import {TabContext} from './contexts/TabContext'; + +export type TabProviderProps = React.PropsWithChildren<{ + value: string | undefined; +}>; + +export const TabProvider = ({value: activeTabId, children}: TabProviderProps) => { + const value = React.useMemo(() => ({activeTabId}), [activeTabId]); + return {children}; +}; diff --git a/src/components/tabs/__stories__/Docs.mdx b/src/components/tabs/__stories__/Docs.mdx new file mode 100644 index 0000000000..193a5e4693 --- /dev/null +++ b/src/components/tabs/__stories__/Docs.mdx @@ -0,0 +1,6 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/tabs/__stories__/TabProvider.stories.tsx b/src/components/tabs/__stories__/TabProvider.stories.tsx new file mode 100644 index 0000000000..44bcba6888 --- /dev/null +++ b/src/components/tabs/__stories__/TabProvider.stories.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import {useArgs} from '@storybook/preview-api'; +import type {Meta, StoryFn} from '@storybook/react'; + +import {Tab} from '../Tab'; +import {TabList} from '../TabList'; +import type {TabListProps} from '../TabList'; +import {TabPanel} from '../TabPanel'; +import {TabProvider} from '../TabProvider'; + +import {getTabsMock} from './getTabsMock'; + +const meta: Meta = { + title: 'Components/Navigation/tabs/TabProvider', + component: TabList, + args: { + value: 'active', + }, + argTypes: { + value: { + control: {type: 'select'}, + options: getTabsMock({})?.map(({value}) => value), + }, + }, + parameters: { + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'aria-required-children', + enabled: false, + selector: '[id^="wrapped"]', + }, + { + id: 'aria-required-parent', + enabled: false, + selector: '[id^="wrapped"]', + }, + { + id: 'color-contrast', + enabled: false, + }, + ], + }, + }, + }, +}; + +export default meta; + +export const Default: StoryFn = ({...args}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => + getTabsMock({})?.map((props, i) => ( + + )), + [], + ); + + const panels = React.useMemo( + () => + getTabsMock({})?.map((tab, i) => ( + + {`Content of ${tab.value} tab panel`} + + )), + [], + ); + + return ( + + setStoryArgs({value: tabId})}> + {items} + +
{panels}
+
+ ); +}; diff --git a/src/components/tabs/__stories__/getTabsMock.tsx b/src/components/tabs/__stories__/getTabsMock.tsx new file mode 100644 index 0000000000..7288e7b995 --- /dev/null +++ b/src/components/tabs/__stories__/getTabsMock.tsx @@ -0,0 +1,107 @@ +import React from 'react'; + +import {Flame, SquarePlus, SquareXmark} from '@gravity-ui/icons'; + +import {Icon} from '../../Icon'; +import {Flex} from '../../layout'; +import type {TabProps} from '../Tab'; + +type StoryParams = { + withIcon?: boolean; + withCounter?: boolean; + withLabel?: boolean; + withLink?: boolean; + withCustomChildren?: boolean; +}; + +const gearIcon = ; + +export function getTabsMock(args: StoryParams): TabProps[] { + return [ + { + value: 'first', + title: 'First Tab', + children: args.withCustomChildren ? : 'First Tab', + icon: args.withIcon ? gearIcon : undefined, + counter: args.withCounter ? Math.floor(Math.random() * 5 + 1) : undefined, + label: args.withLabel ? {content: 'Normal', theme: 'normal'} : undefined, + href: args.withLink ? 'https://gravity-ui.com' : undefined, + }, + { + value: 'active', + title: 'Active Tab', + children: args.withCustomChildren ? ( + + ) : ( + 'Active Tab' + ), + icon: args.withIcon ? gearIcon : undefined, + counter: args.withCounter ? Math.floor(Math.random() * 5 + 1) : undefined, + label: args.withLabel ? {content: 'Warning', theme: 'warning'} : undefined, + href: args.withLink ? 'https://gravity-ui.com/components' : undefined, + }, + { + value: 'disabled', + title: 'disabled', + children: args.withCustomChildren ? ( + + ) : ( + 'Disabled Tab' + ), + icon: args.withIcon ? gearIcon : undefined, + counter: args.withCounter ? Math.floor(Math.random() * 5 + 1) : undefined, + label: args.withLabel ? {content: 'Danger', theme: 'danger'} : undefined, + disabled: true, + href: args.withLink ? 'https://gravity-ui.com/components/uikit/tabs' : undefined, + }, + { + value: 'fourth', + title: 'Fourth Long Text To Show Tab', + children: args.withCustomChildren ? ( + + ) : ( + 'Fourth Long Text To Show Tab' + ), + icon: args.withIcon ? gearIcon : undefined, + counter: args.withCounter ? Math.floor(Math.random() * 5 + 1) : undefined, + label: args.withLabel ? {content: 'Warning', theme: 'warning'} : undefined, + href: args.withLink ? 'https://gravity-ui.com' : undefined, + }, + { + value: 'fifth', + title: 'One More Long Text Tab To Show', + children: args.withCustomChildren ? ( + + ) : ( + 'One More Long Text Tab To Show' + ), + icon: args.withIcon ? gearIcon : undefined, + counter: args.withCounter ? Math.floor(Math.random() * 5 + 1) : undefined, + label: args.withLabel ? {content: 'Warning', theme: 'warning'} : undefined, + href: args.withLink ? 'https://gravity-ui.com' : undefined, + }, + ]; +} + +const RenderWithWrap = (props: {title: string | React.ReactNode}) => { + const {title} = props; + return ( + + + + + {title} + + + + ); +}; diff --git a/src/components/tabs/__stories__/tabs.stories.tsx b/src/components/tabs/__stories__/tabs.stories.tsx new file mode 100644 index 0000000000..a46dad2aa0 --- /dev/null +++ b/src/components/tabs/__stories__/tabs.stories.tsx @@ -0,0 +1,150 @@ +import React from 'react'; + +import {useArgs} from '@storybook/preview-api'; +import type {Meta, StoryFn} from '@storybook/react'; + +import {Tab} from '../Tab'; +import {TabList} from '../TabList'; +import type {TabListProps} from '../TabList'; + +import {getTabsMock} from './getTabsMock'; + +const meta: Meta = { + title: 'Components/Navigation/tabs', + component: TabList, + args: { + value: 'active', + }, + argTypes: { + value: { + control: {type: 'select'}, + options: getTabsMock({})?.map(({value}) => value), + }, + }, + parameters: { + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'aria-required-children', + enabled: false, + selector: '[id^="wrapped"]', + }, + { + id: 'aria-required-parent', + enabled: false, + selector: '[id^="wrapped"]', + }, + { + id: 'color-contrast', + enabled: false, + }, + ], + }, + }, + }, +}; + +export default meta; + +export const Default: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => getTabsMock({})?.map((props, i) => ), + [], + ); + + return ( + setStoryArgs({value: tabId})}> + {items} + + ); +}; + +export const WithIcons: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => getTabsMock({withIcon: true})?.map((props, i) => ), + [], + ); + return ( + setStoryArgs({value: tabId})}> + {items} + + ); +}; + +export const WithCounter: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => getTabsMock({withCounter: true})?.map((props, i) => ), + [], + ); + return ( + setStoryArgs({value: tabId})}> + {items} + + ); +}; + +export const WithLabel: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => getTabsMock({withLabel: true})?.map((props, i) => ), + [], + ); + return ( + setStoryArgs({value: tabId})}> + {items} + + ); +}; + +export const WithCustomWidth: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => + getTabsMock({})?.map((props, i) => ), + [], + ); + return ( + setStoryArgs({value: tabId})}> + {items} + + ); +}; + +export const WithLink: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => getTabsMock({withLink: true})?.map((props, i) => ), + [], + ); + return ( + setStoryArgs({value: tabId})}> + {items} + + ); +}; + +export const CustomTab: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => + getTabsMock({withCustomChildren: true})?.map((props, i) => ), + [], + ); + return ( + setStoryArgs({value: tabId})}> + {items} + + ); +}; diff --git a/src/components/tabs/__tests__/Tab.test.tsx b/src/components/tabs/__tests__/Tab.test.tsx new file mode 100644 index 0000000000..c29637e2d0 --- /dev/null +++ b/src/components/tabs/__tests__/Tab.test.tsx @@ -0,0 +1,148 @@ +import React from 'react'; + +import {Flame} from '@gravity-ui/icons'; + +import {Tab} from '../'; +import {render, screen} from '../../../../test-utils/utils'; + +import {tab1} from './constants'; + +test('should render tab item by default', () => { + render({tab1.title}); + const component = screen.getByRole('tab'); + + expect(component).toBeVisible(); + expect(component).not.toHaveClass('g-tabs__item_active'); + expect(component).toHaveAttribute('aria-selected', 'false'); + expect(component).toHaveAttribute('aria-disabled', 'false'); +}); + +test('should render disabled tab item', () => { + render( + + {tab1.title} + , + ); + const component = screen.getByRole('tab'); + + expect(component).toBeVisible(); + expect(component).toHaveAttribute('aria-disabled', 'true'); + expect(component).toHaveAttribute('tabIndex', '-1'); +}); + +test('should passed title', () => { + render( + + {tab1.title} + , + ); + const component = screen.getByTitle(tab1.title); + + expect(component).toBeVisible(); +}); + +test('should passed aria-controls and id', () => { + const tabId = 'tab-id'; + const ariaId = 'aria-id'; + render( + + {tab1.title} + , + ); + const component = screen.getByTitle(tab1.title); + + expect(component).toHaveAttribute('aria-controls', ariaId); + expect(component).toHaveAttribute('id', tabId); +}); + +test('should passed children', () => { + const titleQaId = 'title-qa-id'; + render( + + html title + , + ); + + const component = screen.getByRole('tab'); + const titleComponent = screen.getByTestId(titleQaId); + + expect(component).toContainElement(titleComponent); +}); + +test('should render value if children is empty', () => { + render(); + + const component = screen.getByRole('tab'); + const titleComponent = screen.getByText(tab1.id); + + expect(component).toContainElement(titleComponent); + expect(titleComponent).toHaveClass('g-tab-list__item-title'); +}); + +test('should render counter', () => { + const counter = 3; + + render( + + {tab1.title} + , + ); + + const component = screen.getByRole('tab'); + const counterComponent = screen.getByText(counter); + + expect(counterComponent).toBeVisible(); + expect(counterComponent).toHaveClass('g-tab-list__item-counter'); + expect(component).toContainElement(counterComponent); +}); + +test('should render label', () => { + const labelQaId = 'label-qa-id'; + + const label = { + theme: 'normal' as const, + content: label content, + }; + + render( + + {tab1.title} + , + ); + + const component = screen.getByRole('tab'); + const labelComponent = screen.getByTestId(labelQaId); + + expect(labelComponent).toBeVisible(); + expect(component).toContainElement(labelComponent); +}); + +test('should render icon', () => { + const iconQaId = 'icon-qa-id'; + + const icon = ; + + render( + + {tab1.title} + , + ); + + const component = screen.getByRole('tab'); + const iconComponent = screen.getByTestId(iconQaId); + + expect(iconComponent).toBeVisible(); + expect(component).toContainElement(iconComponent); +}); + +test('should render link', async () => { + render( + + {tab1.title} + , + ); + + const component = screen.getByRole('tab'); + expect(component.tagName).toBe('A'); + expect(component).toHaveAttribute('href', 'https://example.com/foo/bar'); +}); diff --git a/src/components/tabs/__tests__/TabPanel.test.tsx b/src/components/tabs/__tests__/TabPanel.test.tsx new file mode 100644 index 0000000000..ad6435eec7 --- /dev/null +++ b/src/components/tabs/__tests__/TabPanel.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import {TabPanel} from '../'; +import {render, screen} from '../../../../test-utils/utils'; + +import {tab1} from './constants'; + +test('should render tab panel by default', () => { + render(Panel Title); + const component = screen.getByRole('tabpanel'); + + expect(component).toBeVisible(); + expect(component).not.toHaveClass('g-tabs__panel_active'); +}); + +test('should passed aria-labelledby and id', () => { + const panelId = 'panel-id'; + const ariaId = 'aria-id'; + render( + + Panel Title + , + ); + + const component = screen.getByRole('tabpanel'); + + expect(component).toHaveAttribute('aria-labelledby', ariaId); + expect(component).toHaveAttribute('id', panelId); +}); diff --git a/src/components/tabs/__tests__/TabProvider.test.tsx b/src/components/tabs/__tests__/TabProvider.test.tsx new file mode 100644 index 0000000000..801380a9ef --- /dev/null +++ b/src/components/tabs/__tests__/TabProvider.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import userEvent from '@testing-library/user-event'; + +import {Tab, TabList, TabPanel, TabProvider} from '../'; +import {render, screen} from '../../../../test-utils/utils'; + +import {panel1qa, panel2qa, tab1, tab2} from './constants'; +import {ControlledTabs} from './utils'; + +test('should render active tab and panel', () => { + render( + + + + {tab1.title} + + + {tab2.title} + + + + panel1 + + + panel2 + + , + ); + + const tabComponent1 = screen.getByTestId(tab1.qa); + const tabComponent2 = screen.getByTestId(tab2.qa); + + const panelComponent1 = screen.getByTestId(panel1qa); + const panelComponent2 = screen.getByTestId(panel2qa); + + expect(tabComponent1).not.toHaveClass('g-tab-list__item_active'); + expect(tabComponent1).toHaveAttribute('aria-selected', 'false'); + + expect(tabComponent2).toHaveClass('g-tab-list__item_active'); + expect(tabComponent2).toHaveAttribute('aria-selected', 'true'); + + expect(panelComponent1).toBeEmptyDOMElement(); + expect(panelComponent1).not.toHaveClass('g-tab-list__panel_active'); + + expect(panelComponent2).not.toBeEmptyDOMElement(); + expect(panelComponent2).toHaveClass('g-tab-list__panel_active'); +}); + +test('should chose tabpanel on value change', async () => { + render(); + + const user = userEvent.setup(); + const panelComponent1 = screen.getByTestId(panel1qa); + const panelComponent2 = screen.getByTestId(panel2qa); + + expect(panelComponent2).toBeEmptyDOMElement(); + expect(panelComponent2).not.toHaveClass('g-tab-list__panel_active'); + + expect(panelComponent1).not.toBeEmptyDOMElement(); + expect(panelComponent1).toHaveClass('g-tab-list__panel_active'); + + const tabComponent2 = screen.getByTestId(tab2.qa); + + await user.click(tabComponent2); + + expect(panelComponent1).toBeEmptyDOMElement(); + expect(panelComponent1).not.toHaveClass('g-tab-list__panel_active'); + + expect(panelComponent2).not.toBeEmptyDOMElement(); + expect(panelComponent2).toHaveClass('g-tab-list__panel_active'); +}); diff --git a/src/components/tabs/__tests__/TabsList.test.tsx b/src/components/tabs/__tests__/TabsList.test.tsx new file mode 100644 index 0000000000..f248a3d054 --- /dev/null +++ b/src/components/tabs/__tests__/TabsList.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; + +import userEvent from '@testing-library/user-event'; + +import {Tab, TabList} from '../'; +import {render, screen} from '../../../../test-utils/utils'; +import type {TabSize} from '../types'; + +const qaId = 'tabs-list'; + +import {tab1, tab2} from './constants'; + +test('should render tabs by default', () => { + render(); + const component = screen.getByTestId(qaId); + + expect(component).toBeVisible(); + expect(component).toHaveClass('g-tab-list_size_m'); +}); + +test('should not render tabs if no items', () => { + render(); + const component = screen.getByRole('tablist'); + const tabsComponents = screen.queryAllByRole('tab'); + + expect(component).toBeEmptyDOMElement(); + expect(tabsComponents).toHaveLength(0); +}); + +test.each(new Array('m', 'l', 'xl'))('should render with given "%s" size', (size) => { + render(); + const component = screen.getByTestId(qaId); + + expect(component).toHaveClass(`g-tab-list_size_${size}`); +}); + +test('should passed className', () => { + const className = 'class-name'; + + render(); + const component = screen.getByTestId(qaId); + + expect(component).toHaveClass(className); +}); + +test('should not select tab by default', () => { + render( + + + {tab1.title} + + + {tab2.title} + + , + ); + const tabComponent1 = screen.getByTestId(tab1.qa); + const tabComponent2 = screen.getByTestId(tab2.qa); + + expect(tabComponent1).not.toHaveClass('g-tab-list__item_active'); + expect(tabComponent1).toHaveAttribute('aria-selected', 'false'); + + expect(tabComponent2).not.toHaveClass('g-tab-list__item_active'); + expect(tabComponent2).toHaveAttribute('aria-selected', 'false'); +}); + +test('should passed active tab', () => { + render( + + + {tab1.title} + + + {tab2.title} + + , + ); + const tabComponent1 = screen.getByTestId(tab1.qa); + const tabComponent2 = screen.getByTestId(tab2.qa); + + expect(tabComponent1).not.toHaveClass('g-tab-list__item_active'); + expect(tabComponent1).toHaveAttribute('aria-selected', 'false'); + + expect(tabComponent2).toHaveClass('g-tab-list__item_active'); + expect(tabComponent2).toHaveAttribute('aria-selected', 'true'); +}); + +test('should call onUpdate on tab click', async () => { + const onUpdateFn = jest.fn(); + const user = userEvent.setup(); + + render( + + + {tab1.title} + + + {tab2.title} + + , + ); + const tabComponent1 = screen.getByTestId(tab1.qa); + const tabComponent2 = screen.getByTestId(tab2.qa); + + await user.click(tabComponent2); + expect(onUpdateFn).toHaveBeenCalledWith(tab2.id); + + await user.click(tabComponent1); + expect(onUpdateFn).toHaveBeenCalledWith(tab1.id); +}); + +test('should wrap tabs', () => { + const wrapQaId = 'wrap'; + + render( + +
+ {tab1.title} +
+
, + ); + + const wrapper = screen.getByTestId(wrapQaId); + const tabComponent = screen.getByText(tab1.title); + + expect(wrapper).toContainElement(tabComponent); +}); + +test('should call onUpdate on "\' \'" key', async () => { + const onUpdateFn = jest.fn(); + const user = userEvent.setup(); + render( + + + {tab1.title} + + + {tab2.title} + + , + ); + + const tabComponent2 = screen.getByTestId(tab2.qa); + tabComponent2.focus(); + + await user.keyboard(' '); + + expect(onUpdateFn).toHaveBeenCalledWith(tab2.id); +}); + +test('should not call onUpdate on "[Enter]" key', async () => { + const onUpdateFn = jest.fn(); + const user = userEvent.setup(); + + render( + + + {tab1.title} + + + {tab2.title} + + , + ); + + const tabComponent2 = screen.getByTestId(tab2.qa); + tabComponent2.focus(); + + await user.keyboard('[Enter]'); + + expect(onUpdateFn).not.toHaveBeenCalled(); +}); diff --git a/src/components/tabs/__tests__/constants.ts b/src/components/tabs/__tests__/constants.ts new file mode 100644 index 0000000000..e8d1d21219 --- /dev/null +++ b/src/components/tabs/__tests__/constants.ts @@ -0,0 +1,5 @@ +export const tabId = 'tab-id'; +export const tab1 = {id: 'Tab 1 title', title: 'tab1', qa: 'tab1qa'}; +export const tab2 = {id: 'Tab 2 title', title: 'tab2', qa: 'tab2qa'}; +export const panel1qa = 'panel1qa'; +export const panel2qa = 'panel2qa'; diff --git a/src/components/tabs/__tests__/utils.tsx b/src/components/tabs/__tests__/utils.tsx new file mode 100644 index 0000000000..1c7ff0d3a6 --- /dev/null +++ b/src/components/tabs/__tests__/utils.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import {Tab, TabList, TabPanel, TabProvider} from '..'; + +import {panel1qa, panel2qa, tab1, tab2} from './constants'; + +export const ControlledTabs = ({value}: {value?: string}) => { + const [stateValue, setStateValue] = React.useState(value); + + const handleUpdate = (nextValue: string) => { + setStateValue(nextValue); + }; + + return ( + + + + {tab1.title} + + + {tab2.title} + + + + panel1 + + + panel2 + + + ); +}; diff --git a/src/components/tabs/constants.ts b/src/components/tabs/constants.ts new file mode 100644 index 0000000000..d6e21fdf30 --- /dev/null +++ b/src/components/tabs/constants.ts @@ -0,0 +1,5 @@ +import {block} from '../utils/cn'; + +import './TabList.scss'; + +export const bTabList = block('tab-list'); diff --git a/src/components/tabs/contexts/TabContext.tsx b/src/components/tabs/contexts/TabContext.tsx new file mode 100644 index 0000000000..708756a912 --- /dev/null +++ b/src/components/tabs/contexts/TabContext.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export interface TabContextProps { + activeTabId: string | undefined; +} +export const TabContext = React.createContext({ + activeTabId: undefined, +}); + +TabContext.displayName = 'TabsContext'; diff --git a/src/components/tabs/contexts/TabInnerContext.tsx b/src/components/tabs/contexts/TabInnerContext.tsx new file mode 100644 index 0000000000..cb06715c7a --- /dev/null +++ b/src/components/tabs/contexts/TabInnerContext.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export interface TabInnerContextProps { + activeTabId: string | undefined; + onUpdate: ((value: string) => void) | undefined; +} + +export const TabInnerContext = React.createContext({ + activeTabId: undefined, + onUpdate: undefined, +}); + +TabInnerContext.displayName = 'TabInnerContext'; diff --git a/src/components/tabs/index.ts b/src/components/tabs/index.ts new file mode 100644 index 0000000000..7d13e26dd2 --- /dev/null +++ b/src/components/tabs/index.ts @@ -0,0 +1,5 @@ +export {TabList, type TabListProps} from './TabList'; +export {Tab, type TabProps} from './Tab'; +export {TabProvider, type TabProviderProps} from './TabProvider'; +export {TabPanel, type TabPanelProps} from './TabPanel'; +export type {TabSize} from './types'; diff --git a/src/components/tabs/types.ts b/src/components/tabs/types.ts new file mode 100644 index 0000000000..7d04959e42 --- /dev/null +++ b/src/components/tabs/types.ts @@ -0,0 +1,2 @@ +export type TabSize = 'm' | 'l' | 'xl'; +export type TabTriggerProps = Pick, 'id' | 'aria-controls'>; diff --git a/src/stories/Branding/BrandingConfugurator/BrandingConfigurator.tsx b/src/stories/Branding/BrandingConfugurator/BrandingConfigurator.tsx index 9c6ee96eb8..01e32edce9 100644 --- a/src/stories/Branding/BrandingConfugurator/BrandingConfigurator.tsx +++ b/src/stories/Branding/BrandingConfugurator/BrandingConfigurator.tsx @@ -14,8 +14,9 @@ import { Radio, Spin, Switch, + Tab, + TabList, Table, - Tabs, withTableSelection, } from '../../../components'; import {cn} from '../../../components/utils/cn'; @@ -158,14 +159,10 @@ export function BrandingConfigurator({theme}: BrandingConfiguratorProps) {
Tabs
- + + Overview + Settings +
Table