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/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..f3f3f2aa
--- /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);
+ border-top-right-radius: var(--tab-top-radius);
+
+ @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);
+ 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..352275b2
--- /dev/null
+++ b/src/tedi/components/navigation/tabs/tabs.stories.tsx
@@ -0,0 +1,257 @@
+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
+
+
+ ),
+};
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/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/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..15c8d24f
--- /dev/null
+++ b/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx
@@ -0,0 +1,73 @@
+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',
+ },
+ },
+};
+
+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: 'Закрыть',