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 ` + + ); + + fireEvent.click(getByText('Open')); + expect(mockGetReferenceProps).toHaveBeenCalled(); + }); + + it('passes ref to setReference', () => { + render( + + + + ); + + const refCall = mockGetReferenceProps.mock.calls[0][0]; + expect(refCall.ref).toBe(mockSetReference); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx new file mode 100644 index 00000000..d98e32ca --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx @@ -0,0 +1,21 @@ +import { cloneElement, ReactElement } from 'react'; + +import { useDropdownContext } from '../dropdown-context'; + +export type DropdownTriggerProps = { + /** + * The content of the trigger item (button, icon, etc) + */ + children: ReactElement; +}; + +export const DropdownTrigger = ({ children }: DropdownTriggerProps) => { + const { refs, getReferenceProps } = useDropdownContext(); + + return cloneElement( + children, + getReferenceProps({ + ref: refs.setReference, + }) + ); +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss new file mode 100644 index 00000000..3e49dabd --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -0,0 +1,51 @@ +@use '@tedi-design-system/core/mixins'; + +.tedi-dropdown { + z-index: var(--z-index-dropdown); + display: flex; + flex-direction: column; + width: var(--dropdown-min-width, 10rem); + pointer-events: none; + background-color: var(--dropdown-item-default-background); + border: 1px solid var(--card-border-primary); + border-radius: var(--form-select-area-radius); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + transition: opacity 120ms ease, transform 120ms ease; + transform: translateY(-4px) scale(0.98); + + &[data-state='open'] { + pointer-events: auto; + opacity: 1; + transform: translateY(0) scale(1); + } + + &--tree { + .tedi-dropdown__item { + position: relative; + } + + .tedi-dropdown__item[data-indent='1']::before { + position: absolute; + top: 0; + bottom: 0; + left: 0.75rem; + width: 2px; + content: ''; + background-color: var(--general-border-primary); + } + + .tedi-dropdown__item[data-indent='1']::after { + position: absolute; + top: 50%; + left: 0.75rem; + width: 0.75rem; + height: 2px; + content: ''; + background-color: var(--general-border-primary); + } + + .tedi-dropdown__item[data-indent='1'][data-last]::before { + bottom: 50%; + } + } +} diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx new file mode 100644 index 00000000..5f08e944 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -0,0 +1,244 @@ +import * as FloatingUI from '@floating-ui/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; + +import { UnknownType } from '../../../types/commonTypes'; +import { Dropdown, DropdownProps } from './dropdown'; +import styles from './dropdown.module.scss'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string) => key, + }), +})); + +const renderDropdown = ( + triggerProps: ComponentProps, + content: React.ReactNode, + dropdownProps?: Omit +) => { + return render( + + + {content} + + ); +}; + +describe('Dropdown component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders Trigger children correctly', () => { + renderDropdown({ children: Open menu }, Item); + const trigger = screen.getByText('Open menu'); + expect(trigger.tagName).toBe('SPAN'); + }); + + it('does not render content by default', () => { + renderDropdown({ children: Open menu }, Item); + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + }); + + it('opens dropdown on trigger click', () => { + renderDropdown({ children: Open menu }, Item); + const trigger = screen.getByText('Open menu'); + fireEvent.click(trigger); + expect(screen.getByText('Item')).toBeInTheDocument(); + }); + + it('closes dropdown when item is clicked', () => { + renderDropdown({ children: Open menu }, Item); + fireEvent.click(screen.getByText('Open menu')); + fireEvent.click(screen.getByText('Item')); + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + }); + + it('renders multiple items', () => { + renderDropdown( + { children: Open menu }, + <> + Item 1 + Item 2 + + ); + + fireEvent.click(screen.getByText('Open menu')); + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + }); + + it('marks active item with active class', () => { + renderDropdown( + { children: Open menu }, + <> + Item 1 + + Item 2 + + + ); + + fireEvent.click(screen.getByText('Open menu')); + const activeItem = screen.getByText('Item 2'); + expect(activeItem).toHaveClass('tedi-dropdown__item--active'); + }); + + it('traps focus inside dropdown when modal=true', () => { + renderDropdown({ children: Open menu }, Item, { + modal: true, + }); + + fireEvent.click(screen.getByText('Open menu')); + const item = screen.getByText('Item'); + item.focus(); + expect(document.activeElement).toBe(item); + }); + + it('respects controlled open prop', () => { + const { rerender } = renderDropdown( + { children: Trigger }, + Item, + { open: false } + ); + + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + + rerender( + + +

Trigger

+
+ + Item + +
+ ); + + expect(screen.getByText('Item')).toBeInTheDocument(); + }); + + it('calls onOpenChange in controlled mode but does not change internal state', () => { + const onOpenChange = jest.fn(); + + renderDropdown({ children: Trigger }, Item, { + open: false, + onOpenChange, + }); + + fireEvent.click(screen.getByText('Trigger')); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + }); + + it('does not set a valid width when reference is not available yet', () => { + jest.spyOn(FloatingUI, 'useFloating').mockReturnValue({ + refs: { + reference: { current: null }, + floating: { current: null }, + setReference: jest.fn(), + setFloating: jest.fn(), + }, + x: 0, + y: 0, + strategy: 'absolute', + placement: 'bottom-start', + middlewareData: {}, + } as UnknownType); + + renderDropdown({ children: }, Item, { + width: 'trigger', + }); + + fireEvent.click(screen.getByText('Trigger')); + const dropdown = screen.getByRole('menu'); + expect(dropdown).toHaveStyle({ width: '0px' }); + expect(dropdown.style.width).toBe('0px'); + expect(parseFloat(getComputedStyle(dropdown).width)).toBe(0); + }); + + it('applies tree variant class when variant="tree"', () => { + renderDropdown({ children: Menu }, Item, { variant: 'tree' }); + + fireEvent.click(screen.getByText('Menu')); + const dropdownContainer = screen.getByRole('menu'); + expect(dropdownContainer).toHaveClass(styles['tedi-dropdown--tree']); + }); + + it('sets aria-activedescendant when activeIndex is set', () => { + renderDropdown( + { children: Trigger }, + <> + First + Second + , + {} + ); + + fireEvent.click(screen.getByText('Trigger')); + expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-0'); + }); + + it('applies pixel width when width is a number', () => { + renderDropdown({ children: Trigger }, Item, { + width: 300, + }); + + fireEvent.click(screen.getByText('Trigger')); + + expect(screen.getByRole('menu')).toHaveStyle({ width: '300px' }); + }); + + it('applies custom string width', () => { + renderDropdown({ children: Trigger }, Item, { + width: '16rem', + }); + + fireEvent.click(screen.getByText('Trigger')); + + expect(screen.getByRole('menu')).toHaveStyle({ width: '16rem' }); + }); + + it('does not apply width when width="auto"', () => { + renderDropdown({ children: Trigger }, Item, { + width: 'auto', + }); + + fireEvent.click(screen.getByText('Trigger')); + + const dropdown = screen.getByRole('menu'); + expect(dropdown.style.width).toBe(''); + }); + + it('uses container width when width="full"', () => { + const container = document.createElement('div'); + + jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({ + width: 500, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + toJSON: () => {}, + }); + + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get() { + return container; + }, + }); + + renderDropdown({ children: Trigger }, Item, { + width: 'full', + }); + + fireEvent.click(screen.getByText('Trigger')); + + expect(screen.getByRole('menu')).toHaveStyle({ width: '500px' }); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx new file mode 100644 index 00000000..a9f38438 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -0,0 +1,631 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Icon } from '../../base/icon/icon'; +import { Text } from '../../base/typography/text/text'; +import { Button } from '../../buttons/button/button'; +import Checkbox from '../../form/checkbox/checkbox'; +import Radio from '../../form/radio/radio'; +import { Search } from '../../form/search/search'; +import { Col, Row } from '../../layout/grid'; +import Separator from '../../misc/separator/separator'; +import { Dropdown } from './dropdown'; + +/** + * Dropdown Figma ↗
+ * DropdownItem Figma ↗
+ * Zeroheight ↗ + */ + +export default { + title: 'TEDI-Ready/Components/Overlay/Dropdown', + component: Dropdown, + subcomponents: { + 'Dropdown.Trigger': Dropdown.Trigger, + 'Dropdown.Content': Dropdown.Content, + 'Dropdown.Item': Dropdown.Item, + 'Dropdown.Separator': Dropdown.Separator, + } as never, + parameters: { + status: { + type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }], + }, + controls: { + exclude: ['sm', 'md', 'lg', 'xl', 'xxl'], + }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.35.54?node-id=12185-156201&t=xqukY4r7lJRpWTby-4', + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + + console.log('Lisa pöördumine')}> + Access to health data + + console.log('Lisa toetus')}> + Declaration of intent + + Contacts + + + ), +}; + +export const WithActiveItem: Story = { + render: () => { + const [lang, setLang] = React.useState('ENG'); + const [filter, setFilter] = React.useState('Newest first'); + + return ( + + + + + + + + + {['EST', 'ENG', 'RUS'].map((l, i) => ( + setLang(l)}> + {l} + + ))} + + + + + + + + + + + {['Newest first', 'Oldest first', 'Application name A–Z', 'Application name Z–A'].map((f, i) => ( + setFilter(f)}> + {f} + + ))} + + + + + ); + }, +}; + +export const WithAction: Story = { + render: () => ( + + + + + + + console.log('Lisa pöördumine')}> + Create contact + + console.log('Lisa toetus')}> + Create application + + Create invoice + + + ), +}; + +export const WithIcon: Story = { + render: () => ( + + + + + + + + + Download + + + + + Add + + + + + Delete + + + + + ), +}; + +export const WithCheckbox: Story = { + render: () => { + const [cities, setCities] = React.useState([]); + + const toggle = (value: string) => (_value: string, checked: boolean) => { + setCities((prev) => (checked ? [...prev.filter((v) => v !== value), value] : prev.filter((v) => v !== value))); + }; + + return ( + + + + + + + + + + + + + + + + + + + + ); + }, +}; + +type City = 'tallinn' | 'tartu' | 'parnu'; +const allCities: City[] = ['tallinn', 'tartu', 'parnu']; + +export const WithIndentedItems: Story = { + render: () => { + const [selected, setSelected] = React.useState([]); + + const allChecked = selected.length === allCities.length; + const noneChecked = selected.length === 0; + const indeterminate = !allChecked && !noneChecked; + + const toggleAll = (_: string, checked: boolean) => { + setSelected(checked ? allCities : []); + }; + + const toggleOne = (value: City) => (_: string, checked: boolean) => { + setSelected((prev) => (checked ? [...prev.filter((v) => v !== value), value] : prev.filter((v) => v !== value))); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); + }, +}; + +export const WithRadio: Story = { + render: () => { + const [city, setCity] = React.useState<'tallinn' | 'tartu' | 'parnu'>('tallinn'); + + const cities = { + tallinn: 'Tallinn', + tartu: 'Tartu', + parnu: 'Pärnu', + }; + + return ( + + + + + + + + setCity(value as City)} + /> + + + + setCity(value as City)} + /> + + + + setCity(value as City)} + /> + + + + ); + }, +}; + +export const CustomWidth: Story = { + render: () => ( + + + + + + + + + +
+ Access to health data + +
+
+ +
+ Declaration of intent + +
+
+ +
+ Contacts + +
+
+
+
+ +
+ ), +}; + +export const WithDescription: Story = { + render: () => ( + + + + + + + + + +
+ Access to health data + + Doctors will be able to see your health data + +
+
+ +
+ Access to medications and health data + + Doctors will be able to see your medications and health data + +
+
+ +
+ Access to all + + Doctors will be able to see all your information, including declaration of health and other medical + info + +
+
+
+
+ + + + + + + + + +
+ Tallinn + + 3 timeslots available + +
+
+ +
+ Tartu + + 4 timeslots available + +
+
+ +
+ Elva + + 7 timeslots available + +
+
+ +
+ Rakvere + + 3 timeslots available + +
+
+
+
+ +
+ ), +}; + +export const Divided: Story = { + render: () => ( + + + + + + + Profile + Security + Billing + + + Log out + + + + + ), +}; + +export const WithSeparatorAndOpensRight: Story = { + render: () => ( + + + + + + + + Edit + + + + + Duplicate + + + + + + Archive + + + + + Delete + + + + + ), +}; + +export const CustomContent: Story = { + render: () => { + const [query, setQuery] = React.useState(''); + + const representatives = [ + { name: 'Lauri Lepp', code: '49504080254' }, + { name: 'Mart Mardivere', code: '39504080254' }, + { name: 'Madis Mets', code: '39504080254' }, + { name: 'Kalle Kaasik', code: '39504080254' }, + { name: 'Pille Porgand', code: '49504080254' }, + { name: 'Kert Kasemets', code: '39504080254' }, + ]; + + const filtered = + query.trim() === '' + ? representatives + : representatives.filter( + (rep) => rep.name.toLowerCase().includes(query.toLowerCase()) || rep.code.includes(query) + ); + + return ( + + + + + + + + setQuery(value)} /> + + + {filtered.map((rep, i) => { + const index = i + 1; + + return ( + + + {rep.name} + + {rep.code} + + + ); + })} + + + ); + }, +}; + +export const Tree: Story = { + render: () => ( + + + + + + + + Parent + Child 1 + Child 2 + Child 3 + + + + + + + + + + + Parent + + Child 1 + Child 2 + Child 3 + + + + + ), +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx new file mode 100644 index 00000000..363943e0 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -0,0 +1,233 @@ +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset, + Placement, + shift, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import cn from 'classnames'; +import React from 'react'; + +import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; +import { useLabels } from '../../../providers/label-provider'; +import styles from './dropdown.module.scss'; +import { DropdownContent } from './dropdown-content/dropdown-content'; +import { DropdownContext, DropdownContextValue } from './dropdown-context'; +import { DropdownItem } from './dropdown-item/dropdown-item'; +import { DropdownSeparator } from './dropdown-separator/dropdown-separator'; +import { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; + +type DropdownBreakpointProps = { + /** + * When `true` there is a border between the dropdown items + * @default false + */ + divided?: boolean; + /** + * Controls the width of the dropdown menu. + * - `'auto'` – width is determined by content (default) + * - `'trigger'` – matches the width of the trigger element + * - `'full'` – spans the full width of the containing block + * - `number` – fixed width in pixels + * - `string` – any valid CSS width value (e.g. `'16rem'`, `'100%'`) + * @default auto + */ + width?: 'auto' | 'trigger' | 'full' | number | string; + /** + * Controls where the dropdown is positioned relative to its trigger. + * Accepts any Floating UI placement value, such as: + * `'bottom-start'`, `'bottom-end'`, `'top-start'`, `'right-end'`, etc. + * + * @default bottom-start + */ + placement?: Placement; + /** + * Controls the visual and structural variant of the dropdown. + * - `'default'` – standard flat list of items + * - `'tree'` – hierarchical (tree-style) list with indented items and connector lines + * Tree visuals are only applied when this prop is set to `'tree'`. + * Ignored by default. + * @default 'default' + */ + variant?: 'default' | 'tree'; +}; + +export interface DropdownProps extends BreakpointSupport { + /** + * Child elements — must include exactly one `Dropdown.Trigger` and one `Dropdown.Content` + */ + children: React.ReactNode; + /** + * When `true`, the dropdown behaves like a modal: + * - Traps focus inside the dropdown + * - Shows a visually hidden "Close" button for screen readers + * - Usually used for menus that require explicit dismissal + * + * @default false + */ + modal?: boolean; + /** + * Controlled open state + */ + open?: boolean; + /** + * Uncontrolled default state + */ + defaultOpen?: boolean; + /** + * Change handler (fires for both modes) + */ + onOpenChange?: (open: boolean) => void; + /* + * Additional class name(s) to apply to the dropdown container + * @default undefined + */ + className?: string; +} + +export const Dropdown = (props: DropdownProps) => { + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { + children, + modal = false, + divided = false, + width = 'auto', + variant = 'default', + open: controlledOpen, + defaultOpen = false, + onOpenChange, + placement = 'bottom-start', + className, + } = getCurrentBreakpointProps(props); + const { getLabel } = useLabels(); + const nodeId = useFloatingNodeId(); + + const listItemsRef = React.useRef>([]); + const [activeIndex, setActiveIndex] = React.useState(null); + const [content, setContent] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); + + const open = controlledOpen ?? uncontrolledOpen; + + const setOpen = React.useCallback( + (next: boolean) => { + if (controlledOpen === undefined) { + setUncontrolledOpen(next); + } + onOpenChange?.(next); + }, + [controlledOpen, onOpenChange] + ); + + const floating = useFloating({ + nodeId, + open, + placement, + onOpenChange: setOpen, + middleware: [offset(4), flip(), shift()], + whileElementsMounted: autoUpdate, + }); + + const { context, refs, x, y, strategy } = floating; + + const interactions = useInteractions([ + useClick(context), + useRole(context, { role: 'menu' }), + useDismiss(context), + useListNavigation(context, { + listRef: listItemsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + }), + ]); + + const value: DropdownContextValue = { + open, + setOpen, + refs, + listItemsRef, + activeIndex, + setActiveIndex, + placement, + content, + setContent, + divided, + variant, + ...interactions, + }; + + const triggerWidth = refs.reference.current?.getBoundingClientRect().width; + const containerWidth = React.useMemo(() => { + const ref = refs.reference.current as HTMLElement | null; + if (!ref) return undefined; + + const container = ref.offsetParent as HTMLElement | null; + if (!container) return undefined; + + return container.getBoundingClientRect().width; + }, [refs.reference.current]); + + return ( + + {children} + + + {open && ( + +
+ {content} +
+
+ )} +
+
+ ); +}; + +Dropdown.Trigger = DropdownTrigger; +Dropdown.Content = DropdownContent; +Dropdown.Item = DropdownItem; +Dropdown.Separator = DropdownSeparator; diff --git a/src/tedi/components/overlays/dropdown/index.ts b/src/tedi/components/overlays/dropdown/index.ts new file mode 100644 index 00000000..8565a31b --- /dev/null +++ b/src/tedi/components/overlays/dropdown/index.ts @@ -0,0 +1,5 @@ +export * from './dropdown'; +export * from './dropdown-context'; +export * from './dropdown-content/dropdown-content'; +export * from './dropdown-trigger/dropdown-trigger'; +export * from './dropdown-item/dropdown-item'; diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 2f778ed5..319dbf90 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -37,6 +37,7 @@ export * from './components/form/select/select'; export * from './components/form/checkbox/checkbox'; export * from './components/overlays/tooltip'; export * from './components/overlays/popover'; +export * from './components/overlays/dropdown'; export * from './components/misc/separator/separator'; export * from './components/misc/print/print'; export * from './components/misc/stretch-content/stretch-content';