From 40916eed2cc463b9e277c54c12bf2047b1f1b0db Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:51:49 +0200 Subject: [PATCH 01/16] feat(dropdown): TEDI-Ready Dropdown component #94 --- .../dropdown-content.spec.tsx | 38 +++ .../dropdown-content/dropdown-content.tsx | 14 + .../overlays/dropdown/dropdown-context.tsx | 27 ++ .../dropdown-item/dropdown-item.module.scss | 37 +++ .../dropdown-item/dropdown-item.spec.tsx | 78 +++++ .../dropdown/dropdown-item/dropdown-item.tsx | 114 +++++++ .../dropdown-trigger.spec.tsx | 44 +++ .../dropdown-trigger/dropdown-trigger.tsx | 21 ++ .../overlays/dropdown/dropdown.module.scss | 20 ++ .../overlays/dropdown/dropdown.spec.tsx | 103 +++++++ .../overlays/dropdown/dropdown.stories.tsx | 277 ++++++++++++++++++ .../components/overlays/dropdown/dropdown.tsx | 126 ++++++++ .../components/overlays/dropdown/index.ts | 5 + src/tedi/index.ts | 1 + 14 files changed, 905 insertions(+) create mode 100644 src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-context.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss create mode 100644 src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown.module.scss create mode 100644 src/tedi/components/overlays/dropdown/dropdown.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown.stories.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown.tsx create mode 100644 src/tedi/components/overlays/dropdown/index.ts 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.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.tsx new file mode 100644 index 00000000..ffc3ea12 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-context.tsx @@ -0,0 +1,27 @@ +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; +}; + +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..75234f33 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -0,0 +1,37 @@ +@use '@tedi-design-system/core/mixins'; + +.tedi-dropdown__item { + @include mixins.button-reset; + + 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; + + &--active { + color: var(--dropdown-item-active-text); + background-color: var(--dropdown-item-active-background); + } + + &--disabled { + color: var(--color-text-disabled); + pointer-events: none; + background-color: var(--color-bg-disabled); + } + + &:hover { + color: var(--dropdown-item-hover-text); + cursor: pointer; + background-color: var(--dropdown-item-hover-background); + outline: 0; + } + + &:focus-visible { + &:not(.tedi-dropdown__item--disabled) { + cursor: pointer; + outline: 0; + box-shadow: 0 0 0 1px var(--dropdown-item-default-background), 0 0 0 3px var(--general-surface-selected); + } + } +} 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..ae2ecec5 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render } from '@testing-library/react'; + +import { DropdownItem } from './dropdown-item'; + +const mockSetOpen = jest.fn(); +const mockOnClick = jest.fn(); + +jest.mock('../dropdown-context', () => ({ + useDropdownContext: () => ({ + getItemProps: (props: never) => props, + listItemsRef: { current: [] }, + setOpen: mockSetOpen, + activeIndex: 0, + }), +})); + +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 close dropdown when closeOnSelect=false', () => { + const { getByText } = render( + + Item + + ); + + fireEvent.click(getByText('Item')); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + 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 + + ); + + fireEvent.keyDown(getByText('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'); + }); +}); 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..91721723 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -0,0 +1,114 @@ +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..45b3385a --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -0,0 +1,20 @@ +@use '@tedi-design-system/core/mixins'; + +.tedi-dropdown { + z-index: var(--z-index-dropdown); + display: flex; + flex-direction: column; + 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); + 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); + } +} 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..1a65d909 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ComponentProps } from 'react'; + +import { Dropdown, DropdownProps } from './dropdown'; + +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('closes dropdown on Tab key press', () => { + renderDropdown({ children: Open menu }, Item); + fireEvent.click(screen.getByText('Open menu')); + const dropdown = screen.getByRole('menu'); + fireEvent.keyDown(dropdown, { key: 'Tab' }); + 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); + }); +}); 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..d75e8a88 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -0,0 +1,277 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Icon } from '../../base/icon/icon'; +import { Button } from '../../buttons/button/button'; +import Checkbox from '../../form/checkbox/checkbox'; +import Radio from '../../form/radio/radio'; +import { Dropdown } from './dropdown'; + +/** + * 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, + } as never, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + 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'); + + return ( + + + + + + + {['EST', 'ENG', 'RUS'].map((l, i) => ( + setLang(l)}> + {l} + + ))} + + + ); + }, +}; + +export const WithCheckbox: Story = { + render: () => { + const [cities, setCities] = React.useState([]); + + const toggle = (value: string, checked?: boolean) => { + setCities((prev) => (checked ? [...prev, 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: string, checked?: boolean) => { + setSelected((prev) => (checked ? [...prev, value as City] : prev.filter((v) => v !== value))); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); + }, +}; + +export const WithRadio: Story = { + render: () => { + const [city, setCity] = React.useState('tallinn'); + + return ( + + + + + + + + setCity(value)} + /> + + + + setCity(value)} + /> + + + + setCity(value)} + /> + + + + ); + }, +}; + +export const WithIcon: Story = { + render: () => ( + + + + + + + + + Download + + + + + Add + + + + + Delete + + + + + ), +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx new file mode 100644 index 00000000..d7dc5823 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -0,0 +1,126 @@ +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + shift, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import cn from 'classnames'; +import React from 'react'; + +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 { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; + +export type DropdownProps = { + /** + * 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; +}; + +export const Dropdown = ({ children, modal = false }: DropdownProps) => { + const { getLabel } = useLabels(); + const nodeId = useFloatingNodeId(); + + const listItemsRef = React.useRef>([]); + const [open, setOpen] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(null); + const [content, setContent] = React.useState(null); + + const floating = useFloating({ + placement: 'bottom-start', + nodeId, + open, + onOpenChange: setOpen, + middleware: [flip(), shift()], + whileElementsMounted: autoUpdate, + }); + + const { context, refs, x, y, strategy, placement } = 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, + ...interactions, + }; + + return ( + + {children} + + + {open && ( + +
+ {content} +
+
+ )} +
+
+ ); +}; + +Dropdown.Trigger = DropdownTrigger; +Dropdown.Content = DropdownContent; +Dropdown.Item = DropdownItem; 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'; From 793bc10baa3f6379c62f8b4779d217bf300ff17c Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:55:08 +0200 Subject: [PATCH 02/16] chore: fix stories #94 --- .../overlays/dropdown/dropdown.stories.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index d75e8a88..88225608 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -2,6 +2,7 @@ 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'; @@ -257,19 +258,19 @@ export const WithIcon: Story = { - - Download - + + Download + - - Add - + + Add + - - Delete - + + Delete + From a6e6ef2cc7cf11f18226254ce46dd20950179b72 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:52:33 +0200 Subject: [PATCH 03/16] feat(dropdown): add dropdown separator and divided prop + examples #94 --- .../overlays/dropdown/dropdown-context.tsx | 1 + .../dropdown-item/dropdown-item.module.scss | 8 +++ .../dropdown/dropdown-item/dropdown-item.tsx | 3 +- .../dropdown-separator/dropdown-separator.tsx | 5 ++ .../overlays/dropdown/dropdown.stories.tsx | 55 +++++++++++++++++++ .../components/overlays/dropdown/dropdown.tsx | 6 +- 6 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.tsx index ffc3ea12..41e9048b 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-context.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-context.tsx @@ -14,6 +14,7 @@ export type DropdownContextValue = { placement?: string; content: React.ReactNode; setContent: (content: React.ReactNode) => void; + divided?: boolean; }; export const DropdownContext = React.createContext(null); 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 index 75234f33..48b07fab 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -20,6 +20,14 @@ background-color: var(--color-bg-disabled); } + &--divided { + border-bottom: 1px solid var(--general-border-primary); + + &:last-child { + border-bottom: none; + } + } + &:hover { color: var(--dropdown-item-hover-text); cursor: pointer; diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 91721723..dfddee9e 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -72,7 +72,7 @@ export const DropdownItem = ({ asChild = false, closeOnSelect = true, }: DropdownItemProps) => { - const { getItemProps, listItemsRef, setOpen, activeIndex } = useDropdownContext(); + const { getItemProps, listItemsRef, setOpen, activeIndex, divided } = useDropdownContext(); const Component = asChild ? 'div' : 'button'; @@ -90,6 +90,7 @@ export const DropdownItem = ({ className: cn(styles['tedi-dropdown__item'], { [styles['tedi-dropdown__item--active']]: active, [styles['tedi-dropdown__item--disabled']]: disabled, + [styles['tedi-dropdown__item--divided']]: divided, }), onClick(e) { if (asChild || disabled) return; diff --git a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx new file mode 100644 index 00000000..39f9bb61 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx @@ -0,0 +1,5 @@ +import Separator from '../../../misc/separator/separator'; + +export const DropdownSeparator = () => { + return ; +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 88225608..fb431d21 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -20,6 +20,7 @@ export default { 'Dropdown.Trigger': Dropdown.Trigger, 'Dropdown.Content': Dropdown.Content, 'Dropdown.Item': Dropdown.Item, + 'Dropdown.Separator': Dropdown.Separator, } as never, } as Meta; @@ -276,3 +277,57 @@ export const WithIcon: Story = { ), }; + +export const Divided: Story = { + render: () => ( + + + + + + + Profile + Security + Billing + Log out + + + ), +}; + +export const WithSeparator: Story = { + render: () => ( + + + + + + + + Edit + + + + + Duplicate + + + + + + Archive + + + + + Delete + + + + + ), +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index d7dc5823..196e1883 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -20,6 +20,7 @@ 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'; export type DropdownProps = { @@ -36,9 +37,10 @@ export type DropdownProps = { * @default false */ modal?: boolean; + divided?: boolean; }; -export const Dropdown = ({ children, modal = false }: DropdownProps) => { +export const Dropdown = ({ children, modal = false, divided = false }: DropdownProps) => { const { getLabel } = useLabels(); const nodeId = useFloatingNodeId(); @@ -80,6 +82,7 @@ export const Dropdown = ({ children, modal = false }: DropdownProps) => { placement, content, setContent, + divided, ...interactions, }; @@ -124,3 +127,4 @@ export const Dropdown = ({ children, modal = false }: DropdownProps) => { Dropdown.Trigger = DropdownTrigger; Dropdown.Content = DropdownContent; Dropdown.Item = DropdownItem; +Dropdown.Separator = DropdownSeparator; From c0d085a949f9d17890d7ccae392099197a772bea Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:01:56 +0200 Subject: [PATCH 04/16] feat(dropdown): tree variant, placement control #94 --- .../overlays/dropdown/dropdown-context.tsx | 1 + .../dropdown-item/dropdown-item.module.scss | 79 ++++++ .../dropdown/dropdown-item/dropdown-item.tsx | 36 ++- .../overlays/dropdown/dropdown.module.scss | 33 ++- .../overlays/dropdown/dropdown.stories.tsx | 242 +++++++++++++++--- .../components/overlays/dropdown/dropdown.tsx | 101 +++++++- 6 files changed, 444 insertions(+), 48 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.tsx index 41e9048b..f1a456fc 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-context.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-context.tsx @@ -15,6 +15,7 @@ export type DropdownContextValue = { content: React.ReactNode; setContent: (content: React.ReactNode) => void; divided?: boolean; + variant?: 'default' | 'tree'; }; export const DropdownContext = React.createContext(null); 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 index 48b07fab..43ffab84 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -1,17 +1,32 @@ @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; &--active { color: var(--dropdown-item-active-text); background-color: var(--dropdown-item-active-background); + + p, + label { + color: var(--dropdown-item-active-text); + } } &--disabled { @@ -28,11 +43,67 @@ } } + &--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 { @@ -42,4 +113,12 @@ box-shadow: 0 0 0 1px var(--dropdown-item-default-background), 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.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index dfddee9e..ddb99b66 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -15,7 +15,6 @@ export type DropdownItemProps = { onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; /** * Disables the item — prevents interaction and applies disabled styling. - * * @default false */ disabled?: boolean; @@ -29,13 +28,11 @@ export type DropdownItemProps = { /** * 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 @@ -44,7 +41,6 @@ export type DropdownItemProps = { /** * When `true`, renders a plain `
` instead of a ` + @@ -147,7 +162,9 @@ export const WithIndentedItems: Story = { return ( - + @@ -208,7 +225,9 @@ export const WithRadio: Story = { return ( - + @@ -250,31 +269,155 @@ export const WithRadio: Story = { }, }; -export const WithIcon: Story = { +export const WithIconAndCustomDropdownWidth: Story = { render: () => ( - - - - + + + + + + + + + +
+ Access to health data + +
+
+ +
+ Declaration of intent + +
+
+ +
+ Contacts + +
+
+
+
+ + + + + + + + + + + Download + + + + + Add + + + + + Delete + + + + + +
+ ), +}; - - - - Download - - - - - Add - - - - - Delete - - - -
+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 + +
+
+
+
+ +
), }; @@ -297,9 +440,9 @@ export const Divided: Story = { ), }; -export const WithSeparator: Story = { +export const WithSeparatorAndOpensRight: 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 index 196e1883..2219ace1 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -3,6 +3,7 @@ import { flip, FloatingFocusManager, FloatingPortal, + Placement, shift, useClick, useDismiss, @@ -15,6 +16,7 @@ import { 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'; @@ -23,7 +25,44 @@ import { DropdownItem } from './dropdown-item/dropdown-item'; import { DropdownSeparator } from './dropdown-separator/dropdown-separator'; import { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; -export type DropdownProps = { +type DropdownWidth = 'auto' | 'trigger' | 'full' | number | string; + +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?: DropdownWidth; + /** + * 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` */ @@ -37,28 +76,63 @@ export type DropdownProps = { * @default false */ modal?: boolean; - divided?: boolean; -}; + /** + * Controlled open state + */ + open?: boolean; + /** + * Uncontrolled default state + */ + defaultOpen?: boolean; + /** + * Change handler (fires for both modes) + */ + onOpenChange?: (open: boolean) => void; +} -export const Dropdown = ({ children, modal = false, divided = false }: DropdownProps) => { +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', + } = getCurrentBreakpointProps(props); const { getLabel } = useLabels(); const nodeId = useFloatingNodeId(); const listItemsRef = React.useRef>([]); - const [open, setOpen] = React.useState(false); 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({ - placement: 'bottom-start', nodeId, open, + placement, onOpenChange: setOpen, middleware: [flip(), shift()], whileElementsMounted: autoUpdate, }); - const { context, refs, x, y, strategy, placement } = floating; + const { context, refs, x, y, strategy } = floating; const interactions = useInteractions([ useClick(context), @@ -83,9 +157,12 @@ export const Dropdown = ({ children, modal = false, divided = false }: DropdownP content, setContent, divided, + variant, ...interactions, }; + const triggerWidth = refs.reference.current?.getBoundingClientRect().width; + return ( {children} @@ -100,11 +177,19 @@ export const Dropdown = ({ children, modal = false, divided = false }: DropdownP
Date: Wed, 25 Feb 2026 13:18:42 +0200 Subject: [PATCH 05/16] feat(separator): match stories with Figma #94 --- .../dropdown-item/dropdown-item.spec.tsx | 2 +- .../dropdown/dropdown-item/dropdown-item.tsx | 54 ++++--- .../overlays/dropdown/dropdown.stories.tsx | 135 +++++++++++++----- .../components/overlays/dropdown/dropdown.tsx | 17 ++- 4 files changed, 148 insertions(+), 60 deletions(-) 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 index ae2ecec5..34ff81a7 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -61,7 +61,7 @@ describe('DropdownItem', () => { ); - fireEvent.keyDown(getByText('Item'), { key: 'Enter' }); + fireEvent.click(getByText('Item')); expect(mockOnClick).toHaveBeenCalled(); expect(mockSetOpen).toHaveBeenCalledWith(false); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index ddb99b66..0d8beaa6 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -83,6 +83,7 @@ export const DropdownItem = ({ const { getItemProps, listItemsRef, setOpen, activeIndex, divided, variant } = useDropdownContext(); const Component = asChild ? 'div' : 'button'; + const isInteractive = asChild && closeOnSelect === false; const getCssVars = (indent?: number): React.CSSProperties => { const cssVars: React.CSSProperties = {}; @@ -95,9 +96,14 @@ export const DropdownItem = ({ return cssVars; }; - return ( - - {children} - - ); + style: getCssVars(indent), + }); + + return {children}; }; diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index f61d168a..2031b396 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -6,6 +6,7 @@ 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 { Dropdown } from './dropdown'; @@ -40,11 +41,11 @@ export default { type Story = StoryObj; export const Default: Story = { - render: () => ( - + render: (args) => ( + @@ -85,6 +86,58 @@ export const WithActiveItem: Story = { }, }; +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([]); @@ -162,7 +215,7 @@ export const WithIndentedItems: Story = { return ( - @@ -226,7 +279,7 @@ export const WithRadio: Story = { @@ -269,10 +322,10 @@ export const WithRadio: Story = { }, }; -export const WithIconAndCustomDropdownWidth: Story = { +export const CustomWidth: Story = { render: () => ( - + - - - - - - Download - - - - - Add - - - - - Delete - - - - - ), }; @@ -339,8 +365,8 @@ export const WithDescription: Story = { - @@ -377,7 +403,7 @@ export const WithDescription: Story = { @@ -425,7 +451,7 @@ export const Divided: Story = { render: () => ( - @@ -434,7 +460,11 @@ export const Divided: Story = { Profile Security Billing - Log out + + + Log out + + ), @@ -475,6 +505,33 @@ export const WithSeparatorAndOpensRight: Story = { ), }; +export const CustomContent: Story = { + render: () => ( + + + + + + + + + + Lauri Lepp 49504080254 + + + Mart Mardivere 39504080254 + + Madis Mets 39504080254 + Kalle Kaasik 39504080254 + Pille Porgand 49504080254 + Kert Kasemets 39504080254 + + + ), +}; + export const Tree: Story = { render: () => ( diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 2219ace1..ac2f7aac 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -42,7 +42,7 @@ type DropdownBreakpointProps = { * - `string` – any valid CSS width value (e.g. `'16rem'`, `'100%'`) * @default auto */ - width?: DropdownWidth; + width?: 'auto' | 'trigger' | 'full' | number | string; /** * Controls where the dropdown is positioned relative to its trigger. * Accepts any Floating UI placement value, such as: @@ -162,6 +162,15 @@ export const Dropdown = (props: DropdownProps) => { }; 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 ( @@ -182,8 +191,10 @@ export const Dropdown = (props: DropdownProps) => { position: strategy, left: x ?? 0, top: y ?? 0, - minWidth: - width === 'trigger' + width: + width === 'full' + ? containerWidth + : width === 'trigger' ? triggerWidth : typeof width === 'number' ? `${width}px` From 3ee3091f935bcfa4a2b5bf9d511a47d4847ae163 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:55:06 +0200 Subject: [PATCH 06/16] fix(dropdown): fix stories #94 --- .../overlays/dropdown/dropdown.stories.tsx | 145 +++++++++++++++--- 1 file changed, 124 insertions(+), 21 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 2031b396..54147cb8 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -8,6 +8,7 @@ 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'; /** @@ -65,23 +66,45 @@ export const Default: Story = { 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} - - ))} - - + + + + + + + + + {['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} + + ))} + + + + ); }, }; @@ -518,15 +541,95 @@ export const CustomContent: Story = { - Lauri Lepp 49504080254 + + Lauri Lepp + + 49504080254 + - Mart Mardivere 39504080254 + + Mart Mardivere + + 39504080254 + + + + + Madis Mets + + 39504080254 + + + + + Kalle Kaasik + + 39504080254 + + + + + Pille Porgand + + 49504080254 + + + + + Kert Kasemets + + 39504080254 + - Madis Mets 39504080254 - Kalle Kaasik 39504080254 - Pille Porgand 49504080254 - Kert Kasemets 39504080254 ), From ec73cad3735f6a351e78a301a8cdb5049163662d Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:21:11 +0200 Subject: [PATCH 07/16] feat(dropdown): add className props, add box-shadow, remove unused type #94 --- .../dropdown/dropdown-item/dropdown-item.tsx | 7 +++++++ .../overlays/dropdown/dropdown.module.scss | 1 + src/tedi/components/overlays/dropdown/dropdown.tsx | 14 +++++++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 0d8beaa6..2db808e5 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -67,6 +67,11 @@ export type DropdownItemProps = { * @default false */ isParent?: boolean; + /* + * Additional class name(s) to apply to the dropdown item + * @default undefined + */ + className?: string; }; export const DropdownItem = ({ @@ -79,6 +84,7 @@ export const DropdownItem = ({ asChild = false, closeOnSelect = true, isParent = false, + className, }: DropdownItemProps) => { const { getItemProps, listItemsRef, setOpen, activeIndex, divided, variant } = useDropdownContext(); @@ -119,6 +125,7 @@ export const DropdownItem = ({ [styles['tedi-dropdown__item--indent']]: indent, [styles['tedi-dropdown__item--tree-item']]: variant === 'tree' && indent, [styles['tedi-dropdown__item--tree-parent']]: variant === 'tree' && isParent, + className, }), onClick(e) { if (disabled) return; diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss index 0a2f3aa9..1e877da7 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -10,6 +10,7 @@ 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); diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index ac2f7aac..48d22bac 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -25,8 +25,6 @@ import { DropdownItem } from './dropdown-item/dropdown-item'; import { DropdownSeparator } from './dropdown-separator/dropdown-separator'; import { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; -type DropdownWidth = 'auto' | 'trigger' | 'full' | number | string; - type DropdownBreakpointProps = { /** * When `true` there is a border between the dropdown items @@ -88,6 +86,11 @@ export interface DropdownProps extends BreakpointSupport void; + /* + * Additional class name(s) to apply to the dropdown container + * @default undefined + */ + className?: string; } export const Dropdown = (props: DropdownProps) => { @@ -102,6 +105,7 @@ export const Dropdown = (props: DropdownProps) => { defaultOpen = false, onOpenChange, placement = 'bottom-start', + className, } = getCurrentBreakpointProps(props); const { getLabel } = useLabels(); const nodeId = useFloatingNodeId(); @@ -186,7 +190,11 @@ export const Dropdown = (props: DropdownProps) => {
Date: Wed, 4 Mar 2026 12:33:55 +0200 Subject: [PATCH 08/16] fix(dropdown): focused item indicator fix, fix stories #94 --- .../dropdown-item/dropdown-item.module.scss | 2 +- .../dropdown/dropdown-item/dropdown-item.tsx | 5 +++-- .../overlays/dropdown/dropdown.module.scss | 3 ++- .../overlays/dropdown/dropdown.stories.tsx | 22 +++++++++---------- .../components/overlays/dropdown/dropdown.tsx | 17 ++++++++++++-- 5 files changed, 32 insertions(+), 17 deletions(-) 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 index 43ffab84..d4dc6e28 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -110,7 +110,7 @@ &:not(.tedi-dropdown__item--disabled) { cursor: pointer; outline: 0; - box-shadow: 0 0 0 1px var(--dropdown-item-default-background), 0 0 0 3px var(--general-surface-selected); + box-shadow: 0 0 0 1px var(--TEDI-neutral-100), 0 0 0 3px var(--general-surface-selected); } } diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 2db808e5..0bbb2879 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -149,12 +149,13 @@ export const DropdownItem = ({ if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( 'input[type="checkbox"], input[type="radio"]' ) as HTMLInputElement | null; - input?.click(); + if (input) input.click(); + else onClick?.(e); + if (!asChild && closeOnSelect) setOpen(false); } }, style: getCssVars(indent), diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss index 1e877da7..2c9edba9 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -5,7 +5,8 @@ display: flex; flex-direction: column; width: var(--dropdown-min-width, 10rem); - overflow: hidden; + + // overflow: hidden; pointer-events: none; background-color: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 54147cb8..d5561a82 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -165,8 +165,8 @@ export const WithCheckbox: Story = { render: () => { const [cities, setCities] = React.useState([]); - const toggle = (value: string, checked?: boolean) => { - setCities((prev) => (checked ? [...prev, value] : prev.filter((v) => v !== value))); + const toggle = (value: string) => (_value: string, checked: boolean) => { + setCities((prev) => (checked ? [...prev.filter((v) => v !== value), value] : prev.filter((v) => v !== value))); }; return ( @@ -184,7 +184,7 @@ export const WithCheckbox: Story = { label="Pärnu" value="parnu" checked={cities.includes('parnu')} - onChange={toggle} + onChange={toggle('parnu')} name="" /> @@ -195,7 +195,7 @@ export const WithCheckbox: Story = { label="Tartu" value="tartu" checked={cities.includes('tartu')} - onChange={toggle} + onChange={toggle('tartu')} name="" /> @@ -206,7 +206,7 @@ export const WithCheckbox: Story = { label="Tallinn" value="tallinn" checked={cities.includes('tallinn')} - onChange={toggle} + onChange={toggle('tallinn')} name="" /> @@ -227,12 +227,12 @@ export const WithIndentedItems: Story = { const noneChecked = selected.length === 0; const indeterminate = !allChecked && !noneChecked; - const toggleAll = (_: string, checked?: boolean) => { + const toggleAll = (_: string, checked: boolean) => { setSelected(checked ? allCities : []); }; - const toggleOne = (value: string, checked?: boolean) => { - setSelected((prev) => (checked ? [...prev, value as City] : prev.filter((v) => v !== value))); + const toggleOne = (value: City) => (_: string, checked: boolean) => { + setSelected((prev) => (checked ? [...prev.filter((v) => v !== value), value] : prev.filter((v) => v !== value))); }; return ( @@ -262,7 +262,7 @@ export const WithIndentedItems: Story = { label="Tallinn" value="tallinn" checked={selected.includes('tallinn')} - onChange={toggleOne} + onChange={toggleOne('tallinn')} name="" /> @@ -273,7 +273,7 @@ export const WithIndentedItems: Story = { label="Tartu" value="tartu" checked={selected.includes('tartu')} - onChange={toggleOne} + onChange={toggleOne('tartu')} name="" /> @@ -284,7 +284,7 @@ export const WithIndentedItems: Story = { label="Pärnu" value="parnu" checked={selected.includes('parnu')} - onChange={toggleOne} + onChange={toggleOne('parnu')} name="" /> diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 48d22bac..23d6656d 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -14,7 +14,7 @@ import { useRole, } from '@floating-ui/react'; import cn from 'classnames'; -import React from 'react'; +import React, { useEffect } from 'react'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; @@ -176,6 +176,16 @@ export const Dropdown = (props: DropdownProps) => { return container.getBoundingClientRect().width; }, [refs.reference.current]); + useEffect(() => { + if (open && listItemsRef.current.length > 0) { + const firstEnabledIndex = listItemsRef.current.findIndex((el) => el && !el.disabled); + if (firstEnabledIndex >= 0) { + setActiveIndex(firstEnabledIndex); + listItemsRef.current[firstEnabledIndex]?.focus(); + } + } + }, [open]); + return ( {children} @@ -211,10 +221,13 @@ export const Dropdown = (props: DropdownProps) => { : width, }, onKeyDown(event) { - if (event.key === 'Tab') { + if (!modal && event.key === 'Tab') { setOpen(false); } }, + role: 'menu', + 'aria-orientation': 'vertical', + 'aria-activedescendant': activeIndex !== null ? `dropdown-item-${activeIndex}` : undefined, })} data-placement={placement} data-state={open ? 'open' : 'closed'} From d547f58ec4c686dda9fb7d1b906fcae659203413 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:42:19 +0200 Subject: [PATCH 09/16] fix(dropdown): fix tab targeting on choice items #94 --- .../dropdown/dropdown-item/dropdown-item.module.scss | 2 +- .../components/overlays/dropdown/dropdown.module.scss | 2 -- src/tedi/components/overlays/dropdown/dropdown.tsx | 9 +++++++-- 3 files changed, 8 insertions(+), 5 deletions(-) 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 index d4dc6e28..4aaa71e8 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -110,7 +110,7 @@ &: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); + box-shadow: 0 0 0 1px var(--tedi-neutral-100), 0 0 0 3px var(--general-surface-selected); } } diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss index 2c9edba9..3e49dabd 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -5,8 +5,6 @@ display: flex; flex-direction: column; width: var(--dropdown-min-width, 10rem); - - // overflow: hidden; pointer-events: none; background-color: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 23d6656d..f4b88b70 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -221,8 +221,13 @@ export const Dropdown = (props: DropdownProps) => { : width, }, onKeyDown(event) { - if (!modal && event.key === 'Tab') { - setOpen(false); + if (event.key === 'Tab') { + const floatingEl = refs.floating.current; + const relatedTarget = (event as unknown as KeyboardEvent & { relatedTarget: EventTarget | null }) + .relatedTarget; + if (floatingEl && relatedTarget && !floatingEl.contains(relatedTarget as Node)) { + setOpen(false); + } } }, role: 'menu', From ca094c4d341610fd11264c0ec3c0da8847dc093c Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:53:57 +0200 Subject: [PATCH 10/16] fix(dropdown): fix focus scrolling bug #94 --- .../components/overlays/dropdown/dropdown.tsx | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index f4b88b70..a71676e0 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -14,7 +14,7 @@ import { useRole, } from '@floating-ui/react'; import cn from 'classnames'; -import React, { useEffect } from 'react'; +import React from 'react'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; @@ -176,16 +176,6 @@ export const Dropdown = (props: DropdownProps) => { return container.getBoundingClientRect().width; }, [refs.reference.current]); - useEffect(() => { - if (open && listItemsRef.current.length > 0) { - const firstEnabledIndex = listItemsRef.current.findIndex((el) => el && !el.disabled); - if (firstEnabledIndex >= 0) { - setActiveIndex(firstEnabledIndex); - listItemsRef.current[firstEnabledIndex]?.focus(); - } - } - }, [open]); - return ( {children} @@ -221,13 +211,8 @@ export const Dropdown = (props: DropdownProps) => { : width, }, onKeyDown(event) { - if (event.key === 'Tab') { - const floatingEl = refs.floating.current; - const relatedTarget = (event as unknown as KeyboardEvent & { relatedTarget: EventTarget | null }) - .relatedTarget; - if (floatingEl && relatedTarget && !floatingEl.contains(relatedTarget as Node)) { - setOpen(false); - } + if (!modal && event.key === 'Tab') { + setOpen(false); } }, role: 'menu', From 4c340250009d3edebaa8415472c200b7aa372d84 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:10:34 +0200 Subject: [PATCH 11/16] fix(dropdown): design review fixes #94 --- .../dropdown-item/dropdown-item.module.scss | 8 + .../overlays/dropdown/dropdown.stories.tsx | 177 +++++++----------- .../components/overlays/dropdown/dropdown.tsx | 3 +- 3 files changed, 78 insertions(+), 110 deletions(-) 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 index 4aaa71e8..2862bebc 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -19,6 +19,14 @@ 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); diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index d5561a82..8c1fc759 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -296,13 +296,19 @@ export const WithIndentedItems: Story = { export const WithRadio: Story = { render: () => { - const [city, setCity] = React.useState('tallinn'); + const [city, setCity] = React.useState<'tallinn' | 'tartu' | 'parnu'>('tallinn'); + + const cities = { + tallinn: 'Tallinn', + tartu: 'Tartu', + parnu: 'Pärnu', + }; return ( @@ -314,7 +320,7 @@ export const WithRadio: Story = { value="tallinn" label="Tallinn" checked={city === 'tallinn'} - onChange={(value) => setCity(value)} + onChange={(value) => setCity(value as City)} /> @@ -325,7 +331,7 @@ export const WithRadio: Story = { value="tartu" label="Tartu" checked={city === 'tartu'} - onChange={(value) => setCity(value)} + onChange={(value) => setCity(value as City)} /> @@ -336,7 +342,7 @@ export const WithRadio: Story = { value="parnu" label="Pärnu" checked={city === 'parnu'} - onChange={(value) => setCity(value)} + onChange={(value) => setCity(value as City)} /> @@ -529,110 +535,63 @@ export const WithSeparatorAndOpensRight: Story = { }; export const CustomContent: Story = { - render: () => ( - - - - - - - - - - - Lauri Lepp - - 49504080254 - - - - - Mart Mardivere - - 39504080254 - - - - - Madis Mets - - 39504080254 - - - - - Kalle Kaasik - - 39504080254 - - - - - Pille Porgand - - 49504080254 - - - - - Kert Kasemets - - 39504080254 - - - - - ), + 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 = { diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index a71676e0..dcefd512 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -3,6 +3,7 @@ import { flip, FloatingFocusManager, FloatingPortal, + offset, Placement, shift, useClick, @@ -132,7 +133,7 @@ export const Dropdown = (props: DropdownProps) => { open, placement, onOpenChange: setOpen, - middleware: [flip(), shift()], + middleware: [offset(4), flip(), shift()], whileElementsMounted: autoUpdate, }); From 9ca825045303dc9bf586a65a4521641807190fe2 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:53:08 +0200 Subject: [PATCH 12/16] fix(dropdown): checkbox/radio tab targeting fix #94 --- .../overlays/dropdown/dropdown-item/dropdown-item.tsx | 8 +++++++- src/tedi/components/overlays/dropdown/dropdown.spec.tsx | 8 -------- src/tedi/components/overlays/dropdown/dropdown.tsx | 5 ----- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 0bbb2879..5ac9f324 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -104,6 +104,12 @@ export const DropdownItem = ({ const itemProps = isInteractive ? { + ref(node: HTMLElement | null) { + if (typeof index === 'number') { + listItemsRef.current[index] = node as HTMLButtonElement | null; + } + }, + tabIndex: activeIndex === index ? 0 : -1, // ← crucial className: cn(styles['tedi-dropdown__item'], { [styles['tedi-dropdown__item--indent']]: indent, }), @@ -111,7 +117,7 @@ export const DropdownItem = ({ } : getItemProps({ ref(node: HTMLElement) { - if (!asChild && typeof index === 'number') { + if (typeof index === 'number') { listItemsRef.current[index] = node as HTMLButtonElement; } }, diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx index 1a65d909..2c773cb2 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -52,14 +52,6 @@ describe('Dropdown component', () => { expect(screen.queryByText('Item')).not.toBeInTheDocument(); }); - it('closes dropdown on Tab key press', () => { - renderDropdown({ children: Open menu }, Item); - fireEvent.click(screen.getByText('Open menu')); - const dropdown = screen.getByRole('menu'); - fireEvent.keyDown(dropdown, { key: 'Tab' }); - expect(screen.queryByText('Item')).not.toBeInTheDocument(); - }); - it('renders multiple items', () => { renderDropdown( { children: Open menu }, diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index dcefd512..363943e0 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -211,11 +211,6 @@ export const Dropdown = (props: DropdownProps) => { ? undefined : width, }, - onKeyDown(event) { - if (!modal && event.key === 'Tab') { - setOpen(false); - } - }, role: 'menu', 'aria-orientation': 'vertical', 'aria-activedescendant': activeIndex !== null ? `dropdown-item-${activeIndex}` : undefined, From ad8fb494719e71cfc900598b1b33b990f69b517d Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:01:38 +0200 Subject: [PATCH 13/16] fix(dropdown): add deprecated badge to Community component, update Figma links #94 --- src/community/components/dropdown/dropdown.stories.tsx | 5 +++++ src/tedi/components/overlays/dropdown/dropdown.stories.tsx | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) 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.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 8c1fc759..a9f38438 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -12,7 +12,8 @@ import Separator from '../../misc/separator/separator'; import { Dropdown } from './dropdown'; /** - * Figma ↗
+ * Dropdown Figma ↗
+ * DropdownItem Figma ↗
* Zeroheight ↗ */ From a528d7ca9841dca4397d5eb3391ac5a9c793f55c Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:39:00 +0200 Subject: [PATCH 14/16] fix(dropdown): add more test coverage #94 --- package-lock.json | 12 +- package.json | 1 + .../dropdown/dropdown-context.spec.tsx | 134 ++++++++++++++++++ .../dropdown-item/dropdown-item.spec.tsx | 76 +++++++++- .../dropdown-separator.spec.tsx | 38 +++++ .../overlays/dropdown/dropdown.spec.tsx | 89 +++++++++++- 6 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx 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/tedi/components/overlays/dropdown/dropdown-context.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx new file mode 100644 index 00000000..da24ae3b --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx @@ -0,0 +1,134 @@ +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'); + }); + + it('context value shape matches snapshot', () => { + const { result } = renderHook(() => useDropdownContext(), { + wrapper: wrapperWithContext, + }); + + const { + setOpen, + setActiveIndex, + setContent, + refs, + getReferenceProps, + getFloatingProps, + getItemProps, + listItemsRef, + ...serializable + } = result.current ?? {}; + expect(serializable).toMatchSnapshot(); + }); +}); 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 index 34ff81a7..5fd3db15 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { DropdownItem } from './dropdown-item'; @@ -75,4 +75,78 @@ describe('DropdownItem', () => { 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('clicks inner checkbox/radio when wrapper is clicked (closeOnSelect=false)', () => { + const handleChange = jest.fn(); + + render( + + + Label + + ); + + fireEvent.click(screen.getByTestId('inner-input').parentElement!); + expect(handleChange).toHaveBeenCalledTimes(0); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + it('does NOT close dropdown when clicking inner input and closeOnSelect=false', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('radio')); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + it('ignores events when disabled (even asChild)', () => { + const handleChange = jest.fn(); + + const { getByText } = render( + + + Disabled item + + ); + + fireEvent.click(getByText('Disabled item')); + expect(handleChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(getByText('Disabled item').closest('div')!, { key: 'Enter' }); + expect(handleChange).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(); + }); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx new file mode 100644 index 00000000..da3fed20 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx @@ -0,0 +1,38 @@ +// src/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx + +import { render, screen } from '@testing-library/react'; + +import { DropdownSeparator } from './dropdown-separator'; + +jest.mock('../../../misc/separator/separator', () => ({ + __esModule: true, + default: jest.fn(({ axis, className, ...props }) => ( +
+ )), +})); + +describe('DropdownSeparator', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('separator')).toBeInTheDocument(); + }); + + it('passes axis="horizontal" to Separator', () => { + render(); + + const separator = screen.getByTestId('separator'); + expect(separator).toHaveAttribute('data-axis', 'horizontal'); + }); + + it('renders a semantic separator (hr)', () => { + render(); + + const separator = screen.getByTestId('separator'); + expect(separator.tagName).toBe('HR'); + }); + + it('matches snapshot', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx index 2c773cb2..0cac7c01 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -1,7 +1,10 @@ +import * as FloatingUI from '@floating-ui/react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { ComponentProps } from '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: () => ({ @@ -92,4 +95,88 @@ describe('Dropdown component', () => { 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'); + }); }); From 31df95d3943ef57cd86da1384e647d065c09eb38 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:56:32 +0200 Subject: [PATCH 15/16] fix(dropdown): update tests #94 --- .../dropdown/dropdown-context.spec.tsx | 19 ------------------- .../dropdown-separator.spec.tsx | 13 ++++--------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx index da24ae3b..359ecd9b 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx @@ -112,23 +112,4 @@ describe('DropdownContext + useDropdownContext', () => { expect(ctx).toHaveProperty('divided'); expect(ctx).toHaveProperty('variant'); }); - - it('context value shape matches snapshot', () => { - const { result } = renderHook(() => useDropdownContext(), { - wrapper: wrapperWithContext, - }); - - const { - setOpen, - setActiveIndex, - setContent, - refs, - getReferenceProps, - getFloatingProps, - getItemProps, - listItemsRef, - ...serializable - } = result.current ?? {}; - expect(serializable).toMatchSnapshot(); - }); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx index da3fed20..09368f7e 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx @@ -17,11 +17,11 @@ describe('DropdownSeparator', () => { expect(screen.getByTestId('separator')).toBeInTheDocument(); }); - it('passes axis="horizontal" to Separator', () => { + it('renders a horizontal separator', () => { render(); - - const separator = screen.getByTestId('separator'); - expect(separator).toHaveAttribute('data-axis', 'horizontal'); + const sep = screen.getByTestId('separator'); + expect(sep.tagName).toBe('HR'); + expect(sep).toHaveAttribute('data-axis', 'horizontal'); }); it('renders a semantic separator (hr)', () => { @@ -30,9 +30,4 @@ describe('DropdownSeparator', () => { const separator = screen.getByTestId('separator'); expect(separator.tagName).toBe('HR'); }); - - it('matches snapshot', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); }); From 061ac3e276105a6c2e85982245b46bf1def36b0e Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:21:46 +0200 Subject: [PATCH 16/16] fix(dropdown): improve test coverage #94 --- .../dropdown-item/dropdown-item.spec.tsx | 172 +++++++++++++----- .../dropdown/dropdown-item/dropdown-item.tsx | 139 +++++++------- .../overlays/dropdown/dropdown.spec.tsx | 62 +++++++ 3 files changed, 256 insertions(+), 117 deletions(-) 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 index 5fd3db15..e1ee3b8f 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import { UnknownType } from '../../../../types/commonTypes'; import { DropdownItem } from './dropdown-item'; const mockSetOpen = jest.fn(); @@ -7,10 +8,12 @@ const mockOnClick = jest.fn(); jest.mock('../dropdown-context', () => ({ useDropdownContext: () => ({ - getItemProps: (props: never) => props, + getItemProps: (props: UnknownType) => props, listItemsRef: { current: [] }, setOpen: mockSetOpen, activeIndex: 0, + divided: false, + variant: 'default', }), })); @@ -26,30 +29,17 @@ describe('DropdownItem', () => { Item ); - fireEvent.click(getByText('Item')); expect(mockOnClick).toHaveBeenCalled(); expect(mockSetOpen).toHaveBeenCalledWith(false); }); - it('does not close dropdown when closeOnSelect=false', () => { - const { getByText } = render( - - Item - - ); - - fireEvent.click(getByText('Item')); - expect(mockSetOpen).not.toHaveBeenCalled(); - }); - it('does not call onClick when disabled', () => { const { getByText } = render( Item ); - fireEvent.click(getByText('Item')); expect(mockOnClick).not.toHaveBeenCalled(); }); @@ -60,8 +50,8 @@ describe('DropdownItem', () => { Item ); - - fireEvent.click(getByText('Item')); + const item = getByText('Item'); + fireEvent.keyDown(item, { key: 'Enter' }); expect(mockOnClick).toHaveBeenCalled(); expect(mockSetOpen).toHaveBeenCalledWith(false); }); @@ -72,7 +62,6 @@ describe('DropdownItem', () => { Child ); - expect(getByText('Child').tagName).toBe('SPAN'); }); @@ -83,70 +72,157 @@ describe('DropdownItem', () => { ); - const div = screen.getByLabelText('Custom label').closest('div'); expect(div).toBeInTheDocument(); expect(div?.tagName).toBe('DIV'); }); - it('clicks inner checkbox/radio when wrapper is clicked (closeOnSelect=false)', () => { - const handleChange = jest.fn(); + 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 + + Label text ); - - fireEvent.click(screen.getByTestId('inner-input').parentElement!); - expect(handleChange).toHaveBeenCalledTimes(0); + const wrapper = screen.getByText('Label text').closest('div')!; + fireEvent.click(wrapper); + expect(handleChange).toHaveBeenCalledTimes(1); expect(mockSetOpen).not.toHaveBeenCalled(); }); - it('does NOT close dropdown when clicking inner input and closeOnSelect=false', () => { + 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'); + }); - fireEvent.click(screen.getByTestId('radio')); + 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('ignores events when disabled (even asChild)', () => { + 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(); + }); - const { getByText } = render( - - - Disabled item + 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); + }); - fireEvent.click(getByText('Disabled item')); - expect(handleChange).not.toHaveBeenCalled(); + it('triggers handleClick on non-asChild', () => { + const handle = jest.fn(); + render( + + Clickable + + ); - fireEvent.keyDown(getByText('Disabled item').closest('div')!, { key: 'Enter' }); - expect(handleChange).not.toHaveBeenCalled(); + const item = screen.getByText('Clickable'); + fireEvent.click(item); // triggers handleClick + expect(handle).toHaveBeenCalledTimes(1); }); - it('applies indentation styles when indent is provided', () => { + it('triggers handleKeyDown on space key', () => { + const handle = jest.fn(); render( - - Indented + + SpaceItem ); - const item = screen.getByText('Indented').closest('button'); - expect(item).toHaveStyle('--dropdown-indent: 2rem'); - expect(item).toHaveStyle('--dropdown-indent-level: 2'); + const item = screen.getByText('SpaceItem'); + fireEvent.keyDown(item, { key: ' ' }); // trigger handleKeyDown for space + expect(handle).toHaveBeenCalledTimes(1); }); - it('does not register ref when index is undefined', () => { - expect(() => { - render( No index ); - }).not.toThrow(); + 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 index 5ac9f324..7d526137 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -89,83 +89,84 @@ export const DropdownItem = ({ const { getItemProps, listItemsRef, setOpen, activeIndex, divided, variant } = useDropdownContext(); const Component = asChild ? 'div' : 'button'; - const isInteractive = asChild && closeOnSelect === false; const getCssVars = (indent?: number): React.CSSProperties => { - const cssVars: React.CSSProperties = {}; + if (typeof indent !== 'number') return {}; + return { + '--dropdown-indent-level': indent, + '--dropdown-indent': `${indent}rem`, + } as React.CSSProperties; + }; + + const handleClick = (e: React.MouseEvent) => { + if (disabled) return; // stop everything - if (typeof indent === 'number') { - cssVars['--dropdown-indent-level'] = indent; - cssVars['--dropdown-indent'] = `${indent}rem`; + // only trigger inner inputs if not disabled + const input = (e.currentTarget as HTMLElement).querySelector( + 'input[type="checkbox"], input[type="radio"]' + ); + if (input) { + input.click(); + return; } - return cssVars; + if (!asChild) { + onClick?.(e); + if (closeOnSelect) setOpen(false); + } else { + onClick?.(e); + } }; - const itemProps = isInteractive - ? { - ref(node: HTMLElement | null) { - if (typeof index === 'number') { - listItemsRef.current[index] = node as HTMLButtonElement | null; - } - }, - tabIndex: activeIndex === index ? 0 : -1, // ← crucial - className: cn(styles['tedi-dropdown__item'], { - [styles['tedi-dropdown__item--indent']]: indent, - }), - style: getCssVars(indent), + const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + + const input = (e.currentTarget as HTMLElement).querySelector( + 'input[type="checkbox"], input[type="radio"]' + ); + + if (input) { + input.click(); + } else { + onClick?.(e); } - : getItemProps({ - ref(node: HTMLElement) { - if (typeof index === 'number') { - listItemsRef.current[index] = node as HTMLButtonElement; - } - }, - role: 'menuitem', - disabled: !asChild ? disabled : undefined, - tabIndex: activeIndex === index ? 0 : -1, - className: cn(styles['tedi-dropdown__item'], { - [styles['tedi-dropdown__item--active']]: active, - [styles['tedi-dropdown__item--disabled']]: disabled, - [styles['tedi-dropdown__item--divided']]: divided, - [styles['tedi-dropdown__item--indent']]: indent, - [styles['tedi-dropdown__item--tree-item']]: variant === 'tree' && indent, - [styles['tedi-dropdown__item--tree-parent']]: variant === 'tree' && isParent, - className, - }), - onClick(e) { - if (disabled) return; - - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"], input[type="radio"]' - ) as HTMLInputElement | null; - - if (input) { - input.click(); - return; - } - - if (!asChild) { - onClick?.(e); - if (closeOnSelect) setOpen(false); - } - }, - onKeyDown(e) { - if (disabled) return; - - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"], input[type="radio"]' - ) as HTMLInputElement | null; - - if (input) input.click(); - else onClick?.(e); - if (!asChild && closeOnSelect) setOpen(false); - } - }, - style: getCssVars(indent), - }); + + if (!asChild && closeOnSelect) setOpen(false); + } + }; + + const baseProps = { + ref(node: HTMLElement | null) { + if (typeof index === 'number') { + listItemsRef.current[index] = node as HTMLButtonElement | null; + } + }, + tabIndex: activeIndex === index ? 0 : -1, + className: cn(styles['tedi-dropdown__item'], { + [styles['tedi-dropdown__item--active']]: active, + [styles['tedi-dropdown__item--disabled']]: disabled, + [styles['tedi-dropdown__item--divided']]: divided, + [styles['tedi-dropdown__item--indent']]: indent, + [styles['tedi-dropdown__item--tree-item']]: variant === 'tree' && indent, + [styles['tedi-dropdown__item--tree-parent']]: variant === 'tree' && isParent, + className, + }), + style: getCssVars(indent), + onClick: handleClick, + onKeyDown: handleKeyDown, + }; + + const itemProps = + asChild && closeOnSelect === false + ? baseProps + : getItemProps({ + role: 'menuitem', + disabled: !asChild ? disabled : undefined, + ...baseProps, + }); return {children}; }; diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx index 0cac7c01..5f08e944 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -179,4 +179,66 @@ describe('Dropdown component', () => { 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' }); + }); });