Skip to content

Commit

Permalink
feat(tabs): add new tabs component
Browse files Browse the repository at this point in the history
  • Loading branch information
sofiushko committed Dec 19, 2024
1 parent 7f89644 commit 6998aab
Show file tree
Hide file tree
Showing 23 changed files with 1,141 additions and 2 deletions.
2 changes: 1 addition & 1 deletion CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 5 additions & 0 deletions src/components/tabs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!--GITHUB_BLOCK-->

# tabs

<!--/GITHUB_BLOCK-->
92 changes: 92 additions & 0 deletions src/components/tabs/Tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'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';

export interface TabProps extends AriaLabelingProps, DOMProps, QAProps {
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<HTMLAnchorElement | HTMLDivElement, TabProps>((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 = {
...filterDOMProps(props, {labelable: true}),
'aria-selected': isActive,
'aria-disabled': disabled === true,
'aria-controls': `panel-${value}`,
role: 'tab',
style,
title,
onClick: handleClick,
onKeyDown: handleKeyDown,
id: `tab-${value}`,
'data-qa': qa,
className: bTabList('item', {active: isActive, disabled}, className),
};

const content = (
<div className={bTabList('item-content')}>
{icon && <div className={bTabList('item-icon')}>{icon}</div>}
<div className={bTabList('item-title')}>{children || value}</div>
{counter !== undefined && <div className={bTabList('item-counter')}>{counter}</div>}
{label && (
<Label className={bTabList('item-label')} theme={label.theme}>
{label.content}
</Label>
)}
</div>
);

if (href) {
return (
<a {...tabProps} href={href} ref={ref as React.Ref<HTMLAnchorElement>}>
{content}
</a>
);
}

return (
<div {...tabProps} tabIndex={disabled ? -1 : 0} ref={ref as React.Ref<HTMLDivElement>}>
{content}
</div>
);
});

Tab.displayName = 'Tab';
142 changes: 142 additions & 0 deletions src/components/tabs/TabList.scss
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
46 changes: 46 additions & 0 deletions src/components/tabs/TabList.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, TabListProps>(
({size = 'm', value, children, className, onUpdate, qa}, ref) => {
const activeTabId = React.useContext(TabContext).activeTabId || value;

const tabInnerContextValue = React.useMemo(
() => ({onUpdate, activeTabId}),
[onUpdate, activeTabId],
);

return (
<div className={bTabList({size}, className)} data-qa={qa} ref={ref}>
<div
role="tablist"
className={bTabList('tabs', className)}
aria-orientation="horizontal"
>
<TabInnerContext.Provider value={tabInnerContextValue}>
{children}
</TabInnerContext.Provider>
</div>
</div>
);
},
);

TabList.displayName = 'TabList';
28 changes: 28 additions & 0 deletions src/components/tabs/TabPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import React from 'react';

import type {QAProps} from '../types';

import {bTabList} from './constants';
import {TabContext} from './contexts/TabContext';
export interface TabPanelProps extends QAProps {
value: string;
children: React.ReactNode;
}

export const TabPanel = ({children, value, qa}: TabPanelProps) => {
const {activeTabId} = React.useContext(TabContext);

return (
<div
className={bTabList('panel', {active: activeTabId === value})}
role="tabpanel"
id={`panel-${value}`}
aria-labelledby={`tab-${value}`}
data-qa={qa}
>
{activeTabId === value ? children : null}
</div>
);
};
14 changes: 14 additions & 0 deletions src/components/tabs/TabProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 <TabContext.Provider value={value}>{children}</TabContext.Provider>;
};
6 changes: 6 additions & 0 deletions src/components/tabs/__stories__/Docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {Meta, Markdown} from '@storybook/addon-docs';
import Readme from '../README.md?raw';

<Meta title="Components/navigation/tabs" />

<Markdown>{Readme}</Markdown>
Loading

0 comments on commit 6998aab

Please sign in to comment.