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 = {
- Choose city: {city}
+ City: {city}
@@ -269,10 +322,10 @@ export const WithRadio: Story = {
},
};
-export const WithIconAndCustomDropdownWidth: Story = {
+export const CustomWidth: Story = {
render: () => (
-
+
@@ -302,33 +355,6 @@ export const WithIconAndCustomDropdownWidth: Story = {
-
-
-
-
- Actions
-
-
-
-
-
-
- Download
-
-
-
-
- Add
-
-
-
-
- Delete
-
-
-
-
-
),
};
@@ -339,8 +365,8 @@ export const WithDescription: Story = {
-
- Actions
+
+ Share access
@@ -377,7 +403,7 @@ export const WithDescription: Story = {
- Actions
+ Choose location
@@ -425,7 +451,7 @@ export const Divided: Story = {
render: () => (
-
+
Account
@@ -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: () => (
+
+
+
+ Choose representative
+
+
+
+
+
+
+
+ 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 (
-
-
-
- {lang}
-
-
-
-
- {['EST', 'ENG', 'RUS'].map((l, i) => (
- setLang(l)}>
- {l}
-
- ))}
-
-
+
+
+
+
+
+ {lang}
+
+
+
+
+ {['EST', 'ENG', 'RUS'].map((l, i) => (
+ setLang(l)}>
+ {l}
+
+ ))}
+
+
+
+
+
+
+
+ Sort: {filter}
+
+
+
+
+ {['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 (
- City: {city}
+ City: {cities[city]}
@@ -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: () => (
-
-
-
- Choose representative
-
-
-
-
-
-
-
-
- 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 (
+
+
+
+ Choose representative
+
+
+
+
+
+ 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: Trigger }, 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' });
+ });
});