From 5c096f612508abfc773f468c1e2a80014c39aa5e Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 13 Mar 2026 11:10:56 +0200 Subject: [PATCH 1/2] feat(tabs): new tedi-ready component #555 extracted StatusIndicator from badge --- src/tedi/components/navigation/tabs/index.ts | 6 + .../tabs/tabs-content/tabs-content.tsx | 47 ++ .../navigation/tabs/tabs-context.tsx | 16 + .../tabs/tabs-dropdown/tabs-dropdown.tsx | 125 +++++ .../navigation/tabs/tabs-helpers.ts | 41 ++ .../navigation/tabs/tabs-list/tabs-list.tsx | 135 ++++++ .../tabs/tabs-trigger/tabs-trigger.tsx | 78 +++ .../navigation/tabs/tabs.module.scss | 103 ++++ .../components/navigation/tabs/tabs.spec.tsx | 443 ++++++++++++++++++ .../navigation/tabs/tabs.stories.tsx | 303 ++++++++++++ src/tedi/components/navigation/tabs/tabs.tsx | 66 +++ .../dropdown-trigger/dropdown-trigger.tsx | 1 + .../status-badge/status-badge.module.scss | 28 -- .../tags/status-badge/status-badge.spec.tsx | 10 +- .../tags/status-badge/status-badge.tsx | 4 +- .../components/tags/status-indicator/index.ts | 1 + .../status-indicator.module.scss | 34 ++ .../status-indicator.spec.tsx | 36 ++ .../status-indicator.stories.tsx | 87 ++++ .../status-indicator/status-indicator.tsx | 60 +++ src/tedi/index.ts | 2 + .../providers/label-provider/labels-map.ts | 9 +- 22 files changed, 1600 insertions(+), 35 deletions(-) create mode 100644 src/tedi/components/navigation/tabs/index.ts create mode 100644 src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx create mode 100644 src/tedi/components/navigation/tabs/tabs-context.tsx create mode 100644 src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx create mode 100644 src/tedi/components/navigation/tabs/tabs-helpers.ts create mode 100644 src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx create mode 100644 src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx create mode 100644 src/tedi/components/navigation/tabs/tabs.module.scss create mode 100644 src/tedi/components/navigation/tabs/tabs.spec.tsx create mode 100644 src/tedi/components/navigation/tabs/tabs.stories.tsx create mode 100644 src/tedi/components/navigation/tabs/tabs.tsx create mode 100644 src/tedi/components/tags/status-indicator/index.ts create mode 100644 src/tedi/components/tags/status-indicator/status-indicator.module.scss create mode 100644 src/tedi/components/tags/status-indicator/status-indicator.spec.tsx create mode 100644 src/tedi/components/tags/status-indicator/status-indicator.stories.tsx create mode 100644 src/tedi/components/tags/status-indicator/status-indicator.tsx diff --git a/src/tedi/components/navigation/tabs/index.ts b/src/tedi/components/navigation/tabs/index.ts new file mode 100644 index 00000000..045aa390 --- /dev/null +++ b/src/tedi/components/navigation/tabs/index.ts @@ -0,0 +1,6 @@ +export * from './tabs'; +export * from './tabs-context'; +export * from './tabs-list/tabs-list'; +export * from './tabs-trigger/tabs-trigger'; +export * from './tabs-content/tabs-content'; +export * from './tabs-dropdown/tabs-dropdown'; diff --git a/src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx b/src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx new file mode 100644 index 00000000..cff74193 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx @@ -0,0 +1,47 @@ +import cn from 'classnames'; +import React from 'react'; + +import styles from '../tabs.module.scss'; +import { useTabsContext } from '../tabs-context'; + +export interface TabsContentProps { + /** + * Unique identifier matching the corresponding TabsTrigger id. + * When provided, content is only shown when this tab is active. + * When omitted, content is always rendered (useful for router outlets). + */ + id?: string; + /** + * Tab panel content + */ + children: React.ReactNode; + /** + * Additional class name(s) + */ + className?: string; +} + +export const TabsContent = (props: TabsContentProps) => { + const { id, children, className } = props; + const { currentTab } = useTabsContext(); + + if (id && currentTab !== id) { + return null; + } + + return ( +
+ {children} +
+ ); +}; + +TabsContent.displayName = 'TabsContent'; + +export default TabsContent; diff --git a/src/tedi/components/navigation/tabs/tabs-context.tsx b/src/tedi/components/navigation/tabs/tabs-context.tsx new file mode 100644 index 00000000..c762152f --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-context.tsx @@ -0,0 +1,16 @@ +import React, { useContext } from 'react'; + +export type TabsContextValue = { + currentTab: string; + setCurrentTab: (id: string) => void; +}; + +export const TabsContext = React.createContext(null); + +export const useTabsContext = () => { + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error('Tabs components must be used within '); + } + return ctx; +}; diff --git a/src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx b/src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx new file mode 100644 index 00000000..836dcf55 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx @@ -0,0 +1,125 @@ +import cn from 'classnames'; +import React from 'react'; + +import { Icon } from '../../../base/icon/icon'; +import { Dropdown } from '../../../overlays/dropdown/dropdown'; +import styles from '../tabs.module.scss'; +import { useTabsContext } from '../tabs-context'; +import { navigateTablist } from '../tabs-helpers'; + +export interface TabsDropdownItemProps { + /** + * Unique identifier matching the corresponding TabsContent id + */ + id: string; + /** + * Item label + */ + children: React.ReactNode; + /** + * Whether the item is disabled + * @default false + */ + disabled?: boolean; +} + +const TabsDropdownItem = (props: TabsDropdownItemProps) => { + const { id, children } = props; + return {children}; +}; + +TabsDropdownItem.displayName = 'TabsDropdownItem'; + +export interface TabsDropdownProps { + /** + * Dropdown label displayed on the trigger + */ + label: string; + /** + * TabsDropdown.Item elements + */ + children: React.ReactNode; + /** + * Whether the dropdown trigger is disabled + * @default false + */ + disabled?: boolean; + /** + * Additional class name(s) + */ + className?: string; +} + +export const TabsDropdown = (props: TabsDropdownProps) => { + const { label, children, disabled = false, className } = props; + const { currentTab, setCurrentTab } = useTabsContext(); + + const [open, setOpen] = React.useState(false); + + const childArray = React.Children.toArray(children).filter(React.isValidElement); + const childIds = childArray.map((child) => (child.props as TabsDropdownItemProps).id); + const isSelected = childIds.includes(currentTab); + + const selectedChild = childArray.find((child) => (child.props as TabsDropdownItemProps).id === currentTab); + const displayLabel = selectedChild ? (selectedChild.props as TabsDropdownItemProps).children : label; + + const handleSelect = (id: string) => { + setCurrentTab(id); + setOpen(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const target = navigateTablist(e); + if (target) { + setOpen(false); + setCurrentTab(target.id); + } + }; + + return ( + + + + + + {childArray.map((child, index) => { + const itemProps = child.props as TabsDropdownItemProps; + return ( + handleSelect(itemProps.id)} + > + {itemProps.children} + + ); + })} + + + ); +}; + +TabsDropdown.displayName = 'TabsDropdown'; +TabsDropdown.Item = TabsDropdownItem; + +export default TabsDropdown; diff --git a/src/tedi/components/navigation/tabs/tabs-helpers.ts b/src/tedi/components/navigation/tabs/tabs-helpers.ts new file mode 100644 index 00000000..a7546b88 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-helpers.ts @@ -0,0 +1,41 @@ +/** + * Navigates to a sibling tab in the tablist using ArrowLeft/ArrowRight/Home/End keys. + * Returns the target tab element if navigation occurred, or null otherwise. + */ +export const navigateTablist = (e: React.KeyboardEvent): HTMLButtonElement | null => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') { + return null; + } + + const tablist = e.currentTarget.closest('[role="tablist"]'); + if (!tablist) return null; + + const tabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])')).filter( + (tab) => getComputedStyle(tab).display !== 'none' + ); + const currentIndex = tabs.indexOf(e.currentTarget); + let newIndex = -1; + + switch (e.key) { + case 'ArrowLeft': + newIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1; + break; + case 'ArrowRight': + newIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1; + break; + case 'Home': + newIndex = 0; + break; + case 'End': + newIndex = tabs.length - 1; + break; + } + + if (newIndex !== -1) { + e.preventDefault(); + tabs[newIndex].focus(); + return tabs[newIndex]; + } + + return null; +}; diff --git a/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx new file mode 100644 index 00000000..49106493 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx @@ -0,0 +1,135 @@ +import cn from 'classnames'; +import React from 'react'; + +import { isBreakpointBelow, useBreakpoint } from '../../../../helpers'; +import { useLabels } from '../../../../providers/label-provider'; +import { Icon } from '../../../base/icon/icon'; +import Print, { PrintProps } from '../../../misc/print/print'; +import { Dropdown } from '../../../overlays/dropdown/dropdown'; +import styles from '../tabs.module.scss'; +import { useTabsContext } from '../tabs-context'; +import { TabsDropdown } from '../tabs-dropdown/tabs-dropdown'; +import { TabsDropdownItemProps } from '../tabs-dropdown/tabs-dropdown'; + +export interface TabsListProps { + /** + * Tab trigger elements + */ + children: React.ReactNode; + /** + * Additional class name(s) + */ + className?: string; + /** + * Accessible label for the tablist + */ + 'aria-label'?: string; + /** + * ID of element labelling the tablist + */ + 'aria-labelledby'?: string; + /** + * Controls visibility when printing + * @default 'show' + */ + printVisibility?: PrintProps['visibility']; +} + +interface MobileDropdownItem { + id: string; + label: React.ReactNode; + disabled?: boolean; +} + +export const TabsList = (props: TabsListProps) => { + const { + children, + className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + printVisibility = 'show', + } = props; + + const { getLabel } = useLabels(); + const { currentTab, setCurrentTab } = useTabsContext(); + + const breakpoint = useBreakpoint(); + const isMobile = isBreakpointBelow(breakpoint, 'md'); + + const childArray = React.useMemo(() => { + return React.Children.toArray(children).filter(React.isValidElement); + }, [children]); + + // Flatten all children (including TabsDropdown items) for mobile dropdown + const mobileItems = React.useMemo(() => { + const result: MobileDropdownItem[] = []; + childArray.forEach((child) => { + if ((child.type as { displayName?: string }).displayName === TabsDropdown.displayName) { + const dropdownProps = child.props as { children: React.ReactNode }; + const items = React.Children.toArray(dropdownProps.children).filter(React.isValidElement); + items.forEach((item) => { + const itemProps = item.props as TabsDropdownItemProps; + result.push({ id: itemProps.id, label: itemProps.children, disabled: itemProps.disabled }); + }); + } else { + const triggerProps = child.props as { id: string; children: React.ReactNode; disabled?: boolean }; + result.push({ id: triggerProps.id, label: triggerProps.children, disabled: triggerProps.disabled }); + } + }); + return result; + }, [childArray]); + + const showMore = isMobile && mobileItems.length > 1; + + // Filter out the currently selected tab from the mobile dropdown + const dropdownItems = mobileItems.filter((item) => item.id !== currentTab); + + const handleMobileSelect = (id: string) => { + if (id) { + setCurrentTab(id); + } + }; + + return ( + +
+ {children} + {showMore && ( +
+ + + + + + {dropdownItems.map((item, index) => ( + handleMobileSelect(item.id)} + > + {item.label} + + ))} + + +
+ )} +
+
+ ); +}; + +TabsList.displayName = 'TabsList'; + +export default TabsList; diff --git a/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx b/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx new file mode 100644 index 00000000..46b60a75 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx @@ -0,0 +1,78 @@ +import cn from 'classnames'; +import React from 'react'; + +import { Icon, IconProps } from '../../../base/icon/icon'; +import styles from '../tabs.module.scss'; +import { useTabsContext } from '../tabs-context'; +import { navigateTablist } from '../tabs-helpers'; + +export interface TabsTriggerProps { + /** + * Unique identifier for this tab. Must match the corresponding TabsContent id. + */ + id: string; + /** + * Tab label text + */ + children: React.ReactNode; + /** + * Icon displayed before the label + */ + icon?: IconProps['name']; + /** + * Whether the tab is disabled + * @default false + */ + disabled?: boolean; + /** + * Additional class name(s) + */ + className?: string; +} + +export const TabsTrigger = (props: TabsTriggerProps) => { + const { id, children, icon, disabled = false, className } = props; + const { currentTab, setCurrentTab } = useTabsContext(); + const isSelected = currentTab === id; + + const handleClick = () => { + if (!disabled) { + setCurrentTab(id); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const target = navigateTablist(e); + if (target) { + setCurrentTab(target.id); + } + }; + + return ( + + ); +}; + +TabsTrigger.displayName = 'TabsTrigger'; + +export default TabsTrigger; diff --git a/src/tedi/components/navigation/tabs/tabs.module.scss b/src/tedi/components/navigation/tabs/tabs.module.scss new file mode 100644 index 00000000..7ea63bc0 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.module.scss @@ -0,0 +1,103 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + +@mixin tab-button-base { + --_tab-background: var(--tab-item-default-background); + --_tab-color: var(--tab-item-default-text); + + display: inline-flex; + gap: var(--tab-inner-spacing); + align-items: center; + justify-content: center; + padding: var(--tab-spacing-y) var(--tab-spacing-x); + font-size: var(--body-regular-size); + color: var(--_tab-color); + white-space: nowrap; + cursor: pointer; + background: var(--_tab-background); + border: none; + + &:hover { + --_tab-background: var(--tab-item-hover-background); + --_tab-color: var(--tab-item-hover-text); + } + + &:focus-visible { + outline: none; + box-shadow: inset 0 0 0 var(--borders-02) var(--general-border-brand); + } + + &:active { + --_tab-background: var(--tab-item-active-background); + --_tab-color: var(--tab-item-active-text); + } +} + +.tedi-tabs { + &__list { + display: flex; + overflow-x: auto; + background: var(--tab-background); + border-top-left-radius: var(--tab-top-radius, 4px); + border-top-right-radius: var(--tab-top-radius, 4px); + + @include breakpoints.media-breakpoint-down(md) { + overflow-x: visible; + + .tedi-tabs__trigger { + &:not(.tedi-tabs__trigger--selected) { + display: none; + } + + &--selected { + flex-grow: 1; + } + } + } + } + + &__trigger { + @include tab-button-base; + + position: relative; + text-decoration: none; + + &--selected { + --_tab-background: var(--tab-item-selected-background); + --_tab-color: var(--tab-item-selected-text); + + font-weight: var(--body-bold-weight); + + &::after { + position: absolute; + top: 0; + right: 0; + left: 0; + content: ''; + border-top: 3px solid var(--tab-item-selected-border); + } + } + + &--disabled { + pointer-events: none; + opacity: 0.4; + } + } + + &__content { + padding: var(--tab-content-padding, 1.5rem 2rem 2rem); + background: var(--tab-item-selected-background); + } + + &__more-wrapper { + position: relative; + display: none; + + @include breakpoints.media-breakpoint-down(md) { + display: flex; + } + } + + &__more-btn { + @include tab-button-base; + } +} diff --git a/src/tedi/components/navigation/tabs/tabs.spec.tsx b/src/tedi/components/navigation/tabs/tabs.spec.tsx new file mode 100644 index 00000000..4887686d --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.spec.tsx @@ -0,0 +1,443 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Tabs, TabsProps } from './tabs'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: jest.fn(() => ({ + getLabel: jest.fn((key: string) => { + const labels: Record = { 'tabs.more': 'More', close: 'Close' }; + return labels[key] ?? key; + }), + })), +})); + +const renderTabs = (props?: Partial) => { + return render( + + + Tab 1 + Tab 2 + + Tab 3 + + + Content 1 + Content 2 + Content 3 + + ); +}; + +const setupMobileMode = () => { + (window.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + matches: false, // No min-width queries match → breakpoint is 'xs' + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); +}; + +const setupDesktopMode = () => { + (window.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + matches: ['(min-width: 576px)', '(min-width: 768px)', '(min-width: 992px)'].includes(query), + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); +}; + +describe('Tabs component', () => { + beforeEach(() => { + setupDesktopMode(); + }); + + it('renders the tablist with correct role', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toBeInTheDocument(); + }); + + it('renders tab triggers with correct roles', () => { + renderTabs(); + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(3); + }); + + it('renders the default active tab content', () => { + renderTabs(); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + }); + + it('switches tab on click', () => { + renderTabs(); + fireEvent.click(screen.getByText('Tab 2')); + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('sets aria-selected correctly', () => { + renderTabs(); + expect(screen.getByText('Tab 1')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Tab 2')).toHaveAttribute('aria-selected', 'false'); + + fireEvent.click(screen.getByText('Tab 2')); + expect(screen.getByText('Tab 1')).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByText('Tab 2')).toHaveAttribute('aria-selected', 'true'); + }); + + it('sets aria-controls on triggers and aria-labelledby on panels', () => { + renderTabs(); + expect(screen.getByText('Tab 1')).toHaveAttribute('aria-controls', 'tab-1-panel'); + expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'tab-1'); + }); + + it('manages tabIndex — only selected tab is in tab order', () => { + renderTabs(); + expect(screen.getByText('Tab 1')).toHaveAttribute('tabIndex', '0'); + expect(screen.getByText('Tab 2')).toHaveAttribute('tabIndex', '-1'); + }); + + it('navigates with ArrowRight key', () => { + renderTabs(); + const tab1 = screen.getByText('Tab 1'); + fireEvent.keyDown(tab1, { key: 'ArrowRight' }); + expect(screen.getByText('Tab 2')).toHaveFocus(); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('navigates with ArrowLeft key and wraps around', () => { + renderTabs(); + const tab1 = screen.getByText('Tab 1'); + fireEvent.keyDown(tab1, { key: 'ArrowLeft' }); + expect(screen.getByText('Tab 2')).toHaveFocus(); + }); + + it('navigates with Home and End keys', () => { + renderTabs(); + fireEvent.click(screen.getByText('Tab 2')); + const tab2 = screen.getByText('Tab 2'); + + fireEvent.keyDown(tab2, { key: 'Home' }); + expect(screen.getByText('Tab 1')).toHaveFocus(); + + fireEvent.keyDown(screen.getByText('Tab 1'), { key: 'End' }); + expect(screen.getByText('Tab 2')).toHaveFocus(); + }); + + it('does not activate disabled tabs on click', () => { + renderTabs(); + const disabledTab = screen.getByText('Tab 3'); + expect(disabledTab).toBeDisabled(); + fireEvent.click(disabledTab); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 3')).not.toBeInTheDocument(); + }); + + it('works in controlled mode', () => { + const onChange = jest.fn(); + const { rerender } = render( + + + Tab 1 + Tab 2 + + Content 1 + Content 2 + + ); + + fireEvent.click(screen.getByText('Tab 2')); + expect(onChange).toHaveBeenCalledWith('tab-2'); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + + rerender( + + + Tab 1 + Tab 2 + + Content 1 + Content 2 + + ); + + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('renders with custom className', () => { + renderTabs({ className: 'custom-class' }); + const container = screen.getByRole('tablist').parentElement; + expect(container).toHaveClass('custom-class'); + }); + + it('renders tabpanel with correct id', () => { + renderTabs(); + expect(screen.getByRole('tabpanel')).toHaveAttribute('id', 'tab-1-panel'); + }); + + it('applies data-name attributes', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toHaveAttribute('data-name', 'tabs-list'); + expect(screen.getByRole('tabpanel')).toHaveAttribute('data-name', 'tabs-content'); + }); + + it('does not set tabIndex on tabpanel', () => { + renderTabs(); + expect(screen.getByRole('tabpanel')).not.toHaveAttribute('tabIndex'); + }); + + it('does not render "More" button on desktop', () => { + renderTabs(); + expect(screen.queryByText('More')).not.toBeInTheDocument(); + }); +}); + +describe('Tabs mobile overflow', () => { + beforeEach(() => { + setupMobileMode(); + }); + + afterEach(() => { + setupDesktopMode(); + }); + + it('renders "More" button on mobile when there are multiple tabs', () => { + renderTabs(); + expect(screen.getByText('More')).toBeInTheDocument(); + }); + + it('does not render "More" button when there is only one tab', () => { + render( + + + Only Tab + + Content + + ); + expect(screen.queryByText('More')).not.toBeInTheDocument(); + }); + + it('opens dropdown with non-selected tabs', () => { + renderTabs(); + fireEvent.click(screen.getByText('More')); + + const menu = screen.getByRole('menu'); + const menuItems = menu.querySelectorAll('[role="menuitem"]'); + expect(menuItems).toHaveLength(2); + }); + + it('closes dropdown on toggle', () => { + renderTabs(); + const moreBtn = screen.getByText('More'); + + fireEvent.click(moreBtn); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + fireEvent.click(moreBtn); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('selects a tab from dropdown and closes it', () => { + renderTabs(); + fireEvent.click(screen.getByText('More')); + + const menuItems = screen.getAllByRole('menuitem'); + const tab2Item = menuItems.find((item) => item.textContent === 'Tab 2'); + fireEvent.click(tab2Item!); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('closes dropdown on Escape key', () => { + renderTabs(); + fireEvent.click(screen.getByText('More')); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + act(() => { + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('flattens TabsDropdown items into mobile More menu', () => { + render( + + + Tab 1 + + Sub 1 + Sub 2 + + + Content 1 + Content 2 + Content 3 + + ); + + fireEvent.click(screen.getByText('More')); + const menuItems = screen.getAllByRole('menuitem'); + expect(menuItems).toHaveLength(2); + expect(menuItems[0]).toHaveTextContent('Sub 1'); + expect(menuItems[1]).toHaveTextContent('Sub 2'); + }); +}); + +describe('TabsDropdown', () => { + beforeEach(() => { + setupDesktopMode(); + }); + + const renderWithDropdown = () => { + return render( + + + Tab 1 + + Sub 1 + Sub 2 + + Tab 4 + + Content 1 + Content 2 + Content 3 + Content 4 + + ); + }; + + it('renders dropdown trigger with tab role', () => { + renderWithDropdown(); + const dropdown = screen.getByText('Group').closest('[role="tab"]'); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveAttribute('aria-selected', 'false'); + expect(dropdown).toHaveAttribute('tabIndex', '-1'); + }); + + it('sets aria-selected and aria-controls when a dropdown item is active', () => { + renderWithDropdown(); + const dropdown = screen.getByText('Group').closest('[role="tab"]')!; + + fireEvent.click(dropdown); + const menuItems = screen.getAllByRole('menuitem'); + fireEvent.click(menuItems[0]); + + expect(dropdown).toHaveAttribute('aria-selected', 'true'); + expect(dropdown).toHaveAttribute('aria-controls', 'tab-2-panel'); + }); + + it('shows selected item label on trigger', () => { + renderWithDropdown(); + const dropdown = screen.getByText('Group').closest('[role="tab"]')!; + + fireEvent.click(dropdown); + fireEvent.click(screen.getAllByRole('menuitem')[0]); + + expect(dropdown).toHaveTextContent('Sub 1'); + }); + + it('opens dropdown menu on click', () => { + renderWithDropdown(); + const dropdown = screen.getByText('Group').closest('[role="tab"]')!; + + fireEvent.click(dropdown); + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('selects item and closes dropdown', () => { + renderWithDropdown(); + const dropdown = screen.getByText('Group').closest('[role="tab"]')!; + + fireEvent.click(dropdown); + fireEvent.click(screen.getAllByRole('menuitem')[1]); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByText('Content 3')).toBeInTheDocument(); + }); + + it('navigates to sibling tabs with arrow keys', () => { + renderWithDropdown(); + const tab1 = screen.getByText('Tab 1'); + + fireEvent.keyDown(tab1, { key: 'ArrowRight' }); + const dropdown = screen.getByText('Group').closest('[role="tab"]')!; + expect(dropdown).toHaveFocus(); + + fireEvent.keyDown(dropdown, { key: 'ArrowRight' }); + expect(screen.getByText('Tab 4')).toHaveFocus(); + }); + + it('displays disabled item in dropdown', () => { + render( + + + Tab 1 + + Sub 1 + + Sub 2 + + + + Content 1 + Content 2 + Content 3 + + ); + + const dropdown = screen.getByText('Group').closest('[role="tab"]')!; + fireEvent.click(dropdown); + + const menuItems = screen.getAllByRole('menuitem'); + expect(menuItems[1]).toHaveAttribute('disabled'); + }); +}); + +describe('TabsContent without id', () => { + beforeEach(() => { + setupDesktopMode(); + }); + + it('always renders when id is omitted', () => { + render( + + + Tab 1 + Tab 2 + + Always visible + + ); + + expect(screen.getByText('Always visible')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Tab 2')); + expect(screen.getByText('Always visible')).toBeInTheDocument(); + }); + + it('does not set id or aria-labelledby when id is omitted', () => { + render( + + + Tab 1 + + Router outlet + + ); + + const panel = screen.getByRole('tabpanel'); + expect(panel).not.toHaveAttribute('id'); + expect(panel).not.toHaveAttribute('aria-labelledby'); + }); +}); diff --git a/src/tedi/components/navigation/tabs/tabs.stories.tsx b/src/tedi/components/navigation/tabs/tabs.stories.tsx new file mode 100644 index 00000000..cde86865 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.stories.tsx @@ -0,0 +1,303 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; + +import { Text } from '../../base/typography/text/text'; +import { Col, Row } from '../../layout/grid'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { StatusBadge } from '../../tags/status-badge/status-badge'; +import { StatusIndicator } from '../../tags/status-indicator/status-indicator'; +import { Tabs, TabsProps } from './tabs'; +import { TabsContext } from './tabs-context'; +import { TabsTrigger } from './tabs-trigger/tabs-trigger'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +const meta: Meta = { + component: Tabs, + title: 'TEDI-Ready/Components/Navigation/Tabs', + subcomponents: { + 'Tabs.List': Tabs.List, + 'Tabs.Trigger': Tabs.Trigger, + 'Tabs.Content': Tabs.Content, + 'Tabs.Dropdown': Tabs.Dropdown, + 'Tabs.Dropdown.Item': Tabs.Dropdown.Item, + } as never, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.38.59?node-id=3419-38773&m=dev', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const stateArray = ['Default', 'Hover', 'Active', 'Focus', 'Selected']; + +interface TemplateStateProps extends TabsProps { + array: typeof stateArray; +} + +const noop = () => null; + +const TemplateColumnWithStates: StoryFn = (args) => { + const { array } = args; + + return ( +
+ {array.map((state, index) => { + const triggerId = state === 'Selected' ? 'state-tab' : `${state}-tab`; + const currentTab = state === 'Selected' ? 'state-tab' : ''; + + return ( + + + {state} + + + +
+ Toimingud +
+
+ +
+ ); + })} +
+ ); +}; + +export const Default: Story = { + render: () => ( + + + Toimingud + Dokumendid + Esindusõigused + Kontaktisikud + + +

Toimingud content

+
+ +

Dokumendid content

+
+ +

Esindusõigused content

+
+ +

Kontaktisikud content

+
+
+ ), +}; + +export const WithIcons: Story = { + render: () => ( + + + + Minu andmed + + + Dokumendid + + + Ligipääs + + + Seaded + + + +

Minu andmed content

+
+ +

Dokumendid content

+
+ +

Ligipääs content

+
+ +

Seaded content

+
+
+ ), +}; + +export const WithStatusBadge: Story = { + render: () => ( + + + + Toimingud Esitatud + + + + Lugemata teated  + + + + Esindusõigused + + +

Toimingud content

+
+ +

Lugemata teated content

+
+ +

Esindusõigused content

+
+
+ ), +}; + +export const States: StoryObj = { + render: TemplateColumnWithStates, + args: { + array: stateArray, + }, + parameters: { + pseudo: { + hover: '#Hover-tab', + active: '#Active-tab', + focusVisible: '#Focus-tab', + }, + }, +}; + +export const Controlled: Story = { + render: () => { + const [currentTab, setCurrentTab] = useState('tab-1'); + + return ( + +

+ Current tab: {currentTab} +

+ + + Toimingud + Dokumendid + Esindusõigused + + +

Toimingud content

+
+ +

Dokumendid content

+
+ +

Esindusõigused content

+
+
+
+ ); + }, +}; + +export const WithDropdown: Story = { + render: () => ( + + + Toimingud + + Volitused + Õigused + Pääsud + + Dokumendid + + +

Toimingud content

+
+ +

Dokumendid content

+
+ +

Volitused content

+
+ +

Õigused content

+
+ +

Pääsud content

+
+
+ ), +}; + +export const WithDisabledTab: Story = { + render: () => ( + + + Toimingud + Dokumendid + + Esindusõigused + + + +

Toimingud content

+
+ +

Dokumendid content

+
+ +

Esindusõigused content

+
+
+ ), +}; + +/** + * ## Usage with React Router + * + * Use controlled mode (`value`/`onChange`) to sync tabs with the current route. + * Wrap the router outlet in `` without an `id` — it always renders + * and provides the content panel styling. + * + * ```tsx + * import { useLocation, useNavigate, Routes, Route } from 'react-router-dom'; + * import { Tabs } from '@tedi-design-system/react/tedi'; + * + * const tabs = [ + * { id: '/toimingud', label: 'Toimingud' }, + * { id: '/dokumendid', label: 'Dokumendid' }, + * { id: '/esindusõigused', label: 'Esindusõigused' }, + * ]; + * + * function TabsWithRouting() { + * const location = useLocation(); + * const navigate = useNavigate(); + * + * return ( + * navigate(path)}> + * + * {tabs.map((tab) => ( + * + * {tab.label} + * + * ))} + * + * + * + * Toimingud content

} /> + * Dokumendid content

} /> + * Esindusõigused content

} /> + *
+ *
+ *
+ * ); + * } + * ``` + */ +export const WithRouting: Story = { + render: () => <>, +}; diff --git a/src/tedi/components/navigation/tabs/tabs.tsx b/src/tedi/components/navigation/tabs/tabs.tsx new file mode 100644 index 00000000..06ff9736 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.tsx @@ -0,0 +1,66 @@ +import cn from 'classnames'; +import React from 'react'; + +import styles from './tabs.module.scss'; +import { TabsContent } from './tabs-content/tabs-content'; +import { TabsContext } from './tabs-context'; +import { TabsDropdown } from './tabs-dropdown/tabs-dropdown'; +import { TabsList } from './tabs-list/tabs-list'; +import { TabsTrigger } from './tabs-trigger/tabs-trigger'; + +export interface TabsProps { + /** + * Tabs content — should include Tabs.List and Tabs.Content elements + */ + children: React.ReactNode; + /** + * Controlled active tab id. Use together with onChange. + */ + value?: string; + /** + * Default active tab id for uncontrolled usage. + */ + defaultValue?: string; + /** + * Callback fired when the active tab changes + */ + onChange?: (tabId: string) => void; + /** + * Additional class name(s) + */ + className?: string; +} + +export const Tabs = (props: TabsProps) => { + const { children, value: controlledTab, defaultValue, onChange, className } = props; + const [uncontrolledTab, setUncontrolledTab] = React.useState(defaultValue || ''); + + const currentTab = controlledTab ?? uncontrolledTab; + + const setCurrentTab = React.useCallback( + (id: string) => { + if (id === currentTab) return; + if (controlledTab === undefined) { + setUncontrolledTab(id); + } + onChange?.(id); + }, + [controlledTab, currentTab, onChange] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +Tabs.displayName = 'Tabs'; +Tabs.List = TabsList; +Tabs.Trigger = TabsTrigger; +Tabs.Content = TabsContent; +Tabs.Dropdown = TabsDropdown; + +export default Tabs; diff --git a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx index d98e32ca..8422118b 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx @@ -16,6 +16,7 @@ export const DropdownTrigger = ({ children }: DropdownTriggerProps) => { children, getReferenceProps({ ref: refs.setReference, + ...children.props, }) ); }; diff --git a/src/tedi/components/tags/status-badge/status-badge.module.scss b/src/tedi/components/tags/status-badge/status-badge.module.scss index 7e20a99d..99a02897 100644 --- a/src/tedi/components/tags/status-badge/status-badge.module.scss +++ b/src/tedi/components/tags/status-badge/status-badge.module.scss @@ -2,7 +2,6 @@ $badge-colors: ('neutral', 'brand', 'accent', 'success', 'danger', 'warning', 'transparent'); $badge-variants: ('filled', 'filled-bordered', 'bordered'); -$badge-status-colors: ('inactive', 'success', 'danger', 'warning'); :root { --status-badge-icon-primary: var(--tedi-blue-700); @@ -54,33 +53,6 @@ $badge-status-colors: ('inactive', 'success', 'danger', 'warning'); } } - &--status { - &::before { - position: absolute; - top: -0.25rem; - right: -0.25rem; - z-index: 1; - width: 0.625rem; - height: 0.625rem; - content: ''; - border: 1px solid var(--tedi-neutral-100); - border-radius: 50%; - } - - &.tedi-badge--large::before { - top: -0.1875rem; - right: -0.1875rem; - width: 0.875rem; - height: 0.875rem; - } - - @each $status in $badge-status-colors { - &-#{$status}::before { - background-color: var(--status-badge-indicator-#{$status}); - } - } - } - &__icon-only { display: inline-flex; align-items: center; diff --git a/src/tedi/components/tags/status-badge/status-badge.spec.tsx b/src/tedi/components/tags/status-badge/status-badge.spec.tsx index 8b680e75..a5f1a9a3 100644 --- a/src/tedi/components/tags/status-badge/status-badge.spec.tsx +++ b/src/tedi/components/tags/status-badge/status-badge.spec.tsx @@ -48,9 +48,9 @@ describe('StatusBadge component', () => { Warning Badge ); - const badge = container.querySelector('.tedi-status-badge'); - expect(badge).toHaveClass('tedi-status-badge--status'); - expect(badge).toHaveClass('tedi-status-badge--status-warning'); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveClass('tedi-status-indicator--warning'); }); it('renders with icon only', () => { @@ -112,8 +112,10 @@ describe('StatusBadge component', () => { const { container } = render(All Props Badge); const badge = container.querySelector('.tedi-status-badge'); expect(badge).toHaveClass('tedi-status-badge--variant-filled-bordered'); - expect(badge).toHaveClass('tedi-status-badge--status-success'); expect(badge).toHaveClass('tedi-status-badge--large'); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveClass('tedi-status-indicator--success'); expect(badge).toHaveClass('custom-class'); expect(badge).toHaveTextContent('All Props Badge'); expect(badge).toHaveAttribute('title', 'Success Badge'); diff --git a/src/tedi/components/tags/status-badge/status-badge.tsx b/src/tedi/components/tags/status-badge/status-badge.tsx index a2ee589c..ef8fb528 100644 --- a/src/tedi/components/tags/status-badge/status-badge.tsx +++ b/src/tedi/components/tags/status-badge/status-badge.tsx @@ -2,6 +2,7 @@ import cn from 'classnames'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { Icon } from '../../base/icon/icon'; +import { StatusIndicator } from '../status-indicator/status-indicator'; import styles from './status-badge.module.scss'; export type StatusBadgeColor = 'neutral' | 'brand' | 'accent' | 'success' | 'danger' | 'warning' | 'transparent'; @@ -82,8 +83,6 @@ export const StatusBadge = (props: StatusBadgeProps): JSX.Element => { styles['tedi-status-badge'], styles[`tedi-status-badge--variant-${variant}`], styles[`tedi-status-badge--color-${color}`], - status && styles['tedi-status-badge--status'], - status && styles[`tedi-status-badge--status-${status}`], size === 'large' && styles['tedi-status-badge--large'], icon && !children && styles['tedi-status-badge__icon-only'], className @@ -100,6 +99,7 @@ export const StatusBadge = (props: StatusBadgeProps): JSX.Element => { aria-live={role ? ariaLive : undefined} {...rest} > + {status && } {icon && } {children && {children}} diff --git a/src/tedi/components/tags/status-indicator/index.ts b/src/tedi/components/tags/status-indicator/index.ts new file mode 100644 index 00000000..e012d858 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/index.ts @@ -0,0 +1 @@ +export * from './status-indicator'; diff --git a/src/tedi/components/tags/status-indicator/status-indicator.module.scss b/src/tedi/components/tags/status-indicator/status-indicator.module.scss new file mode 100644 index 00000000..d37a6b7b --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.module.scss @@ -0,0 +1,34 @@ +$indicator-types: ('success', 'danger', 'warning', 'inactive'); + +.tedi-status-indicator { + display: inline-block; + flex-shrink: 0; + border-radius: 50%; + + &--top-right { + position: absolute; + top: -0.25rem; + right: -0.25rem; + z-index: 1; + } + + &--sm { + width: var(--status-indicator-sm); + height: var(--status-indicator-sm); + } + + &--lg { + width: var(--status-indicator-lg); + height: var(--status-indicator-lg); + } + + &--bordered { + box-shadow: 0 0 0 2px var(--tedi-neutral-100, white); + } + + @each $type in $indicator-types { + &--#{$type} { + background-color: var(--status-badge-indicator-#{$type}); + } + } +} diff --git a/src/tedi/components/tags/status-indicator/status-indicator.spec.tsx b/src/tedi/components/tags/status-indicator/status-indicator.spec.tsx new file mode 100644 index 00000000..dbdd42f2 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.spec.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; + +import { StatusIndicator } from './status-indicator'; + +describe('StatusIndicator', () => { + it('renders with default props', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders with danger type', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('tedi-status-indicator--danger'); + }); + + it('renders with large size', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('tedi-status-indicator--lg'); + }); + + it('renders with border', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('tedi-status-indicator--bordered'); + }); + + it('applies custom className', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('custom'); + }); +}); diff --git a/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx b/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx new file mode 100644 index 00000000..bfddd730 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx @@ -0,0 +1,87 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Text } from '../../base/typography/text/text'; +import { Col, Row } from '../../layout/grid'; +import { StatusIndicator } from './status-indicator'; + +/** + * Figma ↗ + */ +const meta: Meta = { + component: StatusIndicator, + title: 'TEDI-Ready/Components/Tag/StatusIndicator', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.38.59?node-id=2405-53326&m=dev', + }, + }, + argTypes: { + type: { + control: 'select', + options: ['success', 'danger', 'warning', 'inactive'], + }, + size: { + control: 'select', + options: ['sm', 'lg'], + }, + position: { + control: 'select', + options: ['default', 'top-right'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + type: 'success', + size: 'sm', + hasBorder: false, + }, +}; + +const types = ['success', 'danger', 'warning', 'inactive'] as const; +const sizes = ['sm', 'lg'] as const; + +export const AllVariants: Story = { + render: () => ( +
+ {sizes.map((size) => ( + + + {size === 'sm' ? 'Small' : 'Large'} + + + {types.map((type) => ( + + ))} + + + ))} + {sizes.map((size) => ( + + + {size === 'sm' ? 'Small bordered' : 'Large bordered'} + + + {types.map((type) => ( + + ))} + + + ))} +
+ ), +}; + +export const Examples: Story = { + render: () => ( + + Lugemata teated  + + + ), +}; diff --git a/src/tedi/components/tags/status-indicator/status-indicator.tsx b/src/tedi/components/tags/status-indicator/status-indicator.tsx new file mode 100644 index 00000000..994b2635 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.tsx @@ -0,0 +1,60 @@ +import cn from 'classnames'; + +import styles from './status-indicator.module.scss'; + +export type StatusIndicatorType = 'success' | 'danger' | 'warning' | 'inactive'; +export type StatusIndicatorSize = 'sm' | 'lg'; +export type StatusIndicatorPosition = 'default' | 'top-right'; + +export interface StatusIndicatorProps { + /** + * The status type, which determines the indicator color. + * @default 'success' + */ + type?: StatusIndicatorType; + /** + * The size of the indicator. + * @default 'sm' + */ + size?: StatusIndicatorSize; + /** + * Whether the indicator has a white border ring. + * @default false + */ + hasBorder?: boolean; + /** + * Controls positioning of the indicator. + * - `'default'` — inline, no absolute positioning + * - `'top-right'` — absolutely positioned at the top-right corner of the parent + * @default 'default' + */ + position?: StatusIndicatorPosition; + /** + * Additional class name(s) + */ + className?: string; +} + +export const StatusIndicator = (props: StatusIndicatorProps): JSX.Element => { + const { type = 'success', size = 'sm', hasBorder = false, position = 'default', className } = props; + + return ( +
), }; - -/** - * ## Usage with React Router - * - * Use controlled mode (`value`/`onChange`) to sync tabs with the current route. - * Wrap the router outlet in `` without an `id` — it always renders - * and provides the content panel styling. - * - * ```tsx - * import { useLocation, useNavigate, Routes, Route } from 'react-router-dom'; - * import { Tabs } from '@tedi-design-system/react/tedi'; - * - * const tabs = [ - * { id: '/toimingud', label: 'Toimingud' }, - * { id: '/dokumendid', label: 'Dokumendid' }, - * { id: '/esindusõigused', label: 'Esindusõigused' }, - * ]; - * - * function TabsWithRouting() { - * const location = useLocation(); - * const navigate = useNavigate(); - * - * return ( - * navigate(path)}> - * - * {tabs.map((tab) => ( - * - * {tab.label} - * - * ))} - * - * - * - * Toimingud content

} /> - * Dokumendid content

} /> - * Esindusõigused content

} /> - *
- *
- *
- * ); - * } - * ``` - */ -export const WithRouting: Story = { - render: () => <>, -}; diff --git a/src/tedi/components/navigation/tabs/usage-with-router.mdx b/src/tedi/components/navigation/tabs/usage-with-router.mdx new file mode 100644 index 00000000..157d4287 --- /dev/null +++ b/src/tedi/components/navigation/tabs/usage-with-router.mdx @@ -0,0 +1,56 @@ +import { Meta } from '@storybook/blocks'; + + + +# Usage with React Router + +Use controlled mode (`value`/`onChange`) to sync tabs with the current route. +Wrap the router outlet in `` without an `id` — it always renders +and provides the content panel styling. + +--- + +## Example + +```tsx +import { useLocation, useNavigate, Routes, Route } from 'react-router-dom'; +import { Tabs } from '@tedi-design-system/react/tedi'; + +const tabs = [ + { id: '/toimingud', label: 'Toimingud' }, + { id: '/dokumendid', label: 'Dokumendid' }, + { id: '/esindusõigused', label: 'Esindusõigused' }, +]; + +function TabsWithRouting() { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + navigate(path)}> + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + Toimingud content

} /> + Dokumendid content

} /> + Esindusõigused content

} /> +
+
+
+ ); +} +``` + +--- + +## Key Points + +- Use `value` and `onChange` to keep tab state in sync with the URL. +- `Tabs.Content` without an `id` always renders its children — use it to wrap your router outlet. +- Each `Tabs.Trigger` `id` should match the route path so the correct tab is highlighted. diff --git a/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx b/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx index bfddd730..15c8d24f 100644 --- a/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx +++ b/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx @@ -16,20 +16,6 @@ const meta: Meta = { url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.38.59?node-id=2405-53326&m=dev', }, }, - argTypes: { - type: { - control: 'select', - options: ['success', 'danger', 'warning', 'inactive'], - }, - size: { - control: 'select', - options: ['sm', 'lg'], - }, - position: { - control: 'select', - options: ['default', 'top-right'], - }, - }, }; export default meta;