From cdcd8ca9847a1d2762aa744deddf386de80434b4 Mon Sep 17 00:00:00 2001 From: Sofiya Pavlenko Date: Tue, 14 Jan 2025 18:26:54 +0300 Subject: [PATCH 1/6] feat(tabs): move tabs to deprecated --- package.json | 5 +++++ src/components/{ => lab}/Tabs/README-ru.md | 0 src/components/{ => lab}/Tabs/README.md | 2 +- src/components/{ => lab}/Tabs/Tabs.scss | 4 ++-- src/components/{ => lab}/Tabs/Tabs.tsx | 6 +++--- src/components/{ => lab}/Tabs/TabsContext.ts | 0 src/components/{ => lab}/Tabs/TabsItem.tsx | 8 ++++---- ...moke-allow-not-selected-light-chromium-linux.png | Bin .../Tabs-smoke-light-chromium-linux.png | Bin ...s-smoke-with-custom-tab-light-chromium-linux.png | Bin src/components/{ => lab}/Tabs/__stories__/Docs.mdx | 0 .../{ => lab}/Tabs/__stories__/Tabs.stories.tsx | 2 +- .../{ => lab}/Tabs/__stories__/getTabsMock.tsx | 0 src/components/{ => lab}/Tabs/__stories__/types.ts | 0 .../{ => lab}/Tabs/__tests__/Tabs.test.tsx | 2 +- .../{ => lab}/Tabs/__tests__/Tabs.visual.test.tsx | 2 +- .../{ => lab}/Tabs/__tests__/TabsItem.test.tsx | 2 +- src/components/{ => lab}/Tabs/__tests__/cases.tsx | 2 +- src/components/{ => lab}/Tabs/__tests__/helpers.tsx | 0 src/components/{ => lab}/Tabs/index.ts | 0 src/deprecated.ts | 8 ++++++++ 21 files changed, 28 insertions(+), 15 deletions(-) rename src/components/{ => lab}/Tabs/README-ru.md (100%) rename src/components/{ => lab}/Tabs/README.md (99%) rename src/components/{ => lab}/Tabs/Tabs.scss (98%) rename src/components/{ => lab}/Tabs/Tabs.tsx (95%) rename src/components/{ => lab}/Tabs/TabsContext.ts (100%) rename src/components/{ => lab}/Tabs/TabsItem.tsx (94%) rename src/components/{ => lab}/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-allow-not-selected-light-chromium-linux.png (100%) rename src/components/{ => lab}/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-light-chromium-linux.png (100%) rename src/components/{ => lab}/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-with-custom-tab-light-chromium-linux.png (100%) rename src/components/{ => lab}/Tabs/__stories__/Docs.mdx (100%) rename src/components/{ => lab}/Tabs/__stories__/Tabs.stories.tsx (98%) rename src/components/{ => lab}/Tabs/__stories__/getTabsMock.tsx (100%) rename src/components/{ => lab}/Tabs/__stories__/types.ts (100%) rename src/components/{ => lab}/Tabs/__tests__/Tabs.test.tsx (98%) rename src/components/{ => lab}/Tabs/__tests__/Tabs.visual.test.tsx (96%) rename src/components/{ => lab}/Tabs/__tests__/TabsItem.test.tsx (98%) rename src/components/{ => lab}/Tabs/__tests__/cases.tsx (79%) rename src/components/{ => lab}/Tabs/__tests__/helpers.tsx (100%) rename src/components/{ => lab}/Tabs/index.ts (100%) create mode 100644 src/deprecated.ts diff --git a/package.json b/package.json index 80bf63c2c0..258ee24fd4 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,11 @@ "default": "./build/cjs/unstable.js" } }, + "./deprecated": { + "types": "./build/esm/deprecated.d.ts", + "require": "./build/cjs/deprecated.js", + "import": "./build/esm/deprecated.js" + }, "./server": { "import": { "types": "./build/esm/server.d.ts", diff --git a/src/components/Tabs/README-ru.md b/src/components/lab/Tabs/README-ru.md similarity index 100% rename from src/components/Tabs/README-ru.md rename to src/components/lab/Tabs/README-ru.md diff --git a/src/components/Tabs/README.md b/src/components/lab/Tabs/README.md similarity index 99% rename from src/components/Tabs/README.md rename to src/components/lab/Tabs/README.md index 901c4b62c5..29376f9f30 100644 --- a/src/components/Tabs/README.md +++ b/src/components/lab/Tabs/README.md @@ -5,7 +5,7 @@ ```tsx -import {Tabs} from '@gravity-ui/uikit'; +import {deprecated_Tabs as Tabs} from '@gravity-ui/uikit/deprecated'; ``` The `Tabs` component is used to explore and organize content, as well as to switch across various views. diff --git a/src/components/Tabs/Tabs.scss b/src/components/lab/Tabs/Tabs.scss similarity index 98% rename from src/components/Tabs/Tabs.scss rename to src/components/lab/Tabs/Tabs.scss index f81579f5e3..d290374252 100644 --- a/src/components/Tabs/Tabs.scss +++ b/src/components/lab/Tabs/Tabs.scss @@ -1,5 +1,5 @@ -@use '../variables'; -@use '../../../styles/mixins'; +@use '../../variables'; +@use '../../../../styles/mixins'; $block: '.#{variables.$ns}tabs'; diff --git a/src/components/Tabs/Tabs.tsx b/src/components/lab/Tabs/Tabs.tsx similarity index 95% rename from src/components/Tabs/Tabs.tsx rename to src/components/lab/Tabs/Tabs.tsx index 375fcaaa1e..1d92c2eae6 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/lab/Tabs/Tabs.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; -import type {AriaLabelingProps, QAProps} from '../types'; -import {block} from '../utils/cn'; -import {filterDOMProps} from '../utils/filterDOMProps'; +import type {AriaLabelingProps, QAProps} from '../../types'; +import {block} from '../../utils/cn'; +import {filterDOMProps} from '../../utils/filterDOMProps'; import {TabsContext} from './TabsContext'; import {TabsItem} from './TabsItem'; diff --git a/src/components/Tabs/TabsContext.ts b/src/components/lab/Tabs/TabsContext.ts similarity index 100% rename from src/components/Tabs/TabsContext.ts rename to src/components/lab/Tabs/TabsContext.ts diff --git a/src/components/Tabs/TabsItem.tsx b/src/components/lab/Tabs/TabsItem.tsx similarity index 94% rename from src/components/Tabs/TabsItem.tsx rename to src/components/lab/Tabs/TabsItem.tsx index 4fcb33bd3f..a38748640c 100644 --- a/src/components/Tabs/TabsItem.tsx +++ b/src/components/lab/Tabs/TabsItem.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; -import {Label} from '../Label'; -import type {LabelProps} from '../Label'; -import type {QAProps} from '../types'; -import {block} from '../utils/cn'; +import {Label} from '../../Label'; +import type {LabelProps} from '../../Label'; +import type {QAProps} from '../../types'; +import {block} from '../../utils/cn'; import {TabsContext} from './TabsContext'; diff --git a/src/components/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-allow-not-selected-light-chromium-linux.png b/src/components/lab/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-allow-not-selected-light-chromium-linux.png similarity index 100% rename from src/components/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-allow-not-selected-light-chromium-linux.png rename to src/components/lab/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-allow-not-selected-light-chromium-linux.png diff --git a/src/components/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-light-chromium-linux.png b/src/components/lab/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-light-chromium-linux.png similarity index 100% rename from src/components/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-light-chromium-linux.png rename to src/components/lab/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-light-chromium-linux.png diff --git a/src/components/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-with-custom-tab-light-chromium-linux.png b/src/components/lab/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-with-custom-tab-light-chromium-linux.png similarity index 100% rename from src/components/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-with-custom-tab-light-chromium-linux.png rename to src/components/lab/Tabs/__snapshots__/Tabs.visual.test.tsx-snapshots/Tabs-smoke-with-custom-tab-light-chromium-linux.png diff --git a/src/components/Tabs/__stories__/Docs.mdx b/src/components/lab/Tabs/__stories__/Docs.mdx similarity index 100% rename from src/components/Tabs/__stories__/Docs.mdx rename to src/components/lab/Tabs/__stories__/Docs.mdx diff --git a/src/components/Tabs/__stories__/Tabs.stories.tsx b/src/components/lab/Tabs/__stories__/Tabs.stories.tsx similarity index 98% rename from src/components/Tabs/__stories__/Tabs.stories.tsx rename to src/components/lab/Tabs/__stories__/Tabs.stories.tsx index 6295e3ad7b..38553e69d6 100644 --- a/src/components/Tabs/__stories__/Tabs.stories.tsx +++ b/src/components/lab/Tabs/__stories__/Tabs.stories.tsx @@ -10,7 +10,7 @@ import {getTabsMock} from './getTabsMock'; import type {StoryParams} from './types'; const meta: Meta = { - title: 'Components/Navigation/Tabs', + title: 'Deprecated/Tabs', component: Tabs, args: { direction: TabsDirection.Horizontal, diff --git a/src/components/Tabs/__stories__/getTabsMock.tsx b/src/components/lab/Tabs/__stories__/getTabsMock.tsx similarity index 100% rename from src/components/Tabs/__stories__/getTabsMock.tsx rename to src/components/lab/Tabs/__stories__/getTabsMock.tsx diff --git a/src/components/Tabs/__stories__/types.ts b/src/components/lab/Tabs/__stories__/types.ts similarity index 100% rename from src/components/Tabs/__stories__/types.ts rename to src/components/lab/Tabs/__stories__/types.ts diff --git a/src/components/Tabs/__tests__/Tabs.test.tsx b/src/components/lab/Tabs/__tests__/Tabs.test.tsx similarity index 98% rename from src/components/Tabs/__tests__/Tabs.test.tsx rename to src/components/lab/Tabs/__tests__/Tabs.test.tsx index 63c7c913c7..bafc7caa6c 100644 --- a/src/components/Tabs/__tests__/Tabs.test.tsx +++ b/src/components/lab/Tabs/__tests__/Tabs.test.tsx @@ -2,7 +2,7 @@ import type * as React from 'react'; import userEvent from '@testing-library/user-event'; -import {render, screen} from '../../../../test-utils/utils'; +import {render, screen} from '../../../../../test-utils/utils'; import {Tabs, TabsDirection} from '../Tabs'; import type {TabsItemProps, TabsSize} from '../Tabs'; diff --git a/src/components/Tabs/__tests__/Tabs.visual.test.tsx b/src/components/lab/Tabs/__tests__/Tabs.visual.test.tsx similarity index 96% rename from src/components/Tabs/__tests__/Tabs.visual.test.tsx rename to src/components/lab/Tabs/__tests__/Tabs.visual.test.tsx index d4acb7dfd9..bd5a4c57fe 100644 --- a/src/components/Tabs/__tests__/Tabs.visual.test.tsx +++ b/src/components/lab/Tabs/__tests__/Tabs.visual.test.tsx @@ -1,6 +1,6 @@ import {smokeTest, test} from '~playwright/core'; -import {createSmokeScenarios} from '../../../stories/tests-factory/create-smoke-scenarios'; +import {createSmokeScenarios} from '../../../../stories/tests-factory/create-smoke-scenarios'; import type {TabsProps} from '../Tabs'; import {directionCases, sizeCases} from './cases'; diff --git a/src/components/Tabs/__tests__/TabsItem.test.tsx b/src/components/lab/Tabs/__tests__/TabsItem.test.tsx similarity index 98% rename from src/components/Tabs/__tests__/TabsItem.test.tsx rename to src/components/lab/Tabs/__tests__/TabsItem.test.tsx index aa44254082..cd7db7ff4a 100644 --- a/src/components/Tabs/__tests__/TabsItem.test.tsx +++ b/src/components/lab/Tabs/__tests__/TabsItem.test.tsx @@ -1,7 +1,7 @@ import {Flame} from '@gravity-ui/icons'; import userEvent from '@testing-library/user-event'; -import {render, screen} from '../../../../test-utils/utils'; +import {render, screen} from '../../../../../test-utils/utils'; import {TabsItem} from '../TabsItem'; const tabId = 'tab-id'; diff --git a/src/components/Tabs/__tests__/cases.tsx b/src/components/lab/Tabs/__tests__/cases.tsx similarity index 79% rename from src/components/Tabs/__tests__/cases.tsx rename to src/components/lab/Tabs/__tests__/cases.tsx index 5de47fd6b1..b31fd9f89e 100644 --- a/src/components/Tabs/__tests__/cases.tsx +++ b/src/components/lab/Tabs/__tests__/cases.tsx @@ -1,4 +1,4 @@ -import type {Cases} from '../../../stories/tests-factory/models'; +import type {Cases} from '../../../../stories/tests-factory/models'; import type {TabsProps} from '../Tabs'; import {TabsDirection} from '../Tabs'; diff --git a/src/components/Tabs/__tests__/helpers.tsx b/src/components/lab/Tabs/__tests__/helpers.tsx similarity index 100% rename from src/components/Tabs/__tests__/helpers.tsx rename to src/components/lab/Tabs/__tests__/helpers.tsx diff --git a/src/components/Tabs/index.ts b/src/components/lab/Tabs/index.ts similarity index 100% rename from src/components/Tabs/index.ts rename to src/components/lab/Tabs/index.ts diff --git a/src/deprecated.ts b/src/deprecated.ts new file mode 100644 index 0000000000..4b00da5878 --- /dev/null +++ b/src/deprecated.ts @@ -0,0 +1,8 @@ +/* eslint-disable camelcase */ +export { + Tabs as deprecated_Tabs, + TabsDirection as deprecated_TabsDirection, + type TabsSize as deprecated_TabsSize, + type TabsItemProps as deprecated_TabsItemProps, + type TabsProps as deprecated_TabsProps, +} from './components/lab/Tabs'; From f64c9849d1555e1d641f2f36dc43cba7b8c79f24 Mon Sep 17 00:00:00 2001 From: Sofiya Pavlenko Date: Tue, 14 Jan 2025 18:26:54 +0300 Subject: [PATCH 2/6] feat(tabs): add new tabs component --- CODEOWNERS | 2 +- src/components/index.ts | 2 +- src/components/tabs/README.md | 5 + src/components/tabs/Tab.tsx | 93 ++++++++++ src/components/tabs/TabList.scss | 142 +++++++++++++++ src/components/tabs/TabList.tsx | 46 +++++ src/components/tabs/TabPanel.tsx | 32 ++++ src/components/tabs/TabProvider.tsx | 14 ++ src/components/tabs/__stories__/Docs.mdx | 6 + .../tabs/__stories__/TabProvider.stories.tsx | 82 +++++++++ .../tabs/__stories__/getTabsMock.tsx | 107 +++++++++++ .../tabs/__stories__/tabs.stories.tsx | 150 ++++++++++++++++ src/components/tabs/__tests__/Tab.test.tsx | 146 +++++++++++++++ .../tabs/__tests__/TabList.test.tsx | 170 ++++++++++++++++++ .../tabs/__tests__/TabList.visual.test.tsx | 86 +++++++++ .../tabs/__tests__/TabPanel.test.tsx | 27 +++ .../tabs/__tests__/TabProvider.test.tsx | 70 ++++++++ src/components/tabs/__tests__/cases.tsx | 4 + src/components/tabs/__tests__/constants.ts | 5 + src/components/tabs/__tests__/helpers.tsx | 58 ++++++ src/components/tabs/constants.ts | 5 + src/components/tabs/contexts/TabContext.tsx | 10 ++ .../tabs/contexts/TabInnerContext.tsx | 13 ++ src/components/tabs/index.ts | 5 + src/components/tabs/types.ts | 2 + .../BrandingConfigurator.tsx | 15 +- 26 files changed, 1286 insertions(+), 11 deletions(-) create mode 100644 src/components/tabs/README.md create mode 100644 src/components/tabs/Tab.tsx create mode 100644 src/components/tabs/TabList.scss create mode 100644 src/components/tabs/TabList.tsx create mode 100644 src/components/tabs/TabPanel.tsx create mode 100644 src/components/tabs/TabProvider.tsx create mode 100644 src/components/tabs/__stories__/Docs.mdx create mode 100644 src/components/tabs/__stories__/TabProvider.stories.tsx create mode 100644 src/components/tabs/__stories__/getTabsMock.tsx create mode 100644 src/components/tabs/__stories__/tabs.stories.tsx create mode 100644 src/components/tabs/__tests__/Tab.test.tsx create mode 100644 src/components/tabs/__tests__/TabList.test.tsx create mode 100644 src/components/tabs/__tests__/TabList.visual.test.tsx create mode 100644 src/components/tabs/__tests__/TabPanel.test.tsx create mode 100644 src/components/tabs/__tests__/TabProvider.test.tsx create mode 100644 src/components/tabs/__tests__/cases.tsx create mode 100644 src/components/tabs/__tests__/constants.ts create mode 100644 src/components/tabs/__tests__/helpers.tsx create mode 100644 src/components/tabs/constants.ts create mode 100644 src/components/tabs/contexts/TabContext.tsx create mode 100644 src/components/tabs/contexts/TabInnerContext.tsx create mode 100644 src/components/tabs/index.ts create mode 100644 src/components/tabs/types.ts diff --git a/CODEOWNERS b/CODEOWNERS index b919e8a4cd..297d87abd7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,7 +51,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 aff2a20ff4..91b3dc5a0a 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..6dabca2e6f --- /dev/null +++ b/src/components/tabs/Tab.tsx @@ -0,0 +1,93 @@ +'use client'; + +import * as 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..2df1cab65b --- /dev/null +++ b/src/components/tabs/TabList.tsx @@ -0,0 +1,46 @@ +'use client'; + +import * as 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..8641c060db --- /dev/null +++ b/src/components/tabs/TabPanel.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as 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..915fa485c9 --- /dev/null +++ b/src/components/tabs/TabProvider.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as 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..c3eb6b2e0e --- /dev/null +++ b/src/components/tabs/__stories__/TabProvider.stories.tsx @@ -0,0 +1,82 @@ +import * as 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..dfd852207a --- /dev/null +++ b/src/components/tabs/__stories__/getTabsMock.tsx @@ -0,0 +1,107 @@ +import type * as 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..e0ad3afad3 --- /dev/null +++ b/src/components/tabs/__stories__/tabs.stories.tsx @@ -0,0 +1,150 @@ +import * as 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..363767ab4b --- /dev/null +++ b/src/components/tabs/__tests__/Tab.test.tsx @@ -0,0 +1,146 @@ +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__/TabList.test.tsx b/src/components/tabs/__tests__/TabList.test.tsx new file mode 100644 index 0000000000..bdc975e94c --- /dev/null +++ b/src/components/tabs/__tests__/TabList.test.tsx @@ -0,0 +1,170 @@ +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__/TabList.visual.test.tsx b/src/components/tabs/__tests__/TabList.visual.test.tsx new file mode 100644 index 0000000000..a2a8ab878e --- /dev/null +++ b/src/components/tabs/__tests__/TabList.visual.test.tsx @@ -0,0 +1,86 @@ +import {smokeTest, test} from '~playwright/core'; + +import {createSmokeScenarios} from '../../../stories/tests-factory/create-smoke-scenarios'; +import type {TabListProps} from '../TabList'; + +import {sizeCases} from './cases'; +import {TestTabList, TestTabListWithCustomTabs} from './helpers'; + +test.describe('TabList', {tag: '@TabList'}, () => { + smokeTest('', async ({mount, expectScreenshot}) => { + const smokeScenarios = createSmokeScenarios( + { + value: 'active', + }, + { + size: sizeCases, + }, + ); + + await mount( +
+ {smokeScenarios.map(([title, props]) => ( +
+

{title}

+
+ +
+
+ ))} +
, + ); + + await expectScreenshot({ + themes: ['light'], + }); + }); + + smokeTest('without value', async ({mount, expectScreenshot}) => { + const smokeScenarios = createSmokeScenarios({}, {}); + + await mount( +
+ {smokeScenarios.map(([title, props]) => ( +
+

{title}

+
+ +
+
+ ))} +
, + ); + + await expectScreenshot({ + themes: ['light'], + }); + }); + + smokeTest('with custom tab', async ({mount, expectScreenshot}) => { + const smokeScenarios = createSmokeScenarios( + { + value: 'active', + }, + { + size: sizeCases, + }, + ); + + await mount( +
+ {smokeScenarios.map(([title, props]) => ( +
+

{title}

+
+ +
+
+ ))} +
, + ); + + await expectScreenshot({ + themes: ['light'], + }); + }); +}); diff --git a/src/components/tabs/__tests__/TabPanel.test.tsx b/src/components/tabs/__tests__/TabPanel.test.tsx new file mode 100644 index 0000000000..c7a6fc97eb --- /dev/null +++ b/src/components/tabs/__tests__/TabPanel.test.tsx @@ -0,0 +1,27 @@ +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..9f26cc98b9 --- /dev/null +++ b/src/components/tabs/__tests__/TabProvider.test.tsx @@ -0,0 +1,70 @@ +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 './helpers'; + +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__/cases.tsx b/src/components/tabs/__tests__/cases.tsx new file mode 100644 index 0000000000..3f45eff11c --- /dev/null +++ b/src/components/tabs/__tests__/cases.tsx @@ -0,0 +1,4 @@ +import type {Cases} from '../../../stories/tests-factory/models'; +import type {TabListProps} from '../TabList'; + +export const sizeCases: Cases = ['m', 'l', 'xl']; 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__/helpers.tsx b/src/components/tabs/__tests__/helpers.tsx new file mode 100644 index 0000000000..1f51db5bf2 --- /dev/null +++ b/src/components/tabs/__tests__/helpers.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import {Tab, TabList, TabPanel, TabProvider} from '..'; +import type {TabListProps} from '../TabList'; +import {getTabsMock} from '../__stories__/getTabsMock'; + +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 + + + ); +}; + +export const TestTabList = (props: Partial) => { + const items = React.useMemo( + () => + getTabsMock({})?.map((props, i) => ( + + )), + [], + ); + + return {items}; +}; + +export const TestTabListWithCustomTabs = (props: Partial) => { + const items = React.useMemo( + () => + getTabsMock({withCustomChildren: true})?.map((props, i) => ( + + )), + [], + ); + + return {items}; +}; 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..282a3f716e --- /dev/null +++ b/src/components/tabs/contexts/TabContext.tsx @@ -0,0 +1,10 @@ +import * as 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..8baab9d865 --- /dev/null +++ b/src/components/tabs/contexts/TabInnerContext.tsx @@ -0,0 +1,13 @@ +import * as 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 fb16b5330b..550b0ab841 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'; @@ -156,14 +157,10 @@ export function BrandingConfigurator({theme}: BrandingConfiguratorProps) {
Tabs
- + + Overview + Settings +
Table
From 7e67ab31eb4314d17981b1559105fcdc490a8fa8 Mon Sep 17 00:00:00 2001 From: Sofiya Pavlenko Date: Tue, 14 Jan 2025 18:26:55 +0300 Subject: [PATCH 3/6] fix(tabs): readme --- src/components/tabs/README.md | 357 +++++++++++++++++++++++++++++++++- 1 file changed, 356 insertions(+), 1 deletion(-) diff --git a/src/components/tabs/README.md b/src/components/tabs/README.md index c89d84caba..91530bfceb 100644 --- a/src/components/tabs/README.md +++ b/src/components/tabs/README.md @@ -1,5 +1,360 @@ -# tabs +# Tabs components + +```tsx +import {TabProvider, TabList, Tab, TabPanel} from '@gravity-ui/uikit'; +``` + +Tabs components is used to explore, organize content and switch between different views. + + + + + +```tsx +const [activeTab, setActiveTab] = React.useState('second'); + +return ( + + + + First Tab + + + Active Tab + + + Disabled Tab + + +
+ First Panel + Second Panel + Third Panel +
+
+); +``` + + + +### Components + +- [TabProvider](#tabprovider) +- [TabList](#tablist) +- [Tab](#tab) +- [TabPanel](#tabpanel) + +## TabProvider + +A component that provides the tab selection functionality + +### Properties + +| Name | Description | Type | Default | +| :------- | :------------------------------------------------------- | :-------------------: | :-----: | +| children | List of tabs and tab panels, probably with some wrappers | `React.ReactNode` | | +| value | Active tab value | `string \| undefined` | | + +## TabList + +Component that serves as the container for a set of `tabs` + +### Size + +To control the size of the `tabs` use the `size` property. Default size is `m`. + + + + + +```tsx + + M Size first + M Size second + + + L Size first + L Size second + + + XL Size first + v Size second + +``` + + + +### Properties + +| Name | Description | Type | Default | +| :-------------: | :---------------------------------------- | :------------------------------: | :------: | +| value | Active tab value | `string \| undefined` | | +| children | List of tabs, probably with some wrappers | `React.ReactNode` | | +| onUpdate | Update tab handler | `onUpdate?(value: string): void` | | +| className | CSS-class of element | `string \| undefined` | | +| size | Element size | `'m' \| 'l' \| 'xl'` | `'m'` | +| contentOverflow | Controls component overflow behavior | `'wrap'` | `'wrap'` | +| qa | HTML `data-qa` attribute, used in tests | `string` | | + +## Tab + +This component is used to render tab items. + +### Icon + +Used if you need to display an icon for a tab item. + + + + + +```tsx + + }> + Tab with icon + + Tab without icon + +``` + + + +### States + +Tab item has disabled flag. + + + + + +```tsx + + First Tab + + Disabled Tab + + +``` + + + +### Counter + +Used if you need to display a number for a tabs item. + + + + + +```tsx + + + First Tab + + + Second Tab + + +``` + + + +### Label + +Used if you need to display a label for a tabs item. + + + + + +```tsx + + + First Tab + + + Second Tab + + +``` + + + +### Properties + +| Name | Description | Type | Default | +| :------------ | ---------------------------------------------------------------- | :-------------------: | :-----: | +| value | Tab value | `string` | | +| title | Tab title | `string \| undefined` | | +| icon | Icon displayed at the start | `React.ReactNode` | | +| counter | Content displayed at the end | `number \| string` | | +| href | A URL to link to. | `string \| undefined` | | +| label | `
diff --git a/src/components/tabs/TabProvider.tsx b/src/components/tabs/TabProvider.tsx index 915fa485c9..af63f4cea3 100644 --- a/src/components/tabs/TabProvider.tsx +++ b/src/components/tabs/TabProvider.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import {TabContext} from './contexts/TabContext'; export type TabProviderProps = React.PropsWithChildren<{ - value: string | undefined; + value?: string; }>; export const TabProvider = ({value: activeTabId, children}: TabProviderProps) => { diff --git a/src/components/tabs/__stories__/TabProvider.stories.tsx b/src/components/tabs/__stories__/TabProvider.stories.tsx index c3eb6b2e0e..fb4749a99b 100644 --- a/src/components/tabs/__stories__/TabProvider.stories.tsx +++ b/src/components/tabs/__stories__/TabProvider.stories.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import {useArgs} from '@storybook/preview-api'; import type {Meta, StoryFn} from '@storybook/react'; +import {Button} from '../../Button'; import {Tab} from '../Tab'; import {TabList} from '../TabList'; import type {TabListProps} from '../TabList'; @@ -65,7 +66,9 @@ export const Default: StoryFn = ({...args}) => { () => getTabsMock({})?.map((tab, i) => ( - {`Content of ${tab.value} tab panel`} + )), [], diff --git a/src/components/tabs/__stories__/getTabsMock.tsx b/src/components/tabs/__stories__/getTabsMock.tsx index dfd852207a..5cf0e84f32 100644 --- a/src/components/tabs/__stories__/getTabsMock.tsx +++ b/src/components/tabs/__stories__/getTabsMock.tsx @@ -12,15 +12,17 @@ type StoryParams = { withLabel?: boolean; withLink?: boolean; withCustomChildren?: boolean; + withTitle?: boolean; }; const gearIcon = ; export function getTabsMock(args: StoryParams): TabProps[] { + const {withTitle = true} = args; return [ { value: 'first', - title: 'First Tab', + title: withTitle ? 'First Tab' : undefined, children: args.withCustomChildren ? : 'First Tab', icon: args.withIcon ? gearIcon : undefined, counter: args.withCounter ? Math.floor(Math.random() * 5 + 1) : undefined, @@ -29,7 +31,7 @@ export function getTabsMock(args: StoryParams): TabProps[] { }, { value: 'active', - title: 'Active Tab', + title: withTitle ? 'Active Tab' : undefined, children: args.withCustomChildren ? ( ) : ( @@ -42,7 +44,7 @@ export function getTabsMock(args: StoryParams): TabProps[] { }, { value: 'disabled', - title: 'disabled', + title: withTitle ? 'disabled' : undefined, children: args.withCustomChildren ? ( ) : ( @@ -56,7 +58,7 @@ export function getTabsMock(args: StoryParams): TabProps[] { }, { value: 'fourth', - title: 'Fourth Long Text To Show Tab', + title: withTitle ? 'Fourth Long Text To Show Tab' : undefined, children: args.withCustomChildren ? ( ) : ( @@ -69,7 +71,7 @@ export function getTabsMock(args: StoryParams): TabProps[] { }, { value: 'fifth', - title: 'One More Long Text Tab To Show', + title: withTitle ? 'One More Long Text Tab To Show' : undefined, children: args.withCustomChildren ? ( ) : ( diff --git a/src/components/tabs/__stories__/tabs.stories.tsx b/src/components/tabs/__stories__/tabs.stories.tsx index e0ad3afad3..253ffd81d1 100644 --- a/src/components/tabs/__stories__/tabs.stories.tsx +++ b/src/components/tabs/__stories__/tabs.stories.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import {useArgs} from '@storybook/preview-api'; import type {Meta, StoryFn} from '@storybook/react'; +import {Tooltip} from '../../Tooltip'; import {Tab} from '../Tab'; import {TabList} from '../TabList'; import type {TabListProps} from '../TabList'; @@ -148,3 +149,24 @@ export const CustomTab: StoryFn = ({...props}) => {
); }; + +export const WrapTab: StoryFn = ({...props}) => { + const [, setStoryArgs] = useArgs(); + + const items = React.useMemo( + () => + getTabsMock({withTitle: false})?.map(({value, ...props}, i) => ( + + + + )), + [], + ); + return ( + + setStoryArgs({value: tabId})}> + {items} + + + ); +}; diff --git a/src/components/tabs/__tests__/TabList.test.tsx b/src/components/tabs/__tests__/TabList.test.tsx index bdc975e94c..2a07993268 100644 --- a/src/components/tabs/__tests__/TabList.test.tsx +++ b/src/components/tabs/__tests__/TabList.test.tsx @@ -146,7 +146,7 @@ test('should call onUpdate on "\' \'" key', async () => { expect(onUpdateFn).toHaveBeenCalledWith(tab2.id); }); -test('should not call onUpdate on "[Enter]" key', async () => { +test('should call onUpdate on "[Enter]" key', async () => { const onUpdateFn = jest.fn(); const user = userEvent.setup(); @@ -166,5 +166,5 @@ test('should not call onUpdate on "[Enter]" key', async () => { await user.keyboard('[Enter]'); - expect(onUpdateFn).not.toHaveBeenCalled(); + expect(onUpdateFn).toHaveBeenCalledWith(tab2.id); }); diff --git a/src/components/tabs/contexts/TabContext.tsx b/src/components/tabs/contexts/TabContext.tsx index 282a3f716e..d7341c3804 100644 --- a/src/components/tabs/contexts/TabContext.tsx +++ b/src/components/tabs/contexts/TabContext.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; export interface TabContextProps { - activeTabId: string | undefined; + activeTabId?: string; } export const TabContext = React.createContext({ activeTabId: undefined, diff --git a/src/components/tabs/contexts/TabInnerContext.tsx b/src/components/tabs/contexts/TabInnerContext.tsx index 8baab9d865..0131c19967 100644 --- a/src/components/tabs/contexts/TabInnerContext.tsx +++ b/src/components/tabs/contexts/TabInnerContext.tsx @@ -1,13 +1,17 @@ import * as React from 'react'; export interface TabInnerContextProps { - activeTabId: string | undefined; - onUpdate: ((value: string) => void) | undefined; + activeTabId?: string; + onUpdate?: (value: string) => void; + focusedIndex: number; + setFocusedIndex?: (value: number) => void; } export const TabInnerContext = React.createContext({ activeTabId: undefined, onUpdate: undefined, + focusedIndex: -1, + setFocusedIndex: undefined, }); TabInnerContext.displayName = 'TabInnerContext';