From 5c096f612508abfc773f468c1e2a80014c39aa5e Mon Sep 17 00:00:00 2001
From: m2rt
Date: Fri, 13 Mar 2026 11:10:56 +0200
Subject: [PATCH 1/3] 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/3] 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;
From f66bfb29c708fa5e911885a248980b18310b51ca Mon Sep 17 00:00:00 2001
From: m2rt
Date: Wed, 25 Mar 2026 13:28:22 +0200
Subject: [PATCH 3/3] feat(tabs): changes and improvements from design review
#555
---
jest-mocks.js | 8 +
.../misc/scroll-fade/scroll-fade.tsx | 60 ++---
src/tedi/components/navigation/tabs/index.ts | 1 -
.../tabs/tabs-dropdown/tabs-dropdown.tsx | 125 ----------
.../navigation/tabs/tabs-helpers.ts | 1 +
.../navigation/tabs/tabs-list/tabs-list.tsx | 134 +++++++---
.../tabs/tabs-trigger/tabs-trigger.tsx | 2 +-
.../navigation/tabs/tabs.module.scss | 66 +++--
.../components/navigation/tabs/tabs.spec.tsx | 229 ++++++------------
.../navigation/tabs/tabs.stories.tsx | 210 +++++++++-------
src/tedi/components/navigation/tabs/tabs.tsx | 2 -
.../helpers/hooks/use-scroll-fade.spec.tsx | 173 +++++++++++++
src/tedi/helpers/hooks/use-scroll-fade.ts | 115 +++++++++
src/tedi/helpers/index.ts | 1 +
14 files changed, 658 insertions(+), 469 deletions(-)
delete mode 100644 src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx
create mode 100644 src/tedi/helpers/hooks/use-scroll-fade.spec.tsx
create mode 100644 src/tedi/helpers/hooks/use-scroll-fade.ts
diff --git a/jest-mocks.js b/jest-mocks.js
index 3282fc94..0a43275f 100644
--- a/jest-mocks.js
+++ b/jest-mocks.js
@@ -12,3 +12,11 @@ Object.defineProperty(window, 'matchMedia', {
dispatchEvent: jest.fn(),
})),
});
+
+global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+}));
+
+Element.prototype.scrollIntoView = jest.fn();
diff --git a/src/tedi/components/misc/scroll-fade/scroll-fade.tsx b/src/tedi/components/misc/scroll-fade/scroll-fade.tsx
index 9a570bd2..11f043c0 100644
--- a/src/tedi/components/misc/scroll-fade/scroll-fade.tsx
+++ b/src/tedi/components/misc/scroll-fade/scroll-fade.tsx
@@ -1,6 +1,7 @@
import cn from 'classnames';
-import { forwardRef, useCallback, useState } from 'react';
+import { forwardRef, useCallback } from 'react';
+import { useScrollFade } from '../../../helpers';
import styles from './scroll-fade.module.scss';
export interface ScrollFadeProps {
@@ -47,61 +48,32 @@ export const ScrollFade = forwardRef((props, re
fadeSize = 20,
fadePosition = 'both',
} = props;
- const [fade, setFade] = useState({ top: false, bottom: false });
- const handleFade = useCallback(
- (scrollTop: number, scrollHeight: number, clientHeight: number) => {
- const atTop = scrollTop === 0;
- const atBottom = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;
-
- let fadeTop = true;
- let fadeBottom = true;
-
- if (atTop) {
- fadeTop = false;
- onScrollToTop?.();
- }
-
- if (atBottom) {
- fadeBottom = false;
- onScrollToBottom?.();
- }
-
- setFade({ top: fadeTop, bottom: fadeBottom });
- },
- [onScrollToTop, onScrollToBottom]
- );
-
- const onScroll = useCallback(
- (e: React.UIEvent) => {
- const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLDivElement;
- handleFade(scrollTop, scrollHeight, clientHeight);
- },
- [handleFade]
- );
+ const { scrollRef, canScrollStart, canScrollEnd, handleScroll } = useScrollFade({
+ direction: 'vertical',
+ onScrollToStart: onScrollToTop,
+ onScrollToEnd: onScrollToBottom,
+ });
- const callbackRef = useCallback(
+ const mergedRef = useCallback(
(node: HTMLDivElement | null) => {
+ scrollRef(node);
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
-
- if (node) {
- handleFade(node.scrollTop, node.scrollHeight, node.clientHeight);
- }
},
- [handleFade, ref]
+ [scrollRef, ref]
);
+ const showStartFade = canScrollStart && (fadePosition === 'both' || fadePosition === 'top');
+ const showEndFade = canScrollEnd && (fadePosition === 'both' || fadePosition === 'bottom');
+
const ScrollFadeBEM = cn(
styles['tedi-scroll-fade'],
- { [styles[`tedi-scroll-fade--top-${fadeSize}`]]: fade.top && (fadePosition === 'both' || fadePosition === 'top') },
- {
- [styles[`tedi-scroll-fade--bottom-${fadeSize}`]]:
- fade.bottom && (fadePosition === 'both' || fadePosition === 'bottom'),
- },
+ { [styles[`tedi-scroll-fade--top-${fadeSize}`]]: showStartFade },
+ { [styles[`tedi-scroll-fade--bottom-${fadeSize}`]]: showEndFade },
className
);
@@ -111,7 +83,7 @@ export const ScrollFade = forwardRef((props, re
return (
-
diff --git a/src/tedi/components/navigation/tabs/index.ts b/src/tedi/components/navigation/tabs/index.ts
index 045aa390..f2a4981c 100644
--- a/src/tedi/components/navigation/tabs/index.ts
+++ b/src/tedi/components/navigation/tabs/index.ts
@@ -3,4 +3,3 @@ 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-dropdown/tabs-dropdown.tsx b/src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx
deleted file mode 100644
index 836dcf55..00000000
--- a/src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-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
index a7546b88..c018a3c9 100644
--- a/src/tedi/components/navigation/tabs/tabs-helpers.ts
+++ b/src/tedi/components/navigation/tabs/tabs-helpers.ts
@@ -34,6 +34,7 @@ export const navigateTablist = (e: React.KeyboardEvent): HTML
if (newIndex !== -1) {
e.preventDefault();
tabs[newIndex].focus();
+ tabs[newIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' });
return tabs[newIndex];
}
diff --git a/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx
index 49106493..4575e497 100644
--- a/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx
+++ b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx
@@ -1,15 +1,13 @@
import cn from 'classnames';
-import React from 'react';
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
-import { isBreakpointBelow, useBreakpoint } from '../../../../helpers';
+import { useScrollFade } 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 {
/**
@@ -33,9 +31,16 @@ export interface TabsListProps {
* @default 'show'
*/
printVisibility?: PrintProps['visibility'];
+ /**
+ * How to handle tab overflow when tabs don't fit in available space.
+ * - 'dropdown': Shows a dropdown button containing overflowing tabs (default)
+ * - 'scroll': Enables horizontal scrolling with a fade indicator
+ * @default 'dropdown'
+ */
+ overflowMode?: 'dropdown' | 'scroll';
}
-interface MobileDropdownItem {
+interface OverflowItem {
id: string;
label: React.ReactNode;
disabled?: boolean;
@@ -48,58 +53,121 @@ export const TabsList = (props: TabsListProps) => {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
printVisibility = 'show',
+ overflowMode = 'dropdown',
} = props;
const { getLabel } = useLabels();
const { currentTab, setCurrentTab } = useTabsContext();
- const breakpoint = useBreakpoint();
- const isMobile = isBreakpointBelow(breakpoint, 'md');
+ const wrapperRef = useRef(null);
+ const listRef = useRef(null);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const isOverflowingRef = useRef(false);
+ const naturalWidthRef = useRef(0);
+
+ isOverflowingRef.current = isOverflowing;
+
+ const {
+ scrollRef,
+ canScrollStart: canScrollLeft,
+ canScrollEnd: canScrollRight,
+ handleScroll,
+ } = useScrollFade({ direction: 'horizontal' });
+
+ const mergedListRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ (listRef as React.MutableRefObject).current = node;
+ if (overflowMode === 'scroll') {
+ scrollRef(node);
+ }
+ },
+ [overflowMode, scrollRef]
+ );
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[] = [];
+ const allItems = React.useMemo(() => {
+ const result: OverflowItem[] = [];
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 });
- }
+ 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;
+ const showMore = overflowMode === 'dropdown' && isOverflowing && allItems.length > 1;
+ const dropdownItems = allItems.filter((item) => item.id !== currentTab);
- // Filter out the currently selected tab from the mobile dropdown
- const dropdownItems = mobileItems.filter((item) => item.id !== currentTab);
-
- const handleMobileSelect = (id: string) => {
+ const handleMoreSelect = (id: string) => {
if (id) {
setCurrentTab(id);
}
};
+ // Capture natural width when all tabs are visible and check for overflow synchronously
+ useLayoutEffect(() => {
+ if (overflowMode !== 'dropdown') return;
+ const list = listRef.current;
+ if (!list || isOverflowingRef.current) return;
+
+ naturalWidthRef.current = list.scrollWidth;
+ if (list.scrollWidth > list.clientWidth && list.clientWidth > 0) {
+ setIsOverflowing(true);
+ }
+ }, [overflowMode, isOverflowing, childArray]);
+
+ // ResizeObserver for "dropdown" mode overflow detection
+ useEffect(() => {
+ if (overflowMode !== 'dropdown') return;
+ const wrapper = wrapperRef.current;
+ const list = listRef.current;
+ if (!wrapper || !list) return;
+
+ const checkOverflow = () => {
+ if (isOverflowingRef.current) {
+ if (naturalWidthRef.current <= wrapper.clientWidth) {
+ setIsOverflowing(false);
+ }
+ } else {
+ if (list.scrollWidth > list.clientWidth && list.clientWidth > 0) {
+ naturalWidthRef.current = list.scrollWidth;
+ setIsOverflowing(true);
+ }
+ }
+ };
+
+ const ro = new ResizeObserver(checkOverflow);
+ ro.observe(wrapper);
+ return () => ro.disconnect();
+ }, [overflowMode]);
+
return (
- {children}
+
+ {children}
+
{showMore && (
@@ -116,7 +184,7 @@ export const TabsList = (props: TabsListProps) => {
index={index}
active={currentTab === item.id}
disabled={item.disabled}
- onClick={() => handleMobileSelect(item.id)}
+ onClick={() => handleMoreSelect(item.id)}
>
{item.label}
diff --git a/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx b/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx
index 46b60a75..a9c4195d 100644
--- a/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx
+++ b/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx
@@ -67,7 +67,7 @@ export const TabsTrigger = (props: TabsTriggerProps) => {
onClick={handleClick}
onKeyDown={handleKeyDown}
>
- {icon && }
+ {icon && }
{children}
);
diff --git a/src/tedi/components/navigation/tabs/tabs.module.scss b/src/tedi/components/navigation/tabs/tabs.module.scss
index f3f3f2aa..6edecb75 100644
--- a/src/tedi/components/navigation/tabs/tabs.module.scss
+++ b/src/tedi/components/navigation/tabs/tabs.module.scss
@@ -1,5 +1,3 @@
-@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);
@@ -33,24 +31,57 @@
}
.tedi-tabs {
- &__list {
+ &__list-wrapper {
+ position: relative;
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;
+ &--scroll-fade-left::before,
+ &--scroll-fade-right::after {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 1;
+ width: 48px;
+ pointer-events: none;
+ content: '';
+ }
+
+ &--scroll-fade-left::before {
+ left: 0;
+ background: linear-gradient(to right, var(--tab-background) 10%, transparent 80%);
+ }
+
+ &--scroll-fade-right::after {
+ right: 0;
+ background: linear-gradient(to left, var(--tab-background) 10%, transparent 80%);
+ }
+ }
+
+ &__list {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
- .tedi-tabs__trigger {
- &:not(.tedi-tabs__trigger--selected) {
- display: none;
- }
+ &--overflow {
+ .tedi-tabs__trigger:not(.tedi-tabs__trigger--selected) {
+ display: none;
+ }
- &--selected {
- flex-grow: 1;
- }
+ .tedi-tabs__trigger--selected {
+ flex-grow: 1;
+ }
+ }
+
+ &--scroll {
+ overflow-x: auto;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
}
}
}
@@ -84,17 +115,12 @@
}
&__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;
- }
+ display: flex;
+ flex-shrink: 0;
}
&__more-btn {
diff --git a/src/tedi/components/navigation/tabs/tabs.spec.tsx b/src/tedi/components/navigation/tabs/tabs.spec.tsx
index 4887686d..c8098ed3 100644
--- a/src/tedi/components/navigation/tabs/tabs.spec.tsx
+++ b/src/tedi/components/navigation/tabs/tabs.spec.tsx
@@ -12,6 +12,20 @@ jest.mock('../../../providers/label-provider', () => ({
})),
}));
+let resizeCallback: (() => void) | null = null;
+
+beforeAll(() => {
+ global.ResizeObserver = jest.fn().mockImplementation((cb: ResizeObserverCallback) => ({
+ observe: jest.fn(() => {
+ resizeCallback = () => cb([], {} as ResizeObserver);
+ }),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(() => {
+ resizeCallback = null;
+ }),
+ }));
+});
+
const renderTabs = (props?: Partial) => {
return render(
@@ -29,37 +43,33 @@ const renderTabs = (props?: Partial) => {
);
};
-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 simulateOverflow = () => {
+ const listEl = screen.getByRole('tablist');
+ Object.defineProperty(listEl, 'scrollWidth', { value: 500, configurable: true });
+ Object.defineProperty(listEl, 'clientWidth', { value: 300, configurable: true });
-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(),
- }));
+ const wrapper = listEl.parentElement!;
+ Object.defineProperty(wrapper, 'clientWidth', { value: 300, configurable: true });
+
+ act(() => {
+ resizeCallback?.();
+ });
};
-describe('Tabs component', () => {
- beforeEach(() => {
- setupDesktopMode();
+const simulateNoOverflow = () => {
+ const listEl = screen.getByRole('tablist');
+ Object.defineProperty(listEl, 'scrollWidth', { value: 300, configurable: true });
+ Object.defineProperty(listEl, 'clientWidth', { value: 600, configurable: true });
+
+ const wrapper = listEl.parentElement!;
+ Object.defineProperty(wrapper, 'clientWidth', { value: 600, configurable: true });
+
+ act(() => {
+ resizeCallback?.();
});
+};
+describe('Tabs component', () => {
it('renders the tablist with correct role', () => {
renderTabs();
expect(screen.getByRole('tablist')).toBeInTheDocument();
@@ -175,7 +185,7 @@ describe('Tabs component', () => {
it('renders with custom className', () => {
renderTabs({ className: 'custom-class' });
- const container = screen.getByRole('tablist').parentElement;
+ const container = screen.getByRole('tablist').closest('[data-name="tabs"]');
expect(container).toHaveClass('custom-class');
});
@@ -195,23 +205,16 @@ describe('Tabs component', () => {
expect(screen.getByRole('tabpanel')).not.toHaveAttribute('tabIndex');
});
- it('does not render "More" button on desktop', () => {
+ it('does not render "More" button when tabs do not overflow', () => {
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', () => {
+describe('Tabs overflow (more mode)', () => {
+ it('renders "More" button when tabs overflow', () => {
renderTabs();
+ simulateOverflow();
expect(screen.getByText('More')).toBeInTheDocument();
});
@@ -224,11 +227,22 @@ describe('Tabs mobile overflow', () => {
Content
);
+
+ const listEl = screen.getByRole('tablist');
+ Object.defineProperty(listEl, 'scrollWidth', { value: 500, configurable: true });
+ Object.defineProperty(listEl, 'clientWidth', { value: 300, configurable: true });
+ const wrapper = listEl.parentElement!;
+ Object.defineProperty(wrapper, 'clientWidth', { value: 300, configurable: true });
+ act(() => {
+ resizeCallback?.();
+ });
+
expect(screen.queryByText('More')).not.toBeInTheDocument();
});
it('opens dropdown with non-selected tabs', () => {
renderTabs();
+ simulateOverflow();
fireEvent.click(screen.getByText('More'));
const menu = screen.getByRole('menu');
@@ -238,6 +252,7 @@ describe('Tabs mobile overflow', () => {
it('closes dropdown on toggle', () => {
renderTabs();
+ simulateOverflow();
const moreBtn = screen.getByText('More');
fireEvent.click(moreBtn);
@@ -249,6 +264,7 @@ describe('Tabs mobile overflow', () => {
it('selects a tab from dropdown and closes it', () => {
renderTabs();
+ simulateOverflow();
fireEvent.click(screen.getByText('More'));
const menuItems = screen.getAllByRole('menuitem');
@@ -261,6 +277,7 @@ describe('Tabs mobile overflow', () => {
it('closes dropdown on Escape key', () => {
renderTabs();
+ simulateOverflow();
fireEvent.click(screen.getByText('More'));
expect(screen.getByRole('menu')).toBeInTheDocument();
@@ -270,146 +287,36 @@ describe('Tabs mobile overflow', () => {
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
-
- );
+ it('removes overflow when container grows', () => {
+ renderTabs();
+ simulateOverflow();
+ expect(screen.getByText('More')).toBeInTheDocument();
- 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');
+ simulateNoOverflow();
+ expect(screen.queryByText('More')).not.toBeInTheDocument();
});
});
-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', () => {
+describe('Tabs overflow (scroll mode)', () => {
+ it('renders with scroll mode', () => {
render(
-
+
Tab 1
-
- Sub 1
-
- Sub 2
-
-
+ Tab 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');
+ const tablist = screen.getByRole('tablist');
+ expect(tablist).toBeInTheDocument();
+ expect(screen.queryByText('More')).not.toBeInTheDocument();
});
});
describe('TabsContent without id', () => {
- beforeEach(() => {
- setupDesktopMode();
- });
-
it('always renders when id is omitted', () => {
render(
diff --git a/src/tedi/components/navigation/tabs/tabs.stories.tsx b/src/tedi/components/navigation/tabs/tabs.stories.tsx
index 352275b2..112a2b20 100644
--- a/src/tedi/components/navigation/tabs/tabs.stories.tsx
+++ b/src/tedi/components/navigation/tabs/tabs.stories.tsx
@@ -1,7 +1,8 @@
import { Meta, StoryFn, StoryObj } from '@storybook/react';
-import React, { useState } from 'react';
+import { useState } from 'react';
import { Text } from '../../base/typography/text/text';
+import { CardContent } from '../../cards/card/card-content/card-content';
import { Col, Row } from '../../layout/grid';
import { VerticalSpacing } from '../../layout/vertical-spacing';
import { StatusBadge } from '../../tags/status-badge/status-badge';
@@ -22,8 +23,6 @@ const meta: Meta = {
'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: {
@@ -36,6 +35,9 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
+const contentText =
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas turpis odio, iaculis quis sodales at, placerat vitae risus. Integer hendrerit ex eget nisl euismod pharetra.';
+
const stateArray = ['Default', 'Hover', 'Active', 'Focus', 'Selected'];
interface TemplateStateProps extends TabsProps {
@@ -61,7 +63,7 @@ const TemplateColumnWithStates: StoryFn = (args) => {
- Toimingud
+ Health timeline
@@ -75,23 +77,25 @@ const TemplateColumnWithStates: StoryFn = (args) => {
export const Default: Story = {
render: () => (
-
- Toimingud
- Dokumendid
- Esindusõigused
- Kontaktisikud
+
+ Health timeline
+ Course of diseases
+ Medication history
- Toimingud content
+
+ {contentText}
+
- Dokumendid content
+
+ {contentText}
+
- Esindusõigused content
-
-
- Kontaktisikud content
+
+ {contentText}
+
),
@@ -101,30 +105,22 @@ export const WithIcons: Story = {
render: () => (
-
- Minu andmed
-
-
- Dokumendid
+
+ Table
-
- Ligipääs
-
-
- Seaded
+
+ Grid
- Minu andmed content
+
+ {contentText}
+
- Dokumendid content
-
-
- Ligipääs content
-
-
- Seaded content
+
+ {contentText}
+
),
@@ -135,24 +131,30 @@ export const WithStatusBadge: Story = {
- Toimingud Esitatud
+ Health timeline Submitted
- Lugemata teated
+ Unread messages
- Esindusõigused
+ Medication history
- Toimingud content
+
+ {contentText}
+
- Lugemata teated content
+
+ {contentText}
+
- Esindusõigused content
+
+ {contentText}
+
),
@@ -183,18 +185,18 @@ export const Controlled: Story = {
- Toimingud
- Dokumendid
- Esindusõigused
+ Health timeline
+ Course of diseases
+ Medication history
- Toimingud content
+ {contentText}
- Dokumendid content
+ {contentText}
- Esindusõigused content
+ {contentText}
@@ -202,56 +204,100 @@ export const Controlled: Story = {
},
};
-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
+ Health timeline
+ Course of diseases
- Esindusõigused
+ Medication history
- Toimingud content
+
+ {contentText}
+
- Dokumendid content
+
+ {contentText}
+
- Esindusõigused content
+
+ {contentText}
+
),
};
+
+export const OverflowBehavior: Story = {
+ render: () => (
+
+ Dropdown (default)
+
+
+
+ Health timeline
+ Course of diseases
+ Medication history
+ Declarations of intent
+
+
+
+ {contentText}
+
+
+
+
+ {contentText}
+
+
+
+
+ {contentText}
+
+
+
+
+ {contentText}
+
+
+
+
+ Horizontal scroll
+
+
+
+ Health timeline
+ Course of diseases
+ Medication history
+ Declarations of intent
+
+
+
+ {contentText}
+
+
+
+
+ {contentText}
+
+
+
+
+ {contentText}
+
+
+
+
+ {contentText}
+
+
+
+
+
+ ),
+};
diff --git a/src/tedi/components/navigation/tabs/tabs.tsx b/src/tedi/components/navigation/tabs/tabs.tsx
index 06ff9736..c5703623 100644
--- a/src/tedi/components/navigation/tabs/tabs.tsx
+++ b/src/tedi/components/navigation/tabs/tabs.tsx
@@ -4,7 +4,6 @@ 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';
@@ -61,6 +60,5 @@ Tabs.displayName = 'Tabs';
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Content = TabsContent;
-Tabs.Dropdown = TabsDropdown;
export default Tabs;
diff --git a/src/tedi/helpers/hooks/use-scroll-fade.spec.tsx b/src/tedi/helpers/hooks/use-scroll-fade.spec.tsx
new file mode 100644
index 00000000..3a8082a0
--- /dev/null
+++ b/src/tedi/helpers/hooks/use-scroll-fade.spec.tsx
@@ -0,0 +1,173 @@
+import { act, render, screen } from '@testing-library/react';
+import React from 'react';
+
+import { useScrollFade, UseScrollFadeOptions } from './use-scroll-fade';
+
+let resizeCallbacks: Map void>;
+
+beforeEach(() => {
+ resizeCallbacks = new Map();
+ global.ResizeObserver = jest.fn().mockImplementation((cb: ResizeObserverCallback) => ({
+ observe: jest.fn((el: Element) => {
+ resizeCallbacks.set(el, () => cb([], {} as ResizeObserver));
+ }),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(() => {
+ resizeCallbacks.clear();
+ }),
+ }));
+});
+
+const TestComponent = (props: UseScrollFadeOptions & { testId?: string }) => {
+ const { testId = 'scrollable', ...options } = props;
+ const { scrollRef, canScrollStart, canScrollEnd, handleScroll } = useScrollFade(options);
+
+ return (
+
+
+ Content
+
+
{String(canScrollStart)}
+
{String(canScrollEnd)}
+
+ );
+};
+
+const mockElementScroll = (
+ el: HTMLElement,
+ values: {
+ scrollTop?: number;
+ scrollHeight?: number;
+ clientHeight?: number;
+ scrollLeft?: number;
+ scrollWidth?: number;
+ clientWidth?: number;
+ }
+) => {
+ for (const [key, value] of Object.entries(values)) {
+ Object.defineProperty(el, key, { value, configurable: true });
+ }
+};
+
+describe('useScrollFade', () => {
+ describe('vertical (default)', () => {
+ it('returns false for both when content does not overflow', () => {
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollTop: 0, scrollHeight: 200, clientHeight: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(screen.getByTestId('start')).toHaveTextContent('false');
+ expect(screen.getByTestId('end')).toHaveTextContent('false');
+ });
+
+ it('shows end fade when at top with overflow', () => {
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollTop: 0, scrollHeight: 500, clientHeight: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(screen.getByTestId('start')).toHaveTextContent('false');
+ expect(screen.getByTestId('end')).toHaveTextContent('true');
+ });
+
+ it('shows both fades when scrolled to middle', () => {
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollTop: 100, scrollHeight: 500, clientHeight: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(screen.getByTestId('start')).toHaveTextContent('true');
+ expect(screen.getByTestId('end')).toHaveTextContent('true');
+ });
+
+ it('shows only start fade when scrolled to end', () => {
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollTop: 300, scrollHeight: 500, clientHeight: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(screen.getByTestId('start')).toHaveTextContent('true');
+ expect(screen.getByTestId('end')).toHaveTextContent('false');
+ });
+
+ it('calls onScrollToStart when at start', () => {
+ const onScrollToStart = jest.fn();
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollTop: 0, scrollHeight: 500, clientHeight: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(onScrollToStart).toHaveBeenCalled();
+ });
+
+ it('calls onScrollToEnd when at end', () => {
+ const onScrollToEnd = jest.fn();
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollTop: 300, scrollHeight: 500, clientHeight: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(onScrollToEnd).toHaveBeenCalled();
+ });
+ });
+
+ describe('horizontal', () => {
+ it('shows end fade when at start with overflow', () => {
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollLeft: 0, scrollWidth: 500, clientWidth: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(screen.getByTestId('start')).toHaveTextContent('false');
+ expect(screen.getByTestId('end')).toHaveTextContent('true');
+ });
+
+ it('shows both fades when scrolled to middle', () => {
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollLeft: 100, scrollWidth: 500, clientWidth: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(screen.getByTestId('start')).toHaveTextContent('true');
+ expect(screen.getByTestId('end')).toHaveTextContent('true');
+ });
+
+ it('shows only start fade when scrolled to end', () => {
+ render();
+ const el = screen.getByTestId('scrollable');
+ mockElementScroll(el, { scrollLeft: 300, scrollWidth: 500, clientWidth: 200 });
+
+ act(() => {
+ resizeCallbacks.forEach((cb) => cb());
+ });
+
+ expect(screen.getByTestId('start')).toHaveTextContent('true');
+ expect(screen.getByTestId('end')).toHaveTextContent('false');
+ });
+ });
+});
diff --git a/src/tedi/helpers/hooks/use-scroll-fade.ts b/src/tedi/helpers/hooks/use-scroll-fade.ts
new file mode 100644
index 00000000..db00642b
--- /dev/null
+++ b/src/tedi/helpers/hooks/use-scroll-fade.ts
@@ -0,0 +1,115 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+export interface UseScrollFadeOptions {
+ /**
+ * Scroll direction to track
+ * @default 'vertical'
+ */
+ direction?: 'vertical' | 'horizontal';
+ /**
+ * Called when element is scrolled to start (top or left)
+ */
+ onScrollToStart?: () => void;
+ /**
+ * Called when element is scrolled to end (bottom or right)
+ */
+ onScrollToEnd?: () => void;
+}
+
+export interface UseScrollFadeReturn {
+ /**
+ * Callback ref to attach to the scrollable element.
+ * Sets up ResizeObserver and initial measurement.
+ */
+ scrollRef: React.RefCallback;
+ /**
+ * Whether content can be scrolled towards the start (top or left)
+ */
+ canScrollStart: boolean;
+ /**
+ * Whether content can be scrolled towards the end (bottom or right)
+ */
+ canScrollEnd: boolean;
+ /**
+ * Scroll event handler to attach to the scrollable element's onScroll
+ */
+ handleScroll: React.UIEventHandler;
+}
+
+export const useScrollFade = (options: UseScrollFadeOptions = {}): UseScrollFadeReturn => {
+ const { direction = 'vertical', onScrollToStart, onScrollToEnd } = options;
+
+ const [canScrollStart, setCanScrollStart] = useState(false);
+ const [canScrollEnd, setCanScrollEnd] = useState(false);
+ const nodeRef = useRef(null);
+ const observerRef = useRef(null);
+ const onScrollToStartRef = useRef(onScrollToStart);
+ const onScrollToEndRef = useRef(onScrollToEnd);
+
+ onScrollToStartRef.current = onScrollToStart;
+ onScrollToEndRef.current = onScrollToEnd;
+
+ const checkFade = useCallback(
+ (el: HTMLElement) => {
+ let scrollPos: number;
+ let scrollSize: number;
+ let clientSize: number;
+
+ if (direction === 'horizontal') {
+ scrollPos = el.scrollLeft;
+ scrollSize = el.scrollWidth;
+ clientSize = el.clientWidth;
+ } else {
+ scrollPos = el.scrollTop;
+ scrollSize = el.scrollHeight;
+ clientSize = el.clientHeight;
+ }
+
+ const atStart = scrollPos <= 1;
+ const atEnd = Math.abs(scrollSize - scrollPos - clientSize) <= 1;
+
+ setCanScrollStart(!atStart);
+ setCanScrollEnd(!atEnd);
+
+ if (atStart) onScrollToStartRef.current?.();
+ if (atEnd) onScrollToEndRef.current?.();
+ },
+ [direction]
+ );
+
+ const handleScroll = useCallback(
+ (e: React.UIEvent) => {
+ checkFade(e.currentTarget);
+ },
+ [checkFade]
+ );
+
+ const scrollRef = useCallback(
+ (node: HTMLElement | null) => {
+ if (observerRef.current) {
+ observerRef.current.disconnect();
+ observerRef.current = null;
+ }
+
+ nodeRef.current = node;
+
+ if (node) {
+ checkFade(node);
+
+ observerRef.current = new ResizeObserver(() => {
+ checkFade(node);
+ });
+ observerRef.current.observe(node);
+ }
+ },
+ [checkFade]
+ );
+
+ useEffect(() => {
+ return () => {
+ observerRef.current?.disconnect();
+ };
+ }, []);
+
+ return { scrollRef, canScrollStart, canScrollEnd, handleScroll };
+};
diff --git a/src/tedi/helpers/index.ts b/src/tedi/helpers/index.ts
index c239ce85..99051341 100644
--- a/src/tedi/helpers/index.ts
+++ b/src/tedi/helpers/index.ts
@@ -7,3 +7,4 @@ export * from './hooks/use-scroll';
export * from './hooks/use-is-touch-device';
export * from './hooks/use-file-upload';
export * from './hooks/use-what-input';
+export * from './hooks/use-scroll-fade';