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 (
+
+ );
+};
+
+StatusIndicator.displayName = 'StatusIndicator';
+
+export default StatusIndicator;
diff --git a/src/tedi/index.ts b/src/tedi/index.ts
index 319dbf90..2b21f816 100644
--- a/src/tedi/index.ts
+++ b/src/tedi/index.ts
@@ -11,6 +11,7 @@ export * from './components/loaders/spinner/spinner';
export * from './components/loaders/skeleton';
export * from './components/tags/tag/tag';
export * from './components/tags/status-badge/status-badge';
+export * from './components/tags/status-indicator/status-indicator';
export * from './components/buttons/closing-button/closing-button';
export * from './components/buttons/button/button';
export * from './components/buttons/info-button/info-button';
@@ -24,6 +25,7 @@ export * from './components/notifications/toast/toast';
export * from './components/cards/card';
export * from './components/navigation/hash-trigger/hash-trigger';
export * from './components/navigation/link/link';
+export * from './components/navigation/tabs';
export * from './components/form/textfield/textfield';
export * from './components/form/textarea/textarea';
export * from './components/form/number-field/number-field';
diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts
index e93c30ca..8483ae1b 100644
--- a/src/tedi/providers/label-provider/labels-map.ts
+++ b/src/tedi/providers/label-provider/labels-map.ts
@@ -72,9 +72,16 @@ const muiTranslationsUrl =
* et, en and ru values must be of same type
*/
export const labelsMap = validateDefaultLabels({
+ 'tabs.more': {
+ description: 'Label for the mobile overflow button in Tabs',
+ components: ['Tabs'],
+ et: 'Veel',
+ en: 'More',
+ ru: 'Ещё',
+ },
close: {
description: 'Used for closing',
- components: ['CloseButton', 'Collapse', 'Notification', 'FileUpload', 'Dropdown', 'Tooltip'],
+ components: ['CloseButton', 'Collapse', 'Notification', 'FileUpload', 'Dropdown', 'Tooltip', 'Tabs'],
et: 'Sulge',
en: 'Close',
ru: 'Закрыть',
From 7f0fdaef766eb2547f766f235605cc9debcf1a76 Mon Sep 17 00:00:00 2001
From: m2rt
Date: Fri, 13 Mar 2026 14:55:16 +0200
Subject: [PATCH 2/2] feat(tabs): new tedi-ready component #555 code review
fixes
---
.../components/tabs/tabs.stories.tsx | 5 ++
.../navigation/tabs/tabs.module.scss | 6 +-
.../navigation/tabs/tabs.stories.tsx | 46 ---------------
.../navigation/tabs/usage-with-router.mdx | 56 +++++++++++++++++++
.../status-indicator.stories.tsx | 14 -----
5 files changed, 64 insertions(+), 63 deletions(-)
create mode 100644 src/tedi/components/navigation/tabs/usage-with-router.mdx
diff --git a/src/community/components/tabs/tabs.stories.tsx b/src/community/components/tabs/tabs.stories.tsx
index c120e063..8b3b481b 100644
--- a/src/community/components/tabs/tabs.stories.tsx
+++ b/src/community/components/tabs/tabs.stories.tsx
@@ -8,6 +8,11 @@ const meta: Meta = {
component: Tabs,
title: 'Community/Tabs',
subcomponents: { TabsItem } as never,
+ parameters: {
+ status: {
+ type: ['deprecated', 'ExistsInTediReady'],
+ },
+ },
};
export default meta;
diff --git a/src/tedi/components/navigation/tabs/tabs.module.scss b/src/tedi/components/navigation/tabs/tabs.module.scss
index 7ea63bc0..f3f3f2aa 100644
--- a/src/tedi/components/navigation/tabs/tabs.module.scss
+++ b/src/tedi/components/navigation/tabs/tabs.module.scss
@@ -37,8 +37,8 @@
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);
+ border-top-left-radius: var(--tab-top-radius);
+ border-top-right-radius: var(--tab-top-radius);
@include breakpoints.media-breakpoint-down(md) {
overflow-x: visible;
@@ -84,7 +84,7 @@
}
&__content {
- padding: var(--tab-content-padding, 1.5rem 2rem 2rem);
+ padding: var(--tab-content-padding);
background: var(--tab-item-selected-background);
}
diff --git a/src/tedi/components/navigation/tabs/tabs.stories.tsx b/src/tedi/components/navigation/tabs/tabs.stories.tsx
index cde86865..352275b2 100644
--- a/src/tedi/components/navigation/tabs/tabs.stories.tsx
+++ b/src/tedi/components/navigation/tabs/tabs.stories.tsx
@@ -255,49 +255,3 @@ export const WithDisabledTab: Story = {
),
};
-
-/**
- * ## 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;