diff --git a/package-lock.json b/package-lock.json
index 6268efb2..871217eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -73,6 +73,7 @@
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.3.4",
+ "baseline-browser-mapping": "^2.10.0",
"chromatic": "^13.3.4",
"cross-env": "^7.0.3",
"dompurify": "^3.3.0",
@@ -10204,13 +10205,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.8.15",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz",
- "integrity": "sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==",
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.js"
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
"node_modules/before-after-hook": {
diff --git a/package.json b/package.json
index 33819bf5..7f320534 100644
--- a/package.json
+++ b/package.json
@@ -107,6 +107,7 @@
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.3.4",
+ "baseline-browser-mapping": "^2.10.0",
"chromatic": "^13.3.4",
"cross-env": "^7.0.3",
"dompurify": "^3.3.0",
diff --git a/src/community/components/dropdown/dropdown.stories.tsx b/src/community/components/dropdown/dropdown.stories.tsx
index aeaa016c..96e880f4 100644
--- a/src/community/components/dropdown/dropdown.stories.tsx
+++ b/src/community/components/dropdown/dropdown.stories.tsx
@@ -5,6 +5,11 @@ import { Dropdown, DropdownItem, DropdownProps } from './dropdown';
export default {
component: Dropdown,
title: 'Community/Dropdown',
+ parameters: {
+ status: {
+ type: ['deprecated', 'ExistsInTediReady'],
+ },
+ },
} as Meta;
const items: DropdownItem[] = [
diff --git a/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx
new file mode 100644
index 00000000..7cdaf40d
--- /dev/null
+++ b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx
@@ -0,0 +1,38 @@
+import { render } from '@testing-library/react';
+
+import { DropdownContent } from './dropdown-content';
+
+const mockSetContent = jest.fn();
+
+jest.mock('../dropdown-context', () => ({
+ useDropdownContext: () => ({
+ setContent: mockSetContent,
+ }),
+}));
+
+describe('DropdownContent', () => {
+ beforeEach(() => {
+ mockSetContent.mockClear();
+ });
+
+ it('sets content on mount', () => {
+ render(
+
+ Menu content
+
+ );
+
+ expect(mockSetContent).toHaveBeenCalledWith(expect.objectContaining({ props: { children: 'Menu content' } }));
+ });
+
+ it('clears content on unmount', () => {
+ const { unmount } = render(
+
+ Menu content
+
+ );
+
+ unmount();
+ expect(mockSetContent).toHaveBeenLastCalledWith(null);
+ });
+});
diff --git a/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx
new file mode 100644
index 00000000..763a1d94
--- /dev/null
+++ b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx
@@ -0,0 +1,14 @@
+import { ReactNode, useEffect } from 'react';
+
+import { useDropdownContext } from '../dropdown-context';
+
+export const DropdownContent = ({ children }: { children: ReactNode }) => {
+ const { setContent } = useDropdownContext();
+
+ useEffect(() => {
+ setContent(children);
+ return () => setContent(null);
+ }, [children, setContent]);
+
+ return null;
+};
diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx
new file mode 100644
index 00000000..359ecd9b
--- /dev/null
+++ b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx
@@ -0,0 +1,115 @@
+import { render, renderHook } from '@testing-library/react';
+import React from 'react';
+
+import { DropdownContext, type DropdownContextValue, useDropdownContext } from './dropdown-context';
+
+jest.mock('@floating-ui/react', () => ({
+ useFloating: jest.fn(() => ({
+ refs: {
+ setReference: jest.fn(),
+ setFloating: jest.fn(),
+ reference: { current: null },
+ floating: { current: null },
+ },
+ x: 0,
+ y: 0,
+ strategy: 'absolute',
+ placement: 'bottom-start',
+ })),
+ useInteractions: jest.fn(() => ({
+ getReferenceProps: jest.fn((userProps) => ({ ...userProps })),
+ getFloatingProps: jest.fn((userProps) => ({ ...userProps })),
+ getItemProps: jest.fn((userProps) => ({ ...userProps })),
+ })),
+}));
+
+describe('DropdownContext + useDropdownContext', () => {
+ const mockSetOpen = jest.fn();
+ const mockSetActiveIndex = jest.fn();
+ const mockSetContent = jest.fn();
+
+ const mockContextValue: DropdownContextValue = {
+ open: true,
+ setOpen: mockSetOpen,
+ refs: {
+ setReference: jest.fn(),
+ setFloating: jest.fn(),
+ reference: { current: null },
+ floating: { current: null },
+ domReference: { current: null },
+ setPositionReference: jest.fn(),
+ },
+ getReferenceProps: jest.fn(),
+ getFloatingProps: jest.fn(),
+ getItemProps: jest.fn(),
+ listItemsRef: { current: [] },
+ activeIndex: 2,
+ setActiveIndex: mockSetActiveIndex,
+ placement: 'bottom-start',
+ content:
Mock content
,
+ setContent: mockSetContent,
+ divided: true,
+ variant: 'tree',
+ };
+
+ const wrapperWithContext = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('throws error when useDropdownContext is used outside DropdownContext', () => {
+ expect(() => {
+ renderHook(() => useDropdownContext());
+ }).toThrow('Dropdown components must be used within ');
+ });
+
+ it('returns context value when used inside provider', () => {
+ const { result } = renderHook(() => useDropdownContext(), {
+ wrapper: wrapperWithContext,
+ });
+
+ expect(result.current).toEqual(mockContextValue);
+ expect(result.current?.open).toBe(true);
+ expect(result.current?.activeIndex).toBe(2);
+ expect(result.current?.variant).toBe('tree');
+ expect(result.current?.content).toEqual(Mock content
);
+ });
+
+ it('does not throw when context is provided (smoke test)', () => {
+ const TestConsumer = () => {
+ const ctx = useDropdownContext();
+ return {ctx?.open ? 'Open' : 'Closed'}
;
+ };
+
+ const { getByTestId } = render(, {
+ wrapper: wrapperWithContext,
+ });
+
+ expect(getByTestId('consumer')).toHaveTextContent('Open');
+ });
+
+ it('context value has all expected keys', () => {
+ const { result } = renderHook(() => useDropdownContext(), {
+ wrapper: wrapperWithContext,
+ });
+
+ const ctx = result.current;
+ expect(ctx).toHaveProperty('open');
+ expect(ctx).toHaveProperty('setOpen');
+ expect(ctx).toHaveProperty('refs');
+ expect(ctx).toHaveProperty('getReferenceProps');
+ expect(ctx).toHaveProperty('getFloatingProps');
+ expect(ctx).toHaveProperty('getItemProps');
+ expect(ctx).toHaveProperty('listItemsRef');
+ expect(ctx).toHaveProperty('activeIndex');
+ expect(ctx).toHaveProperty('setActiveIndex');
+ expect(ctx).toHaveProperty('placement');
+ expect(ctx).toHaveProperty('content');
+ expect(ctx).toHaveProperty('setContent');
+ expect(ctx).toHaveProperty('divided');
+ expect(ctx).toHaveProperty('variant');
+ });
+});
diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.tsx
new file mode 100644
index 00000000..f1a456fc
--- /dev/null
+++ b/src/tedi/components/overlays/dropdown/dropdown-context.tsx
@@ -0,0 +1,29 @@
+import { useFloating, useInteractions } from '@floating-ui/react';
+import React, { useContext } from 'react';
+
+export type DropdownContextValue = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ refs: ReturnType['refs'];
+ getReferenceProps: ReturnType['getReferenceProps'];
+ getFloatingProps: ReturnType['getFloatingProps'];
+ getItemProps: ReturnType['getItemProps'];
+ listItemsRef: React.MutableRefObject>;
+ activeIndex: number | null;
+ setActiveIndex: (index: number | null) => void;
+ placement?: string;
+ content: React.ReactNode;
+ setContent: (content: React.ReactNode) => void;
+ divided?: boolean;
+ variant?: 'default' | 'tree';
+};
+
+export const DropdownContext = React.createContext(null);
+
+export const useDropdownContext = () => {
+ const ctx = useContext(DropdownContext);
+ if (!ctx) {
+ throw new Error('Dropdown components must be used within ');
+ }
+ return ctx;
+};
diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss
new file mode 100644
index 00000000..2862bebc
--- /dev/null
+++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss
@@ -0,0 +1,132 @@
+@use '@tedi-design-system/core/mixins';
+
+:root {
+ --tree-trunk-width: 2px;
+ --tree-branch-width: 12px;
+ --tree-bullet-size: 8px;
+ --tree-line-color: var(--navigation-vertical-tree-neutral-default);
+ --tree-indent-base: 0.75rem;
+}
+
+.tedi-dropdown__item {
+ @include mixins.button-reset;
+
+ position: relative;
+ padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
+ padding-left: var(--dropdown-indent, var(--dropdown-item-padding-x));
+ color: var(--dropdown-item-default-text);
+ text-align: left;
+ border-radius: 0;
+ transition: all 0.2s ease;
+
+ &:first-child {
+ border-radius: var(--form-select-area-radius) var(--form-select-area-radius) 0 0;
+ }
+
+ &:last-child {
+ border-radius: 0 0 var(--form-select-area-radius) var(--form-select-area-radius);
+ }
+
+ &--active {
+ color: var(--dropdown-item-active-text);
+ background-color: var(--dropdown-item-active-background);
+
+ p,
+ label {
+ color: var(--dropdown-item-active-text);
+ }
+ }
+
+ &--disabled {
+ color: var(--color-text-disabled);
+ pointer-events: none;
+ background-color: var(--color-bg-disabled);
+ }
+
+ &--divided {
+ border-bottom: 1px solid var(--general-border-primary);
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ &--tree-item {
+ position: relative;
+
+ &::before {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: calc(((var(--dropdown-item-padding-x) / 2) + var(--dropdown-item-padding-x)));
+ width: var(--tree-trunk-width);
+ content: '';
+ background-color: var(--navigation-vertical-tree-neutral-default);
+ border-radius: 0;
+ }
+
+ &::after {
+ position: absolute;
+ top: 20px;
+ bottom: 0;
+ left: calc(((var(--dropdown-item-padding-x) / 2) + var(--dropdown-item-padding-x)));
+ width: var(--tree-branch-width);
+ height: var(--tree-trunk-width);
+ content: '';
+ background-color: var(--navigation-vertical-tree-neutral-default);
+ border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0;
+ }
+
+ &:last-child::before {
+ height: calc(50% + var(--tree-trunk-width));
+ }
+ }
+
+ &--tree-parent {
+ &::before {
+ top: 50%;
+ }
+
+ &::after {
+ position: absolute;
+ top: 50%;
+ left: calc(
+ ((var(--dropdown-item-padding-x) / 2) + var(--dropdown-item-padding-x)) - (var(--tree-bullet-size) / 3)
+ );
+ width: var(--tree-bullet-size);
+ height: var(--tree-bullet-size);
+ content: '';
+ background-color: var(--tree-line-color);
+ border-radius: 100%;
+ transform: translateY(-50%);
+ }
+ }
+
+ &:hover {
+ color: var(--dropdown-item-hover-text);
+ cursor: pointer;
+ background-color: var(--dropdown-item-hover-background);
+ outline: 0;
+
+ p,
+ label {
+ color: var(--dropdown-item-hover-text);
+ }
+ }
+
+ &:focus-visible {
+ &:not(.tedi-dropdown__item--disabled) {
+ cursor: pointer;
+ outline: 0;
+ box-shadow: 0 0 0 1px var(--tedi-neutral-100), 0 0 0 3px var(--general-surface-selected);
+ }
+ }
+
+ &--indent {
+ padding-left: calc(var(--dropdown-indent) + var(--dropdown-item-padding-x));
+
+ &.tedi-dropdown__item--tree-item {
+ padding-left: calc(var(--dropdown-item-padding-x) + var(--tree-indent-base) + var(--dropdown-indent));
+ }
+ }
+}
diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx
new file mode 100644
index 00000000..e1ee3b8f
--- /dev/null
+++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx
@@ -0,0 +1,228 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import { UnknownType } from '../../../../types/commonTypes';
+import { DropdownItem } from './dropdown-item';
+
+const mockSetOpen = jest.fn();
+const mockOnClick = jest.fn();
+
+jest.mock('../dropdown-context', () => ({
+ useDropdownContext: () => ({
+ getItemProps: (props: UnknownType) => props,
+ listItemsRef: { current: [] },
+ setOpen: mockSetOpen,
+ activeIndex: 0,
+ divided: false,
+ variant: 'default',
+ }),
+}));
+
+describe('DropdownItem', () => {
+ beforeEach(() => {
+ mockSetOpen.mockClear();
+ mockOnClick.mockClear();
+ });
+
+ it('calls onClick and closes dropdown on click', () => {
+ const { getByText } = render(
+
+ Item
+
+ );
+ fireEvent.click(getByText('Item'));
+ expect(mockOnClick).toHaveBeenCalled();
+ expect(mockSetOpen).toHaveBeenCalledWith(false);
+ });
+
+ it('does not call onClick when disabled', () => {
+ const { getByText } = render(
+
+ Item
+
+ );
+ fireEvent.click(getByText('Item'));
+ expect(mockOnClick).not.toHaveBeenCalled();
+ });
+
+ it('handles Enter key activation', () => {
+ const { getByText } = render(
+
+ Item
+
+ );
+ const item = getByText('Item');
+ fireEvent.keyDown(item, { key: 'Enter' });
+ expect(mockOnClick).toHaveBeenCalled();
+ expect(mockSetOpen).toHaveBeenCalledWith(false);
+ });
+
+ it('renders div when asChild=true', () => {
+ const { getByText } = render(
+
+ Child
+
+ );
+ expect(getByText('Child').tagName).toBe('SPAN');
+ });
+
+ it('renders children directly inside div when asChild=true', () => {
+ render(
+
+
+
+
+ );
+ const div = screen.getByLabelText('Custom label').closest('div');
+ expect(div).toBeInTheDocument();
+ expect(div?.tagName).toBe('DIV');
+ });
+
+ it('does NOT close dropdown when clicking inner input and closeOnSelect=false', () => {
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByTestId('radio'));
+ expect(mockSetOpen).not.toHaveBeenCalled();
+ });
+
+ it('clicks inner input when wrapper is clicked (asChild + closeOnSelect=false)', () => {
+ const handleChange = jest.fn();
+ render(
+
+
+ Label text
+
+ );
+ const wrapper = screen.getByText('Label text').closest('div')!;
+ fireEvent.click(wrapper);
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ expect(mockSetOpen).not.toHaveBeenCalled();
+ });
+
+ it('applies indentation styles when indent is provided', () => {
+ render(
+
+ Indented
+
+ );
+ const item = screen.getByText('Indented').closest('button');
+ expect(item).toHaveStyle('--dropdown-indent: 2rem');
+ expect(item).toHaveStyle('--dropdown-indent-level: 2');
+ });
+
+ it('does not register ref when index is undefined', () => {
+ expect(() => render(No index)).not.toThrow();
+ });
+
+ it('does not close dropdown when closeOnSelect=false (non-asChild)', () => {
+ render(
+
+ Item
+
+ );
+ fireEvent.click(screen.getByText('Item'));
+ expect(mockOnClick).toHaveBeenCalled();
+ expect(mockSetOpen).not.toHaveBeenCalled();
+ });
+
+ it('clicks inner checkbox and does not close when asChild + closeOnSelect=false', () => {
+ const handleChange = jest.fn();
+ render(
+
+
+ Label text
+
+ );
+ const wrapper = screen.getByText('Label text').closest('div')!;
+ fireEvent.click(wrapper);
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ expect(mockSetOpen).not.toHaveBeenCalled();
+ });
+
+ it('clicks inner radio on Enter key when asChild', () => {
+ const handleChange = jest.fn();
+ render(
+
+
+ Radio label
+
+ );
+ const wrapper = screen.getByText('Radio label').closest('div')!;
+ fireEvent.keyDown(wrapper, { key: 'Enter' });
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('triggers handleClick on non-asChild', () => {
+ const handle = jest.fn();
+ render(
+
+ Clickable
+
+ );
+
+ const item = screen.getByText('Clickable');
+ fireEvent.click(item); // triggers handleClick
+ expect(handle).toHaveBeenCalledTimes(1);
+ });
+
+ it('triggers handleKeyDown on space key', () => {
+ const handle = jest.fn();
+ render(
+
+ SpaceItem
+
+ );
+
+ const item = screen.getByText('SpaceItem');
+ fireEvent.keyDown(item, { key: ' ' }); // trigger handleKeyDown for space
+ expect(handle).toHaveBeenCalledTimes(1);
+ });
+
+ it('triggers inner input click when enabled (asChild)', () => {
+ const inputChange = jest.fn();
+ render(
+
+
+ Enabled input
+
+ );
+
+ const wrapper = screen.getByText('Enabled input').closest('div')!;
+ fireEvent.click(wrapper);
+ fireEvent.keyDown(wrapper, { key: 'Enter' });
+
+ expect(inputChange).toHaveBeenCalledTimes(2);
+ });
+
+ it('calls onClick when asChild=true without inner input', () => {
+ const handle = jest.fn();
+
+ render(
+
+ Plain child
+
+ );
+
+ const wrapper = screen.getByText('Plain child').closest('div')!;
+ fireEvent.click(wrapper);
+
+ expect(handle).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClick on Enter when asChild=true and no input', () => {
+ const handle = jest.fn();
+
+ render(
+
+ Key child
+
+ );
+
+ const wrapper = screen.getByText('Key child').closest('div')!;
+ fireEvent.keyDown(wrapper, { key: 'Enter' });
+
+ expect(handle).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx
new file mode 100644
index 00000000..7d526137
--- /dev/null
+++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx
@@ -0,0 +1,172 @@
+import cn from 'classnames';
+
+import { useDropdownContext } from '../dropdown-context';
+import styles from './dropdown-item.module.scss';
+
+export type DropdownItemProps = {
+ /**
+ * The content of the menu item (text, icons, checkbox, etc.)
+ */
+ children: React.ReactNode;
+ /**
+ * Called when the item is activated (mouse click or Enter/Space key).
+ * Receives either a MouseEvent or KeyboardEvent.
+ */
+ onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void;
+ /**
+ * Disables the item — prevents interaction and applies disabled styling.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * Highlights the item visually (e.g. selected language, current sort option).
+ * Does **not** affect behavior — only styling.
+ *
+ * @default false
+ */
+ active?: boolean;
+ /**
+ * Required when using keyboard navigation (ArrowUp/ArrowDown).
+ * Must be a unique, sequential number (0, 1, 2, ...) for each item in the list.
+ * When omitted, the item won't be keyboard-focusable.
+ */
+ index?: number;
+ /**
+ * Indentation level (in rem units). Useful for nested / hierarchical menus.
+ * Example: `indent={1}` → adds ~1rem left padding
+ *
+ * @default 0
+ */
+ indent?: number;
+ /**
+ * When `true`, renders a plain `` instead of a `