From 09ff9b6573d3b77dc9fec8c6ba52a3d9f0454061 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Tue, 14 Jul 2026 17:01:20 +1200 Subject: [PATCH 01/20] feat: added support for search context on dropdown menu --- .../dropdown-menu/dropdown-menu.module.css | 24 +- .../dropdown-menu/dropdown-menu.tsx | 574 +++++++++++++++--- 2 files changed, 513 insertions(+), 85 deletions(-) diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css b/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css index 7ccfe97e..a1299ac3 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css @@ -63,7 +63,7 @@ .dropdown-menu-label { @apply flex-center-between gap-2; - @apply px-2 py-1.5 text-sm font-medium; + @apply text-text-secondary px-2 py-1.5 text-xs font-medium; } .dropdown-menu-separator { @@ -79,3 +79,25 @@ .dropdown-menu-content { @apply bg-background-overlay shadow-shadow-overlay border-border-overlay; } + +.dropdown-menu-search { + @apply flex-center gap-2; + @apply px-2 py-1.5; + @apply mb-1; + @apply border-border-overlay border-b; +} + +.dropdown-menu-search-input { + @apply min-w-0 flex-1; + @apply border-none bg-transparent outline-none; + @apply text-text-primary text-sm; +} + +.dropdown-menu-search-input::placeholder { + @apply text-text-secondary; +} + +.dropdown-menu-empty { + @apply px-2 py-1.5; + @apply text-text-secondary text-center text-xs; +} diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index 5c242f0a..a31fab7e 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; +import { Check, ChevronRight, Circle, Search } from 'lucide-react'; import styles from '@/components/dropdown-menu/dropdown-menu.module.css'; import { cn, getThemeProviderRoot } from '@/lib/utils'; @@ -8,8 +8,203 @@ import { cn, getThemeProviderRoot } from '@/lib/utils'; const CheckIcon = Check as React.ComponentType<{ className?: string }>; const ChevronRightIcon = ChevronRight as React.ComponentType<{ className?: string }>; const CircleIcon = Circle as React.ComponentType<{ className?: string }>; +const SearchIcon = Search as React.ComponentType<{ className?: string }>; -const DropdownMenu = DropdownMenuPrimitive.Root; +/* + * Search context - Shared by Root, Content, the Item variants, DropdownMenuSearch + * and DropdownMenuEmpty so the whole menu can behave as one searchable unit + */ + +type DropdownMenuSearchContextValue = { + /* True while a is mounted in the tree */ + enabled: boolean; + setEnabled: (value: boolean) => void; + /* True once the search input is actually shown */ + visible: boolean; + /* Reveal the search input */ + reveal: (seed: string) => void; + query: string; + setQuery: (value: string) => void; + /* Re focus the search input from outside DropdownMenuSearch */ + focusSignal: number; + requestFocus: () => void; + /* Item registry, used for the optional empty state */ + registerItem: (id: string, matches: boolean) => void; + unregisterItem: (id: string) => void; + matchCount: number; +}; + +const DropdownMenuSearchContext = React.createContext(null); + +const useDropdownMenuSearch = () => React.useContext(DropdownMenuSearchContext); + +/* True when there is an active (non-empty) search query */ +const useIsSearching = () => { + const ctx = useDropdownMenuSearch(); + return !!ctx && ctx.query.trim().length > 0; +}; + +/* Pull plain text out of children so we can match against it */ +function getNodeText(node: React.ReactNode): string { + if (node == null || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(getNodeText).join(''); + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + return getNodeText(node.props.children); + } + return ''; +} + +/** Stable name used to identify DropdownMenuSubContent regardless of reference identity */ +const SUB_CONTENT_NAME = 'DropdownMenuSubContent'; + +function isSubContent( + node: React.ReactNode +): node is React.ReactElement<{ children?: React.ReactNode }> { + return ( + React.isValidElement(node) && + typeof node.type !== 'string' && // skip host elements like
+ (node.type as { displayName?: string }).displayName === SUB_CONTENT_NAME + ); +} + +/* + * Find the first DropdownMenuSubContent's children, descending recursively + * through fragments, arrays and host elements + */ +function findSubContentChildren(nodes: React.ReactNode): React.ReactNode { + let result: React.ReactNode = null; + let done = false; + + const walk = (ns: React.ReactNode) => { + React.Children.forEach(ns, (child) => { + if (done || !React.isValidElement(child)) return; + if (isSubContent(child)) { + result = child.props.children ?? null; + done = true; + return; + } + const nested = (child.props as { children?: React.ReactNode }).children; + if (nested != null) walk(nested); + }); + }; + + walk(nodes); + return result; +} + +/* + * Shared logic for every item variant: decide whether the item is visible for the current query + * and register its match state (so DropdownMenuEmpty can know when nothing matched) + */ +function useFilterableItem(textValue: string | undefined, children: React.ReactNode): boolean { + const ctx = useDropdownMenuSearch(); + const id = React.useId(); + const query = ctx?.query.trim().toLowerCase() ?? ''; + const visible = !query || (textValue ?? getNodeText(children)).toLowerCase().includes(query); + + React.useEffect(() => { + if (!ctx || !ctx.enabled) return; + ctx.registerItem(id, visible); + return () => ctx.unregisterItem(id); + }, [ctx, ctx?.enabled, id, visible]); + + return visible; +} + +/* + * Root component - manages the search context and the open state + */ +const DropdownMenu = ({ + children, + onOpenChange, + ...props +}: React.ComponentPropsWithoutRef) => { + const [enabled, setEnabled] = React.useState(false); + const [visible, setVisible] = React.useState(false); + const [query, setQuery] = React.useState(''); + + // Bumped whenever something outside DropdownMenuSearch wants the input re-focused + const [focusSignal, setFocusSignal] = React.useState(0); + const requestFocus = React.useCallback(() => setFocusSignal((n) => n + 1), []); + + // Item registry for the empty state + const itemsRef = React.useRef>(new Map()); + const [matchCount, setMatchCount] = React.useState(0); + const recount = React.useCallback(() => { + let count = 0; + itemsRef.current.forEach((matches) => { + if (matches) count += 1; + }); + setMatchCount(count); + }, []); + const registerItem = React.useCallback( + (id: string, matches: boolean) => { + itemsRef.current.set(id, matches); + recount(); + }, + [recount] + ); + const unregisterItem = React.useCallback( + (id: string) => { + itemsRef.current.delete(id); + recount(); + }, + [recount] + ); + + const reveal = React.useCallback((seed: string) => { + setVisible(true); + setQuery(seed); + }, []); + + const handleOpenChange = React.useCallback( + (open: boolean) => { + // Reset when opening to keep the filtered list intact while closing + if (open) { + setVisible(false); + setQuery(''); + } + onOpenChange?.(open); + }, + [onOpenChange] + ); + + const value = React.useMemo( + () => ({ + enabled, + setEnabled, + visible, + reveal, + query, + setQuery, + focusSignal, + requestFocus, + registerItem, + unregisterItem, + matchCount, + }), + [ + enabled, + visible, + reveal, + query, + focusSignal, + requestFocus, + registerItem, + unregisterItem, + matchCount, + ] + ); + + return ( + + + {children} + + + ); +}; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; @@ -21,29 +216,35 @@ const DropdownMenuPortal = ({ children }: { children: React.ReactNode }) => ( ); -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; +/* + * SubTrigger - hidden while searching (its items are flattened up into the main list) + */ const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); +>(({ className, inset, children, ...props }, ref) => { + const searching = useIsSearching(); + if (searching) return null; + + return ( + + {children} + + + ); +}); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< @@ -56,106 +257,309 @@ const DropdownMenuSubContent = React.forwardRef< {...props} /> )); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; +DropdownMenuSubContent.displayName = SUB_CONTENT_NAME; + +const DropdownMenuSub = ({ + children, + ...props +}: React.ComponentPropsWithoutRef) => { + const searching = useIsSearching(); + + if (searching) { + // Flatten: pull the SubContent's items inline so they participate in the filter + // Recursive so it survives fragments / arrays / host-element wrapping + return <>{findSubContentChildren(children)}; + } + + return {children}; +}; +/* + * Content - intercepts the first printable key to reveal the search input + */ const DropdownMenuContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); +>(({ className, sideOffset = 4, onKeyDown, ...props }, ref) => { + const ctx = useDropdownMenuSearch(); + + return ( + + { + onKeyDown?.(event); + if (!ctx?.enabled || event.defaultPrevented) return; + + const isPrintable = + event.key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + /\S/.test(event.key); + if (!isPrintable) return; + + // If the search input already has focus, let it type normally + const target = event.target as HTMLElement | null; + if (target?.closest?.('[data-dropdown-search]')) return; + + // preventDefault() stops Radix's built-in typeahead from also handling this key + event.preventDefault(); + if (!ctx.visible) { + // First keystroke: reveal and seed the search input + ctx.reveal(event.key); + } else { + // Bring focus back to the input + ctx.setQuery(ctx.query + event.key); + ctx.requestFocus(); + } + }} + {...props} + /> + + ); +}); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; +/* + * Search input - renders the search input and manages the search context + */ +const DropdownMenuSearch = ({ + className, + placeholder = 'Search…', + icon, + /* Render the input immediately instead of revealing on first keypress */ + alwaysVisible = false, + onKeyDown, + ...props +}: Omit, 'value' | 'onChange'> & { + icon?: React.ReactNode; + alwaysVisible?: boolean; +}) => { + const ctx = useDropdownMenuSearch(); + if (!ctx) { + throw new Error('DropdownMenuSearch must be used within a DropdownMenu'); + } + const { setEnabled, reveal, visible, focusSignal } = ctx; + + const inputRef = React.useRef(null); + + // Tell Content a search exists so it knows to intercept keystrokes + React.useEffect(() => { + setEnabled(true); + return () => setEnabled(false); + }, [setEnabled]); + + // Focus the input when it becomes visible AND whenever focus is requested from Content + React.useEffect(() => { + if (!visible) return; + const el = inputRef.current; + if (!el) return; + el.focus(); + const end = el.value.length; + el.setSelectionRange(end, end); + }, [visible, focusSignal]); + + // If always visible, reveal as soon as the menu opens + React.useEffect(() => { + if (alwaysVisible && !visible) reveal(''); + }, [alwaysVisible, visible, reveal]); + + if (!alwaysVisible && !visible) return null; + + return ( +
+ {icon ?? } + ctx.setQuery(event.target.value)} + onKeyDown={(event) => { + onKeyDown?.(event); + + // Arrow keys move focus into the list - Radix won't do this for us because focus + // is on the input, not a menu item. Jump to the first/last currently-visible item + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + const menu = event.currentTarget.closest('[role="menu"]'); + const items = menu + ? Array.from( + menu.querySelectorAll( + '[role="menuitem"]:not([data-disabled]),' + + '[role="menuitemcheckbox"]:not([data-disabled]),' + + '[role="menuitemradio"]:not([data-disabled])' + ) + ) + : []; + if (items.length) { + event.preventDefault(); + (event.key === 'ArrowDown' ? items[0] : items[items.length - 1]).focus(); + } + return; + } + + // Bubble events to Radix (close / select / tab out) + if (['Enter', 'Escape', 'Tab'].includes(event.key)) return; + + // Everything else stays in the input so Radix typeahead / shortcuts don't fire + event.stopPropagation(); + }} + {...props} + /> +
+ ); +}; +DropdownMenuSearch.displayName = 'DropdownMenuSearch'; + +/* + * Empty search state - renders only when a query doesn't match any items + */ +const DropdownMenuEmpty = ({ + className, + children, + ...props +}: React.HTMLAttributes) => { + const ctx = useDropdownMenuSearch(); + const query = ctx?.query.trim() ?? ''; + if (!ctx || !query || ctx.matchCount > 0) return null; + return ( +
+ {children} +
+ ); +}; +DropdownMenuEmpty.displayName = 'DropdownMenuEmpty'; + +/* + * Items - each variant hides itself when it doesn't match the active query + */ const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; variant?: 'neutral' | 'danger'; } ->(({ className, inset, variant = 'neutral', ...props }, ref) => ( - -)); +>(({ className, inset, variant = 'neutral', textValue, children, ...props }, ref) => { + const visible = useFilterableItem(textValue, children); + if (!visible) return null; + + return ( + + {children} + + ); +}); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); +>(({ className, children, checked, textValue, ...props }, ref) => { + const visible = useFilterableItem(textValue, children); + if (!visible) return null; + + return ( + + + + + + + {children} + + ); +}); DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); +>(({ className, children, textValue, ...props }, ref) => { + const visible = useFilterableItem(textValue, children); + if (!visible) return null; + + return ( + + + + + + + {children} + + ); +}); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; +/* + * Label - hidden while searching + */ const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } ->(({ className, inset, ...props }, ref) => ( - -)); +>(({ className, inset, ...props }, ref) => { + const searching = useIsSearching(); + if (searching) return null; + + return ( + + ); +}); DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; +/* + * Separator - hidden while searching + */ const DropdownMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const searching = useIsSearching(); + if (searching) return null; + + return ( + + ); +}); DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { @@ -167,12 +571,14 @@ export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuEmpty, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSearch, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, From d070b4915041d6140db7151c4fbda28575cc5931 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Tue, 14 Jul 2026 17:01:46 +1200 Subject: [PATCH 02/20] feat: added new examples on docs to test out search on dropdown menu --- .../src/components/demo/dropdown-menu.tsx | 161 ++++++++++++- .../src/content/components/dropdown-menu.mdx | 217 ++++++++++++++---- 2 files changed, 332 insertions(+), 46 deletions(-) diff --git a/packages/demo/src/components/demo/dropdown-menu.tsx b/packages/demo/src/components/demo/dropdown-menu.tsx index 2bf47e5c..c2faa87f 100644 --- a/packages/demo/src/components/demo/dropdown-menu.tsx +++ b/packages/demo/src/components/demo/dropdown-menu.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { + Avatar, + AvatarFallback, Button, DropdownMenu, DropdownMenuTrigger, @@ -15,9 +17,32 @@ import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, + DropdownMenuSearch, + DropdownMenuEmpty, + Icon, } from "@eqtylab/equality"; import { Settings, User, LogOut } from "lucide-react"; +const MEMBERS = [ + { name: "Ada Lovelace", icon: "User" }, + { name: "Alan Turing", icon: "UserCog" }, + { name: "Grace Hopper", icon: "User" }, + { name: "Katherine Johnson", icon: "UserCog" }, + { name: "Linus Torvalds", icon: "UserStar" }, + { name: "Margaret Hamilton", icon: "User" }, +]; + +const COLUMN_LABELS: Record = { + name: "Name", + email: "Email", + role: "Role", + status: "Status", + created: "Created", + lastActive: "Last active", + team: "Team", + location: "Location", +}; + export const DropdownMenuDemo = ({ variant = "default", }: { @@ -28,12 +53,26 @@ export const DropdownMenuDemo = ({ | "with-radio" | "with-shortcuts" | "with-submenu" - | "with-groups"; + | "with-groups" + | "with-search" + | "with-search-always" + | "with-search-submenu"; }) => { const [showStatusBar, setShowStatusBar] = useState(true); const [showActivityBar, setShowActivityBar] = useState(false); const [showPanel, setShowPanel] = useState(false); const [position, setPosition] = useState("bottom"); + const [assignee, setAssignee] = useState(null); + const [columns, setColumns] = useState>({ + name: true, + email: true, + role: true, + status: false, + created: false, + lastActive: false, + team: false, + location: false, + }); if (variant === "default") { return ( @@ -192,6 +231,7 @@ export const DropdownMenuDemo = ({ + Back Forward Reload @@ -205,11 +245,30 @@ export const DropdownMenuDemo = ({ Create Shortcut... Name Window... - Developer Tools + + + Developer Tools + + + Console + Network + + + + Profiling + + + Performance + Memory + + + + Settings + No results found
@@ -256,5 +315,103 @@ export const DropdownMenuDemo = ({ ); } + if (variant === "with-search") { + return ( +
+ + + + + + + Team members + {MEMBERS.map((person) => ( + setAssignee(person.name)} + > + + {person.name} + + ))} + No members found + + +
+ ); + } + + if (variant === "with-search-always") { + return ( +
+ + + + + + + Toggle columns + {Object.entries(COLUMN_LABELS).map(([key, label]) => ( + + setColumns((prev) => ({ ...prev, [key]: checked })) + } + onSelect={(event) => event.preventDefault()} + > + {label} + + ))} + No columns found + + +
+ ); + } + + if (variant === "with-search-submenu") { + return ( +
+ + + + + + + Cut + Copy + Paste + + + + More Tools + + + Save Page As... + Create Shortcut... + Developer Tools + Task Manager + + + No actions found + + +
+ ); + } + return null; }; diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index e882f4b3..d6b93f3f 100644 --- a/packages/demo/src/content/components/dropdown-menu.mdx +++ b/packages/demo/src/content/components/dropdown-menu.mdx @@ -8,7 +8,7 @@ import { DropdownMenuDemo } from "@demo/components/demo/dropdown-menu"; ## Overview -A dropdown menu displays a list of actions or options in a floating panel anchored to a trigger. Use it for contextual actions, account menus, view options, and settings. It supports labels, separators, checkboxes, radio groups, keyboard shortcuts, grouping, and nested submenus, and is fully keyboard navigable. +A dropdown menu displays a list of actions or options in a floating panel anchored to a trigger. Use it for contextual actions, account menus, view options, and settings. It supports labels, separators, checkboxes, radio groups, keyboard shortcuts, grouping, nested submenus, and optional in-place search, and is fully keyboard navigable. ## Usage @@ -118,22 +118,51 @@ Use `DropdownMenuShortcut` to display a keyboard shortcut hint aligned to the en ### With Submenu -Use `DropdownMenuSub`, `DropdownMenuSubTrigger`, and `DropdownMenuSubContent` to nest a menu inside an item. The submenu opens on hover or keyboard focus. +Use `DropdownMenuSub`, `DropdownMenuSubTrigger`, and `DropdownMenuSubContent` to nest a menu inside an item. The submenu opens on hover or keyboard focus. Submenus can be nested to any depth — place another `DropdownMenuSub` inside a `DropdownMenuSubContent` to create a further level. ```jsx - - - More Tools - - - Save Page As... - Create Shortcut... - - Developer Tools - - + + + Back + Forward + Reload + + + + More Tools + + + Save Page As... + Create Shortcut... + Name Window... + + + + Developer Tools + + + Console + Network + + + + Profiling + + + Performance + Memory + + + + + + + + Settings + No results found + ``` ### With Groups @@ -156,24 +185,107 @@ Wrap related items in `DropdownMenuGroup` to associate a label with its items fo ``` +## Search & filtering + +Add a `DropdownMenuSearch` inside `DropdownMenuContent` to filter items in place. It is opt-in per menu — without it, the menu behaves exactly as before and the built-in typeahead still works. Items hide themselves when they don't match, and matching is done against each item's `textValue`, falling back to its rendered text. + +Import the additional parts: + +```ts +import { DropdownMenuSearch, DropdownMenuEmpty } from "@eqtylab/equality"; +``` + +### Reveal on typing + +By default the search box is hidden and reveals as soon as you start typing — the first keystroke seeds the query. Add a `DropdownMenuEmpty` to show a "no results" row when nothing matches. Give items that lead with an icon or avatar a `textValue` so they filter on the label rather than the icon's contents. + + + +```jsx + + + Team members + + + Ada Lovelace + + {/* …more members… */} + No members found + +``` + +### Always visible + +Pass `alwaysVisible` to show the search box the moment the menu opens instead of waiting for the first keystroke. Filtering works across every item type, including checkbox and radio items. Labels, separators, and submenu triggers hide while a search is active so results stay compact. + + + +```jsx + + + Toggle columns + setColumns({ ...columns, email: checked })} + onSelect={(event) => event.preventDefault()} + > + Email + + {/* …more columns… */} + No columns found + +``` + +### Searching submenus + +Items nested in a `DropdownMenuSub` are flattened into the main list while searching, so submenu items appear in the results without opening the submenu. Try searching for "developer" below. + + + +```jsx + + + Cut + Copy + Paste + + + + More Tools + + + Save Page As... + Create Shortcut... + Developer Tools + Task Manager + + + No actions found + +``` + +Flattening only works when `DropdownMenuSubContent` is placed directly inside `DropdownMenuSub` — wrapping it in another element prevents its items from being searched. + ## Slots -| Name | Description | -| -------------------------- | ------------------------------------------------ | -| `DropdownMenu` | Root component, manages open state | -| `DropdownMenuTrigger` | Element that opens the menu on click | -| `DropdownMenuContent` | Floating panel containing the menu items | -| `DropdownMenuItem` | A single actionable menu item | -| `DropdownMenuCheckboxItem` | A toggleable item with a checkmark indicator | -| `DropdownMenuRadioGroup` | Groups radio items into a single-select set | -| `DropdownMenuRadioItem` | A single-select item within a radio group | -| `DropdownMenuLabel` | Non-interactive section title | -| `DropdownMenuSeparator` | Divider between groups of items | -| `DropdownMenuShortcut` | Keyboard shortcut hint aligned to the item's end | -| `DropdownMenuGroup` | Groups related items for assistive technology | -| `DropdownMenuSub` | Root for a nested submenu | -| `DropdownMenuSubTrigger` | Item that opens a nested submenu | -| `DropdownMenuSubContent` | Floating panel for a nested submenu | +| Name | Description | +| -------------------------- | ------------------------------------------------- | +| `DropdownMenu` | Root component, manages open state | +| `DropdownMenuTrigger` | Element that opens the menu on click | +| `DropdownMenuContent` | Floating panel containing the menu items | +| `DropdownMenuSearch` | Optional search input that filters items in place | +| `DropdownMenuEmpty` | "No results" row shown only when nothing matches | +| `DropdownMenuItem` | A single actionable menu item | +| `DropdownMenuCheckboxItem` | A toggleable item with a checkmark indicator | +| `DropdownMenuRadioGroup` | Groups radio items into a single-select set | +| `DropdownMenuRadioItem` | A single-select item within a radio group | +| `DropdownMenuLabel` | Non-interactive section title | +| `DropdownMenuSeparator` | Divider between groups of items | +| `DropdownMenuShortcut` | Keyboard shortcut hint aligned to the item's end | +| `DropdownMenuGroup` | Groups related items for assistive technology | +| `DropdownMenuSub` | Root for a nested submenu | +| `DropdownMenuSubTrigger` | Item that opens a nested submenu | +| `DropdownMenuSubContent` | Floating panel for a nested submenu | ## Props @@ -201,20 +313,22 @@ Wrap related items in `DropdownMenuGroup` to associate a label with its items fo ### DropdownMenuItem -| Name | Description | Type | Default | Required | -| ---------- | ----------------------------------------------------- | ------------------- | --------- | -------- | -| `variant` | Visual style; `danger` marks a destructive action | `neutral`, `danger` | `neutral` | ❌ | -| `inset` | Adds left padding to align with items that have icons | `boolean` | `false` | ❌ | -| `disabled` | Prevents interaction and dims the item | `boolean` | `false` | ❌ | -| `onSelect` | Called when the item is selected | `() => void` | - | ❌ | +| Name | Description | Type | Default | Required | +| ----------- | ---------------------------------------------------------------------- | ------------------- | --------- | -------- | +| `variant` | Visual style; `danger` marks a destructive action | `neutral`, `danger` | `neutral` | ❌ | +| `inset` | Adds left padding to align with items that have icons | `boolean` | `false` | ❌ | +| `disabled` | Prevents interaction and dims the item | `boolean` | `false` | ❌ | +| `textValue` | Text used for search filtering; falls back to the item's rendered text | `string` | - | ❌ | +| `onSelect` | Called when the item is selected | `() => void` | - | ❌ | ### DropdownMenuCheckboxItem -| Name | Description | Type | Default | Required | -| ----------------- | -------------------------------------- | ---------------------------- | ------- | -------- | -| `checked` | Whether the item is checked | `boolean` | - | ❌ | -| `onCheckedChange` | Called when the checked state changes | `(checked: boolean) => void` | - | ❌ | -| `disabled` | Prevents interaction and dims the item | `boolean` | `false` | ❌ | +| Name | Description | Type | Default | Required | +| ----------------- | ---------------------------------------------------------------------- | ---------------------------- | ------- | -------- | +| `checked` | Whether the item is checked | `boolean` | - | ❌ | +| `onCheckedChange` | Called when the checked state changes | `(checked: boolean) => void` | - | ❌ | +| `disabled` | Prevents interaction and dims the item | `boolean` | `false` | ❌ | +| `textValue` | Text used for search filtering; falls back to the item's rendered text | `string` | - | ❌ | ### DropdownMenuRadioGroup @@ -225,13 +339,28 @@ Wrap related items in `DropdownMenuGroup` to associate a label with its items fo ### DropdownMenuRadioItem -| Name | Description | Type | Default | Required | -| ---------- | -------------------------------------- | --------- | ------- | -------- | -| `value` | The unique value of the item | `string` | - | ✅ | -| `disabled` | Prevents interaction and dims the item | `boolean` | `false` | ❌ | +| Name | Description | Type | Default | Required | +| ----------- | ---------------------------------------------------------------------- | --------- | ------- | -------- | +| `value` | The unique value of the item | `string` | - | ✅ | +| `disabled` | Prevents interaction and dims the item | `boolean` | `false` | ❌ | +| `textValue` | Text used for search filtering; falls back to the item's rendered text | `string` | - | ❌ | ### DropdownMenuLabel & DropdownMenuSubTrigger | Name | Description | Type | Default | Required | | ------- | ----------------------------------------------------- | --------- | ------- | -------- | | `inset` | Adds left padding to align with items that have icons | `boolean` | `false` | ❌ | + +### DropdownMenuSearch + +Also accepts standard `input` attributes, except `value` and `onChange`, which are managed internally. + +| Name | Description | Type | Default | Required | +| --------------- | ----------------------------------------------------------------- | ----------- | --------- | -------- | +| `alwaysVisible` | Show the input immediately instead of revealing on first keypress | `boolean` | `false` | ❌ | +| `placeholder` | Placeholder text for the input | `string` | `Search…` | ❌ | +| `icon` | Custom leading icon; defaults to a search icon | `ReactNode` | - | ❌ | + +### DropdownMenuEmpty + +Renders its `children` as a "no results" message, shown only while a search query matches no items. Also accepts standard `div` attributes. From ecd28f92d1f1a6af592a5b5d76405419edfeca02 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Tue, 14 Jul 2026 17:19:50 +1200 Subject: [PATCH 03/20] feat: added search to filter dropdown and radio dropdown --- .../components/filter-dropdown/filter-dropdown.tsx | 11 +++++++++++ .../src/components/radio-dropdown/radio-dropdown.tsx | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx b/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx index 8a626d02..4f302aa0 100644 --- a/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx +++ b/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx @@ -7,7 +7,9 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuEmpty, DropdownMenuLabel, + DropdownMenuSearch, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/dropdown-menu/dropdown-menu'; @@ -30,8 +32,13 @@ interface FilterDropdownProps { buttonClassName?: string; contentClassName?: string; disabled?: boolean; + searchPlaceholder?: string; + emptyPlaceholder?: string; } +/* + * TODO: Add searchPlaceholder and emptyPlaceholder to docs + */ const FilterDropdown = ({ label, options, @@ -41,6 +48,8 @@ const FilterDropdown = ({ buttonClassName, contentClassName, disabled = false, + searchPlaceholder = 'Search filters...', + emptyPlaceholder = 'No filters found', }: FilterDropdownProps) => { const hasSelectedFilters = selectedFilters.length > 0; const filteredOptions = options.filter( @@ -67,6 +76,8 @@ const FilterDropdown = ({ align="end" className={cn(styles['dropdown-menu-content'], contentClassName)} > + + {emptyPlaceholder} Filters {hasSelectedFilters && ( diff --git a/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx b/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx index dbbe2f11..5ca5e64a 100644 --- a/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx +++ b/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx @@ -6,9 +6,11 @@ import { Button } from '@/components/button/button'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuEmpty, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSearch, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/dropdown-menu/dropdown-menu'; @@ -28,14 +30,21 @@ interface RadioDropdownProps { selectedValue: string; onSelect: (value: string) => void; className?: string; + searchPlaceholder?: string; + emptyPlaceholder?: string; } +/* + * TODO: Add searchPlaceholder and emptyPlaceholder to the docs + */ const RadioDropdown = ({ label, options, selectedValue, onSelect, className, + searchPlaceholder = 'Search options...', + emptyPlaceholder = 'No options found', }: RadioDropdownProps) => { const selectedOption = options.find((opt) => opt.value === selectedValue); const hasSelectedCount = selectedOption?.count !== undefined; @@ -57,6 +66,8 @@ const RadioDropdown = ({ + + {emptyPlaceholder} {label} From 757cb6d096530a22e3c79830fd07c3f4b4fc914d Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 11:22:41 +1200 Subject: [PATCH 04/20] fix: removed unused imports --- packages/demo/src/components/demo/dropdown-menu.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/demo/src/components/demo/dropdown-menu.tsx b/packages/demo/src/components/demo/dropdown-menu.tsx index c2faa87f..6a9d51b3 100644 --- a/packages/demo/src/components/demo/dropdown-menu.tsx +++ b/packages/demo/src/components/demo/dropdown-menu.tsx @@ -1,7 +1,5 @@ import { useState } from "react"; import { - Avatar, - AvatarFallback, Button, DropdownMenu, DropdownMenuTrigger, From 3fd3a88e8a66a94dfd94b2aec5bede4453ee3826 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 11:34:04 +1200 Subject: [PATCH 05/20] feat: updated radio dropdown docs --- .../src/content/components/radio-dropdown.mdx | 37 +++++++++++++++---- .../radio-dropdown/radio-dropdown.tsx | 3 -- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/demo/src/content/components/radio-dropdown.mdx b/packages/demo/src/content/components/radio-dropdown.mdx index 81884491..d0cf1fe4 100644 --- a/packages/demo/src/content/components/radio-dropdown.mdx +++ b/packages/demo/src/content/components/radio-dropdown.mdx @@ -10,7 +10,7 @@ import { RadioDropdownDemo } from "@demo/components/demo/radio-dropdown"; Radio Dropdown is a compact control for picking a single option from a list — typically a filter such as a status or category. It renders as a [Button](button) showing the current selection, opening a [Dropdown Menu](dropdown-menu) of radio options. Each option can carry an optional `count`, shown as a [Badge](badge) on the trigger and inline in the menu, which is handy for surfacing how many items match each filter. -It is a controlled component: you provide the `options` and the `selectedValue`, and it calls `onSelect` when the user chooses a different one. Options with an empty `value` or `label` are filtered out automatically. +It is a controlled component: you provide the `options` and the `selectedValue`, and it calls `onSelect` when the user chooses a different one. Options with an empty `value` or `label` are filtered out automatically. The menu is searchable by default, so it stays usable as the list of options grows. ## Usage @@ -42,12 +42,33 @@ The `label` is shown on the trigger before anything is selected, and as the head +## Search + +Every Radio Dropdown includes the [Dropdown Menu](dropdown-menu)'s in-place search. The search box is hidden until the user starts typing with the menu open — the first keystroke reveals it and seeds the query. Options that don't match hide themselves, matching against each option's `label`. + +Use `searchPlaceholder` to describe what is being searched, and `emptyPlaceholder` for the message shown when a query matches nothing: + +```tsx + +``` + +The empty message only appears while a query is active — an empty `options` array renders an empty menu rather than this message. + ## Props -| Name | Description | Type | Default | Required | -| --------------- | ------------------------------------------------------------------- | ---------------------------------------------------- | ------- | -------- | -| `label` | Trigger text before selection, and the menu heading. | `string` | — | ✅ | -| `options` | The selectable options. Each is `{ value, label, count? }`. | `{ value: string; label: string; count?: number }[]` | — | ✅ | -| `selectedValue` | The `value` of the currently selected option. | `string` | — | ✅ | -| `onSelect` | Called with the chosen option's `value` when the selection changes. | `(value: string) => void` | — | ✅ | -| `className` | Additional CSS classes applied to the trigger button. | `string` | — | ❌ | +| Name | Description | Type | Default | Required | +| ------------------- | ------------------------------------------------------------------- | ---------------------------------------------------- | ------------------- | -------- | +| `label` | Trigger text before selection, and the menu heading. | `string` | — | ✅ | +| `options` | The selectable options. Each is `{ value, label, count? }`. | `{ value: string; label: string; count?: number }[]` | — | ✅ | +| `selectedValue` | The `value` of the currently selected option. | `string` | — | ✅ | +| `onSelect` | Called with the chosen option's `value` when the selection changes. | `(value: string) => void` | — | ✅ | +| `className` | Additional CSS classes applied to the trigger button. | `string` | — | ❌ | +| `searchPlaceholder` | Placeholder text for the in-menu search input. | `string` | `Search options...` | ❌ | +| `emptyPlaceholder` | Message shown when a search query matches no options. | `string` | `No options found` | ❌ | diff --git a/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx b/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx index 5ca5e64a..6791babf 100644 --- a/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx +++ b/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx @@ -34,9 +34,6 @@ interface RadioDropdownProps { emptyPlaceholder?: string; } -/* - * TODO: Add searchPlaceholder and emptyPlaceholder to the docs - */ const RadioDropdown = ({ label, options, From 6a6f0c533d06a201becce4edfb3a41c7f0d678e4 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 11:34:18 +1200 Subject: [PATCH 06/20] feat: updated filter dropdown docs --- .../content/components/filter-dropdown.mdx | 42 +++++++++++++++---- .../filter-dropdown/filter-dropdown.tsx | 3 -- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/demo/src/content/components/filter-dropdown.mdx b/packages/demo/src/content/components/filter-dropdown.mdx index 6c4985d6..74e9ef72 100644 --- a/packages/demo/src/content/components/filter-dropdown.mdx +++ b/packages/demo/src/content/components/filter-dropdown.mdx @@ -8,7 +8,7 @@ import { FilterDropdownDemo } from "@demo/components/demo/filter-dropdown"; ## Overview -The Filter Dropdown component provides a multi-select dropdown for filtering content in tables or lists. The dropdown menu lists checkbox options that users can toggle on and off, with a "Clear all" action to reset selections within the component. +The Filter Dropdown component provides a multi-select dropdown for filtering content in tables or lists. The dropdown menu lists checkbox options that users can toggle on and off, with a "Clear all" action to reset selections within the component. The menu is searchable by default, so it stays usable as the list of options grows. ## Usage @@ -37,13 +37,37 @@ Basic usage: +## Search + +Every Filter Dropdown includes the [Dropdown Menu](dropdown-menu)'s in-place search. The search box is hidden until the user starts typing with the menu open — the first keystroke reveals it and seeds the query. Options that don't match hide themselves, matching against each option's `label`. + +Use `searchPlaceholder` to describe what is being searched, and `emptyPlaceholder` for the message shown when a query matches nothing: + +```tsx + +``` + +The empty message only appears while a query is active — an empty `options` array renders an empty menu rather than this message. + +Note that the "Filters" heading and its "Clear all" action are hidden while a search is active, so results stay compact. Clearing the query brings them back; selections made during a search are unaffected. + ## Props -| Name | Description | Type | Default | Required | -| ----------------- | --------------------------------------------------------------------------------- | ------------------------------------- | ------- | -------- | -| `label` | The text displayed on the trigger button. | `string` | - | ✅ | -| `options` | The list of filter options to display in the dropdown. | `{ value: string; label: string; }[]` | - | ✅ | -| `selectedFilters` | Array of currently selected filter values. | `string[]` | - | ✅ | -| `onToggleFilter` | Callback fired when a filter option is checked or unchecked. | `(value: string) => void` | - | ✅ | -| `onClearAll` | Callback fired when the "Clear all" button is clicked. | `() => void` | - | ✅ | -| `disabled` | When `true`, the trigger button is unclickable and the dropdown cannot be opened. | `boolean` | `false` | ❌ | +| Name | Description | Type | Default | Required | +| ------------------- | --------------------------------------------------------------------------------- | ------------------------------------- | ------------------- | -------- | +| `label` | The text displayed on the trigger button. | `string` | - | ✅ | +| `options` | The list of filter options to display in the dropdown. | `{ value: string; label: string; }[]` | - | ✅ | +| `selectedFilters` | Array of currently selected filter values. | `string[]` | - | ✅ | +| `onToggleFilter` | Callback fired when a filter option is checked or unchecked. | `(value: string) => void` | - | ✅ | +| `onClearAll` | Callback fired when the "Clear all" button is clicked. | `() => void` | - | ✅ | +| `disabled` | When `true`, the trigger button is unclickable and the dropdown cannot be opened. | `boolean` | `false` | ❌ | +| `searchPlaceholder` | Placeholder text for the in-menu search input. | `string` | `Search filters...` | ❌ | +| `emptyPlaceholder` | Message shown when a search query matches no options. | `string` | `No filters found` | ❌ | diff --git a/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx b/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx index 4f302aa0..54720091 100644 --- a/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx +++ b/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx @@ -36,9 +36,6 @@ interface FilterDropdownProps { emptyPlaceholder?: string; } -/* - * TODO: Add searchPlaceholder and emptyPlaceholder to docs - */ const FilterDropdown = ({ label, options, From 6e520024c658d785d60cd59444a2f90d64ef4e8a Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 11:47:31 +1200 Subject: [PATCH 07/20] feat: updated DropdownMenuEmpty for screen readers --- .../dropdown-menu/dropdown-menu.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index a31fab7e..461110d9 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -416,7 +416,11 @@ const DropdownMenuSearch = ({ DropdownMenuSearch.displayName = 'DropdownMenuSearch'; /* - * Empty search state - renders only when a query doesn't match any items + * Empty search state - shows its message only when a query doesn't match any items + * + * The wrapper stays mounted (empty, unstyled, zero height) so screen readers have + * the live region in the tree before the message arrives - a region inserted with + * its text already in place is frequently missed */ const DropdownMenuEmpty = ({ className, @@ -424,11 +428,19 @@ const DropdownMenuEmpty = ({ ...props }: React.HTMLAttributes) => { const ctx = useDropdownMenuSearch(); - const query = ctx?.query.trim() ?? ''; - if (!ctx || !query || ctx.matchCount > 0) return null; + if (!ctx) return null; + + const query = ctx.query.trim(); + const isEmpty = !!query && ctx.matchCount === 0; + return ( -
- {children} +
+ {isEmpty ? children : null}
); }; From 495ae5eaa26c4e8b033b2aa7abfbfeaa247c6d16 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 11:52:53 +1200 Subject: [PATCH 08/20] feat: removed search from dropdown menu with submenu example --- packages/demo/src/components/demo/dropdown-menu.tsx | 2 -- packages/demo/src/content/components/dropdown-menu.mdx | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/demo/src/components/demo/dropdown-menu.tsx b/packages/demo/src/components/demo/dropdown-menu.tsx index 6a9d51b3..b4833e92 100644 --- a/packages/demo/src/components/demo/dropdown-menu.tsx +++ b/packages/demo/src/components/demo/dropdown-menu.tsx @@ -229,7 +229,6 @@ export const DropdownMenuDemo = ({ - Back Forward Reload @@ -266,7 +265,6 @@ export const DropdownMenuDemo = ({ Settings - No results found
diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index d6b93f3f..b6b5e27b 100644 --- a/packages/demo/src/content/components/dropdown-menu.mdx +++ b/packages/demo/src/content/components/dropdown-menu.mdx @@ -124,7 +124,6 @@ Use `DropdownMenuSub`, `DropdownMenuSubTrigger`, and `DropdownMenuSubContent` to ```jsx - Back Forward Reload @@ -161,7 +160,6 @@ Use `DropdownMenuSub`, `DropdownMenuSubTrigger`, and `DropdownMenuSubContent` to Settings - No results found ``` From 73fc5475953654b6f6ae05a9678d43d4e67cc1a1 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 11:54:25 +1200 Subject: [PATCH 09/20] fix: comment typo --- packages/ui/src/components/dropdown-menu/dropdown-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index 461110d9..8440671e 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -25,7 +25,7 @@ type DropdownMenuSearchContextValue = { reveal: (seed: string) => void; query: string; setQuery: (value: string) => void; - /* Re focus the search input from outside DropdownMenuSearch */ + /* Re-focus the search input from outside DropdownMenuSearch */ focusSignal: number; requestFocus: () => void; /* Item registry, used for the optional empty state */ From cb67313f15a494768157d14bd4576e96c29c4727 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 12:01:51 +1200 Subject: [PATCH 10/20] feat: update ellipsis on dropdown menu --- .../demo/src/components/demo/dropdown-menu.tsx | 4 ++-- .../src/content/components/dropdown-menu.mdx | 18 +++++++++--------- .../components/dropdown-menu/dropdown-menu.tsx | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/demo/src/components/demo/dropdown-menu.tsx b/packages/demo/src/components/demo/dropdown-menu.tsx index b4833e92..03791e54 100644 --- a/packages/demo/src/components/demo/dropdown-menu.tsx +++ b/packages/demo/src/components/demo/dropdown-menu.tsx @@ -355,7 +355,7 @@ export const DropdownMenuDemo = ({ - + Toggle columns {Object.entries(COLUMN_LABELS).map(([key, label]) => ( - + Cut Copy Paste diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index b6b5e27b..0b9c457b 100644 --- a/packages/demo/src/content/components/dropdown-menu.mdx +++ b/packages/demo/src/content/components/dropdown-menu.mdx @@ -207,7 +207,7 @@ By default the search box is hidden and reveals as soon as you start typing — Ada Lovelace - {/* …more members… */} + {/* ...more members... */} No members found ``` @@ -220,7 +220,7 @@ Pass `alwaysVisible` to show the search box the moment the menu opens instead of ```jsx - + Toggle columns Email - {/* …more columns… */} + {/* ...more columns... */} No columns found ``` @@ -242,7 +242,7 @@ Items nested in a `DropdownMenuSub` are flattened into the main list while searc ```jsx - + Cut Copy Paste @@ -353,11 +353,11 @@ Flattening only works when `DropdownMenuSubContent` is placed directly inside `D Also accepts standard `input` attributes, except `value` and `onChange`, which are managed internally. -| Name | Description | Type | Default | Required | -| --------------- | ----------------------------------------------------------------- | ----------- | --------- | -------- | -| `alwaysVisible` | Show the input immediately instead of revealing on first keypress | `boolean` | `false` | ❌ | -| `placeholder` | Placeholder text for the input | `string` | `Search…` | ❌ | -| `icon` | Custom leading icon; defaults to a search icon | `ReactNode` | - | ❌ | +| Name | Description | Type | Default | Required | +| --------------- | ----------------------------------------------------------------- | ----------- | ----------- | -------- | +| `alwaysVisible` | Show the input immediately instead of revealing on first keypress | `boolean` | `false` | ❌ | +| `placeholder` | Placeholder text for the input | `string` | `Search...` | ❌ | +| `icon` | Custom leading icon; defaults to a search icon | `ReactNode` | - | ❌ | ### DropdownMenuEmpty diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index 8440671e..ca3139fa 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -328,7 +328,7 @@ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; */ const DropdownMenuSearch = ({ className, - placeholder = 'Search…', + placeholder = 'Search...', icon, /* Render the input immediately instead of revealing on first keypress */ alwaysVisible = false, From 7c6cd0eac7591c375ad1f7acebd999c18c90f216 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 12:11:08 +1200 Subject: [PATCH 11/20] feat: added aria label to input on dropdown menu --- .../demo/src/content/components/dropdown-menu.mdx | 13 +++++++------ .../src/components/dropdown-menu/dropdown-menu.tsx | 3 +++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index 0b9c457b..6a3d8ce3 100644 --- a/packages/demo/src/content/components/dropdown-menu.mdx +++ b/packages/demo/src/content/components/dropdown-menu.mdx @@ -353,12 +353,13 @@ Flattening only works when `DropdownMenuSubContent` is placed directly inside `D Also accepts standard `input` attributes, except `value` and `onChange`, which are managed internally. -| Name | Description | Type | Default | Required | -| --------------- | ----------------------------------------------------------------- | ----------- | ----------- | -------- | -| `alwaysVisible` | Show the input immediately instead of revealing on first keypress | `boolean` | `false` | ❌ | -| `placeholder` | Placeholder text for the input | `string` | `Search...` | ❌ | -| `icon` | Custom leading icon; defaults to a search icon | `ReactNode` | - | ❌ | +| Name | Description | Type | Default | Required | +| --------------- | ----------------------------------------------------------------- | ----------- | ------------- | -------- | +| `alwaysVisible` | Show the input immediately instead of revealing on first keypress | `boolean` | `false` | ❌ | +| `placeholder` | Placeholder text for the input | `string` | `Search...` | ❌ | +| `icon` | Custom leading icon; defaults to a search icon | `ReactNode` | - | ❌ | +| `aria-label` | Accessible name for the input; defaults to the `placeholder` text | `string` | `placeholder` | ❌ | ### DropdownMenuEmpty -Renders its `children` as a "no results" message, shown only while a search query matches no items. Also accepts standard `div` attributes. +Renders its `children` as a "no results" message, shown only while a search query matches no items. It is a live region (`role="status"`), so the message is announced when filtering empties the list. Also accepts standard `div` attributes. diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index ca3139fa..b0b62f6b 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -333,6 +333,8 @@ const DropdownMenuSearch = ({ /* Render the input immediately instead of revealing on first keypress */ alwaysVisible = false, onKeyDown, + /* Pulled out of props so the placeholder fallback below isn't overwritten by the spread */ + 'aria-label': ariaLabel, ...props }: Omit, 'value' | 'onChange'> & { icon?: React.ReactNode; @@ -378,6 +380,7 @@ const DropdownMenuSearch = ({ className={cn(styles['dropdown-menu-search-input'], className)} value={ctx.query} placeholder={placeholder} + aria-label={ariaLabel ?? placeholder} onChange={(event) => ctx.setQuery(event.target.value)} onKeyDown={(event) => { onKeyDown?.(event); From faf8c9b7bfc021da6c68ca7e247fdcc020ed7a11 Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 12:20:17 +1200 Subject: [PATCH 12/20] feat: stable registerItem/unregisterItem/enabled values instead of the whole ctx --- .../src/components/dropdown-menu/dropdown-menu.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index b0b62f6b..38e029c3 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -103,11 +103,17 @@ function useFilterableItem(textValue: string | undefined, children: React.ReactN const query = ctx?.query.trim().toLowerCase() ?? ''; const visible = !query || (textValue ?? getNodeText(children)).toLowerCase().includes(query); + // registerItem/unregisterItem are stable, so this now only + // fires when an item's own visibility actually flips + const enabled = ctx?.enabled ?? false; + const registerItem = ctx?.registerItem; + const unregisterItem = ctx?.unregisterItem; + React.useEffect(() => { - if (!ctx || !ctx.enabled) return; - ctx.registerItem(id, visible); - return () => ctx.unregisterItem(id); - }, [ctx, ctx?.enabled, id, visible]); + if (!enabled || !registerItem || !unregisterItem) return; + registerItem(id, visible); + return () => unregisterItem(id); + }, [enabled, registerItem, unregisterItem, id, visible]); return visible; } From 6894c2cd58fe466d2cf0435829d559bd108efb7e Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 12:37:34 +1200 Subject: [PATCH 13/20] feat: added forward ref to dropdown menu search input --- .../src/content/components/dropdown-menu.mdx | 13 +- .../dropdown-menu/dropdown-menu.tsx | 194 ++++++++++-------- 2 files changed, 117 insertions(+), 90 deletions(-) diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index 6a3d8ce3..46a36361 100644 --- a/packages/demo/src/content/components/dropdown-menu.mdx +++ b/packages/demo/src/content/components/dropdown-menu.mdx @@ -353,12 +353,13 @@ Flattening only works when `DropdownMenuSubContent` is placed directly inside `D Also accepts standard `input` attributes, except `value` and `onChange`, which are managed internally. -| Name | Description | Type | Default | Required | -| --------------- | ----------------------------------------------------------------- | ----------- | ------------- | -------- | -| `alwaysVisible` | Show the input immediately instead of revealing on first keypress | `boolean` | `false` | ❌ | -| `placeholder` | Placeholder text for the input | `string` | `Search...` | ❌ | -| `icon` | Custom leading icon; defaults to a search icon | `ReactNode` | - | ❌ | -| `aria-label` | Accessible name for the input; defaults to the `placeholder` text | `string` | `placeholder` | ❌ | +| Name | Description | Type | Default | Required | +| --------------- | ----------------------------------------------------------------------------- | ----------------------- | ------------- | -------- | +| `alwaysVisible` | Show the input immediately instead of revealing on first keypress | `boolean` | `false` | ❌ | +| `placeholder` | Placeholder text for the input | `string` | `Search...` | ❌ | +| `icon` | Custom leading icon; defaults to a search icon | `ReactNode` | - | ❌ | +| `aria-label` | Accessible name for the input; defaults to the `placeholder` text | `string` | `placeholder` | ❌ | +| `ref` | Forwarded to the underlying ``; `null` while the input is not rendered | `Ref` | - | ❌ | ### DropdownMenuEmpty diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index 38e029c3..2a151682 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -330,98 +330,124 @@ const DropdownMenuContent = React.forwardRef< DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; /* - * Search input - renders the search input and manages the search context + * Point every given ref at the same node. Written as a plain assignment (rather than a + * callback ref returning a cleanup) so it behaves identically on React 18 and 19 - 18 + * ignores callback ref cleanups and passes null on unmount instead */ -const DropdownMenuSearch = ({ - className, - placeholder = 'Search...', - icon, - /* Render the input immediately instead of revealing on first keypress */ - alwaysVisible = false, - onKeyDown, - /* Pulled out of props so the placeholder fallback below isn't overwritten by the spread */ - 'aria-label': ariaLabel, - ...props -}: Omit, 'value' | 'onChange'> & { +function assignRefs(node: T | null, ...refs: (React.Ref | undefined)[]) { + refs.forEach((ref) => { + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }); +} + +type DropdownMenuSearchProps = Omit< + React.InputHTMLAttributes, + 'value' | 'onChange' +> & { icon?: React.ReactNode; alwaysVisible?: boolean; -}) => { - const ctx = useDropdownMenuSearch(); - if (!ctx) { - throw new Error('DropdownMenuSearch must be used within a DropdownMenu'); - } - const { setEnabled, reveal, visible, focusSignal } = ctx; - - const inputRef = React.useRef(null); - - // Tell Content a search exists so it knows to intercept keystrokes - React.useEffect(() => { - setEnabled(true); - return () => setEnabled(false); - }, [setEnabled]); - - // Focus the input when it becomes visible AND whenever focus is requested from Content - React.useEffect(() => { - if (!visible) return; - const el = inputRef.current; - if (!el) return; - el.focus(); - const end = el.value.length; - el.setSelectionRange(end, end); - }, [visible, focusSignal]); - - // If always visible, reveal as soon as the menu opens - React.useEffect(() => { - if (alwaysVisible && !visible) reveal(''); - }, [alwaysVisible, visible, reveal]); - - if (!alwaysVisible && !visible) return null; - - return ( -
- {icon ?? } - ctx.setQuery(event.target.value)} - onKeyDown={(event) => { - onKeyDown?.(event); +}; - // Arrow keys move focus into the list - Radix won't do this for us because focus - // is on the input, not a menu item. Jump to the first/last currently-visible item - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - const menu = event.currentTarget.closest('[role="menu"]'); - const items = menu - ? Array.from( - menu.querySelectorAll( - '[role="menuitem"]:not([data-disabled]),' + - '[role="menuitemcheckbox"]:not([data-disabled]),' + - '[role="menuitemradio"]:not([data-disabled])' +/* + * Search input - renders the search input and manages the search context + */ +const DropdownMenuSearch = React.forwardRef( + ( + { + className, + placeholder = 'Search...', + icon, + /* Render the input immediately instead of revealing on first keypress */ + alwaysVisible = false, + onKeyDown, + /* Pulled out of props so the placeholder fallback below isn't overwritten by the spread */ + 'aria-label': ariaLabel, + ...props + }, + forwardedRef + ) => { + const ctx = useDropdownMenuSearch(); + if (!ctx) { + throw new Error('DropdownMenuSearch must be used within a DropdownMenu'); + } + const { setEnabled, reveal, visible, focusSignal } = ctx; + + const inputRef = React.useRef(null); + + // Tell Content a search exists so it knows to intercept keystrokes + React.useEffect(() => { + setEnabled(true); + return () => setEnabled(false); + }, [setEnabled]); + + // Focus the input when it becomes visible AND whenever focus is requested from Content + React.useEffect(() => { + if (!visible) return; + const el = inputRef.current; + if (!el) return; + el.focus(); + const end = el.value.length; + el.setSelectionRange(end, end); + }, [visible, focusSignal]); + + // If always visible, reveal as soon as the menu opens + React.useEffect(() => { + if (alwaysVisible && !visible) reveal(''); + }, [alwaysVisible, visible, reveal]); + + if (!alwaysVisible && !visible) return null; + + return ( +
+ {icon ?? } + { + assignRefs(node, inputRef, forwardedRef); + }} + data-dropdown-search="" + className={cn(styles['dropdown-menu-search-input'], className)} + value={ctx.query} + placeholder={placeholder} + aria-label={ariaLabel ?? placeholder} + onChange={(event) => ctx.setQuery(event.target.value)} + onKeyDown={(event) => { + onKeyDown?.(event); + + // Arrow keys move focus into the list - Radix won't do this for us because focus + // is on the input, not a menu item. Jump to the first/last currently-visible item + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + const menu = event.currentTarget.closest('[role="menu"]'); + const items = menu + ? Array.from( + menu.querySelectorAll( + '[role="menuitem"]:not([data-disabled]),' + + '[role="menuitemcheckbox"]:not([data-disabled]),' + + '[role="menuitemradio"]:not([data-disabled])' + ) ) - ) - : []; - if (items.length) { - event.preventDefault(); - (event.key === 'ArrowDown' ? items[0] : items[items.length - 1]).focus(); + : []; + if (items.length) { + event.preventDefault(); + (event.key === 'ArrowDown' ? items[0] : items[items.length - 1]).focus(); + } + return; } - return; - } - // Bubble events to Radix (close / select / tab out) - if (['Enter', 'Escape', 'Tab'].includes(event.key)) return; + // Bubble events to Radix (close / select / tab out) + if (['Enter', 'Escape', 'Tab'].includes(event.key)) return; - // Everything else stays in the input so Radix typeahead / shortcuts don't fire - event.stopPropagation(); - }} - {...props} - /> -
- ); -}; + // Everything else stays in the input so Radix typeahead / shortcuts don't fire + event.stopPropagation(); + }} + {...props} + /> +
+ ); + } +); DropdownMenuSearch.displayName = 'DropdownMenuSearch'; /* From 8a5e969fb845b64f5471075397767a1d2b13d69a Mon Sep 17 00:00:00 2001 From: giuliana-gladeye Date: Mon, 20 Jul 2026 13:55:50 +1200 Subject: [PATCH 14/20] feat: added searchable prop to both FilterDropdown and RadioDropdown --- .../src/components/demo/filter-dropdown.tsx | 52 +++++++++++++++---- .../src/components/demo/radio-dropdown.tsx | 36 ++++++++++++- .../content/components/filter-dropdown.mdx | 22 +++++--- .../src/content/components/radio-dropdown.mdx | 34 +++++++----- .../filter-dropdown/filter-dropdown.tsx | 11 +++- .../radio-dropdown/radio-dropdown.tsx | 11 +++- 6 files changed, 130 insertions(+), 36 deletions(-) diff --git a/packages/demo/src/components/demo/filter-dropdown.tsx b/packages/demo/src/components/demo/filter-dropdown.tsx index a4a60762..4ca46db7 100644 --- a/packages/demo/src/components/demo/filter-dropdown.tsx +++ b/packages/demo/src/components/demo/filter-dropdown.tsx @@ -1,12 +1,33 @@ -import { useState } from "react"; +import { useState, type Dispatch, type SetStateAction } from "react"; import { FilterDropdown } from "@eqtylab/equality"; -export const FilterDropdownDemo = () => { +const TEAMS = [ + { value: "applied-research", label: "Applied research" }, + { value: "data-platform", label: "Data platform" }, + { value: "design-system", label: "Design system" }, + { value: "developer-relations", label: "Developer relations" }, + { value: "governance", label: "Governance" }, + { value: "infrastructure", label: "Infrastructure" }, + { value: "legal", label: "Legal" }, + { value: "security", label: "Security" }, + { value: "solutions-engineering", label: "Solutions engineering" }, + { value: "trust-and-safety", label: "Trust and safety" }, +]; + +export const FilterDropdownDemo = ({ + variant = "default", +}: { + variant?: "default" | "searchable"; +}) => { const [selectedFilters, setSelectedFilters] = useState([]); + const [selectedTeams, setSelectedTeams] = useState([]); - const onToggleFilter = (value: string) => { - setSelectedFilters((prev: string[]) => { + const toggle = ( + setter: Dispatch>, + value: string, + ) => { + setter((prev: string[]) => { if (prev.includes(value)) { return prev.filter((v) => v !== value); } @@ -14,9 +35,22 @@ export const FilterDropdownDemo = () => { }); }; - const onClearAll = () => { - setSelectedFilters([]); - }; + if (variant === "searchable") { + return ( +
+ toggle(setSelectedTeams, value)} + onClearAll={() => setSelectedTeams([])} + searchable + searchPlaceholder="Search teams..." + emptyPlaceholder="No teams match" + /> +
+ ); + } return ( { { value: "project", label: "Project" }, ]} selectedFilters={selectedFilters} - onToggleFilter={onToggleFilter} - onClearAll={onClearAll} + onToggleFilter={(value) => toggle(setSelectedFilters, value)} + onClearAll={() => setSelectedFilters([])} /> ); }; diff --git a/packages/demo/src/components/demo/radio-dropdown.tsx b/packages/demo/src/components/demo/radio-dropdown.tsx index b8c43f28..33f72a5b 100644 --- a/packages/demo/src/components/demo/radio-dropdown.tsx +++ b/packages/demo/src/components/demo/radio-dropdown.tsx @@ -1,15 +1,49 @@ import { RadioDropdown } from "@eqtylab/equality"; import { useState } from "react"; -export const RadioDropdownDemo = () => { +const CATEGORIES = [ + { value: "access-control", label: "Access control", count: 12 }, + { value: "audit-logging", label: "Audit logging", count: 4 }, + { value: "data-retention", label: "Data retention", count: 9 }, + { value: "encryption", label: "Encryption", count: 3 }, + { value: "incident-response", label: "Incident response", count: 7 }, + { value: "model-evaluation", label: "Model evaluation", count: 15 }, + { value: "privacy", label: "Privacy", count: 6 }, + { value: "third-party-risk", label: "Third party risk", count: 2 }, + { value: "training-data", label: "Training data", count: 11 }, + { value: "transparency", label: "Transparency", count: 8 }, +]; + +export const RadioDropdownDemo = ({ + variant = "default", +}: { + variant?: "default" | "searchable"; +}) => { const [selectedStatus, setSelectedStatus] = useState<"active" | "archived">( "active", ); + const [category, setCategory] = useState("model-evaluation"); const handleStatusChange = (value: "active" | "archived") => { setSelectedStatus(value); }; + if (variant === "searchable") { + return ( +
+ +
+ ); + } + return ( ``` -## Example +## Default ## Search -Every Filter Dropdown includes the [Dropdown Menu](dropdown-menu)'s in-place search. The search box is hidden until the user starts typing with the menu open — the first keystroke reveals it and seeds the query. Options that don't match hide themselves, matching against each option's `label`. +Pass `searchable` to add the [Dropdown Menu](dropdown-menu)'s in-place search. It is off by default, reach for it once the list is long enough. -Use `searchPlaceholder` to describe what is being searched, and `emptyPlaceholder` for the message shown when a query matches nothing: +The search box is hidden until the user starts typing with the menu open — the first keystroke reveals it and seeds the query. Options that don't match hide themselves, matching against each option's `label`. + + + +Use `searchPlaceholder` to describe what is being searched, and `emptyPlaceholder` for the message shown when a query matches nothing. Both are ignored unless `searchable` is set: ```tsx ``` @@ -69,5 +74,6 @@ Note that the "Filters" heading and its "Clear all" action are hidden while a se | `onToggleFilter` | Callback fired when a filter option is checked or unchecked. | `(value: string) => void` | - | ✅ | | `onClearAll` | Callback fired when the "Clear all" button is clicked. | `() => void` | - | ✅ | | `disabled` | When `true`, the trigger button is unclickable and the dropdown cannot be opened. | `boolean` | `false` | ❌ | -| `searchPlaceholder` | Placeholder text for the in-menu search input. | `string` | `Search filters...` | ❌ | -| `emptyPlaceholder` | Message shown when a search query matches no options. | `string` | `No filters found` | ❌ | +| `searchable` | Adds in-menu search, revealed on the first keystroke. | `boolean` | `false` | ❌ | +| `searchPlaceholder` | Placeholder text for the in-menu search input. Requires `searchable`. | `string` | `Search filters...` | ❌ | +| `emptyPlaceholder` | Message shown when a search query matches no options. Requires `searchable`. | `string` | `No filters found` | ❌ | diff --git a/packages/demo/src/content/components/radio-dropdown.mdx b/packages/demo/src/content/components/radio-dropdown.mdx index d0cf1fe4..207c5ebc 100644 --- a/packages/demo/src/content/components/radio-dropdown.mdx +++ b/packages/demo/src/content/components/radio-dropdown.mdx @@ -10,7 +10,7 @@ import { RadioDropdownDemo } from "@demo/components/demo/radio-dropdown"; Radio Dropdown is a compact control for picking a single option from a list — typically a filter such as a status or category. It renders as a [Button](button) showing the current selection, opening a [Dropdown Menu](dropdown-menu) of radio options. Each option can carry an optional `count`, shown as a [Badge](badge) on the trigger and inline in the menu, which is handy for surfacing how many items match each filter. -It is a controlled component: you provide the `options` and the `selectedValue`, and it calls `onSelect` when the user chooses a different one. Options with an empty `value` or `label` are filtered out automatically. The menu is searchable by default, so it stays usable as the list of options grows. +It is a controlled component: you provide the `options` and the `selectedValue`, and it calls `onSelect` when the user chooses a different one. Options with an empty `value` or `label` are filtered out automatically. For longer lists, `searchable` adds in-menu search. ## Usage @@ -44,9 +44,13 @@ The `label` is shown on the trigger before anything is selected, and as the head ## Search -Every Radio Dropdown includes the [Dropdown Menu](dropdown-menu)'s in-place search. The search box is hidden until the user starts typing with the menu open — the first keystroke reveals it and seeds the query. Options that don't match hide themselves, matching against each option's `label`. +Pass `searchable` to add the [Dropdown Menu](dropdown-menu)'s in-place search. It is off by default. Reach for it once the list is long enough. -Use `searchPlaceholder` to describe what is being searched, and `emptyPlaceholder` for the message shown when a query matches nothing: +The search box is hidden until the user starts typing with the menu open — the first keystroke reveals it and seeds the query. Options that don't match hide themselves, matching against each option's `label`. + + + +Use `searchPlaceholder` to describe what is being searched, and `emptyPlaceholder` for the message shown when a query matches nothing. Both are ignored unless `searchable` is set: ```tsx ``` @@ -63,12 +68,13 @@ The empty message only appears while a query is active — an empty `options` ar ## Props -| Name | Description | Type | Default | Required | -| ------------------- | ------------------------------------------------------------------- | ---------------------------------------------------- | ------------------- | -------- | -| `label` | Trigger text before selection, and the menu heading. | `string` | — | ✅ | -| `options` | The selectable options. Each is `{ value, label, count? }`. | `{ value: string; label: string; count?: number }[]` | — | ✅ | -| `selectedValue` | The `value` of the currently selected option. | `string` | — | ✅ | -| `onSelect` | Called with the chosen option's `value` when the selection changes. | `(value: string) => void` | — | ✅ | -| `className` | Additional CSS classes applied to the trigger button. | `string` | — | ❌ | -| `searchPlaceholder` | Placeholder text for the in-menu search input. | `string` | `Search options...` | ❌ | -| `emptyPlaceholder` | Message shown when a search query matches no options. | `string` | `No options found` | ❌ | +| Name | Description | Type | Default | Required | +| ------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------- | -------- | +| `label` | Trigger text before selection, and the menu heading. | `string` | — | ✅ | +| `options` | The selectable options. Each is `{ value, label, count? }`. | `{ value: string; label: string; count?: number }[]` | — | ✅ | +| `selectedValue` | The `value` of the currently selected option. | `string` | — | ✅ | +| `onSelect` | Called with the chosen option's `value` when the selection changes. | `(value: string) => void` | — | ✅ | +| `className` | Additional CSS classes applied to the trigger button. | `string` | — | ❌ | +| `searchable` | Adds in-menu search, revealed on the first keystroke. | `boolean` | `false` | ❌ | +| `searchPlaceholder` | Placeholder text for the in-menu search input. Requires `searchable`. | `string` | `Search options...` | ❌ | +| `emptyPlaceholder` | Message shown when a search query matches no options. Requires `searchable`. | `string` | `No options found` | ❌ | diff --git a/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx b/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx index 54720091..605af33c 100644 --- a/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx +++ b/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx @@ -32,6 +32,8 @@ interface FilterDropdownProps { buttonClassName?: string; contentClassName?: string; disabled?: boolean; + /* Opt in to in-menu search */ + searchable?: boolean; searchPlaceholder?: string; emptyPlaceholder?: string; } @@ -45,6 +47,7 @@ const FilterDropdown = ({ buttonClassName, contentClassName, disabled = false, + searchable = false, searchPlaceholder = 'Search filters...', emptyPlaceholder = 'No filters found', }: FilterDropdownProps) => { @@ -73,8 +76,12 @@ const FilterDropdown = ({ align="end" className={cn(styles['dropdown-menu-content'], contentClassName)} > - - {emptyPlaceholder} + {searchable && ( + <> + + {emptyPlaceholder} + + )} Filters {hasSelectedFilters && ( diff --git a/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx b/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx index 6791babf..a842c4cb 100644 --- a/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx +++ b/packages/ui/src/components/radio-dropdown/radio-dropdown.tsx @@ -30,6 +30,8 @@ interface RadioDropdownProps { selectedValue: string; onSelect: (value: string) => void; className?: string; + /* Opt in to in-menu search */ + searchable?: boolean; searchPlaceholder?: string; emptyPlaceholder?: string; } @@ -40,6 +42,7 @@ const RadioDropdown = ({ selectedValue, onSelect, className, + searchable = false, searchPlaceholder = 'Search options...', emptyPlaceholder = 'No options found', }: RadioDropdownProps) => { @@ -63,8 +66,12 @@ const RadioDropdown = ({ - - {emptyPlaceholder} + {searchable && ( + <> + + {emptyPlaceholder} + + )} {label} From 2c5dde1379fc7994c8ede66858441b04255d7bd2 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 24 Jul 2026 15:39:42 -0400 Subject: [PATCH 15/20] Use avatar for team member dropdown search example --- .../src/components/demo/dropdown-menu.tsx | 24 +++++++++---------- .../src/components/avatar/avatar.module.css | 1 - 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/demo/src/components/demo/dropdown-menu.tsx b/packages/demo/src/components/demo/dropdown-menu.tsx index 03791e54..02b8e36a 100644 --- a/packages/demo/src/components/demo/dropdown-menu.tsx +++ b/packages/demo/src/components/demo/dropdown-menu.tsx @@ -17,17 +17,18 @@ import { DropdownMenuSubTrigger, DropdownMenuSearch, DropdownMenuEmpty, - Icon, + Avatar, + AvatarFallback, } from "@eqtylab/equality"; import { Settings, User, LogOut } from "lucide-react"; const MEMBERS = [ - { name: "Ada Lovelace", icon: "User" }, - { name: "Alan Turing", icon: "UserCog" }, - { name: "Grace Hopper", icon: "User" }, - { name: "Katherine Johnson", icon: "UserCog" }, - { name: "Linus Torvalds", icon: "UserStar" }, - { name: "Margaret Hamilton", icon: "User" }, + { name: "Ada Lovelace", initials: "AL" }, + { name: "Alan Turing", initials: "AT" }, + { name: "Grace Hopper", initials: "GH" }, + { name: "Katherine Johnson", initials: "KJ" }, + { name: "Linus Torvalds", initials: "LT" }, + { name: "Margaret Hamilton", initials: "MH" }, ]; const COLUMN_LABELS: Record = { @@ -329,12 +330,9 @@ export const DropdownMenuDemo = ({ textValue={person.name} onSelect={() => setAssignee(person.name)} > - + + {person.initials} + {person.name} ))} diff --git a/packages/ui/src/components/avatar/avatar.module.css b/packages/ui/src/components/avatar/avatar.module.css index d2030273..7da1c485 100644 --- a/packages/ui/src/components/avatar/avatar.module.css +++ b/packages/ui/src/components/avatar/avatar.module.css @@ -13,7 +13,6 @@ } .avatar-fallback { - @apply border-border border; @apply from-greyscale-500 to-greyscale-700 dark:from-greyscale-800 dark:to-greyscale-900 bg-gradient-to-br; @apply size-full; @apply text-greyscale-200 dark:text-text-secondary select-none font-semibold; From 8eff504b254622595e5d4345cac98720999ba73a Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 24 Jul 2026 16:10:11 -0400 Subject: [PATCH 16/20] Update avatar styling --- .../demo/src/content/components/avatar.mdx | 2 +- .../src/components/avatar/avatar.module.css | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/demo/src/content/components/avatar.mdx b/packages/demo/src/content/components/avatar.mdx index 960dd9e5..f5bfbda3 100644 --- a/packages/demo/src/content/components/avatar.mdx +++ b/packages/demo/src/content/components/avatar.mdx @@ -68,7 +68,7 @@ The `shape` prop controls the border radius of the avatar. ### Square - + ```tsx ... diff --git a/packages/ui/src/components/avatar/avatar.module.css b/packages/ui/src/components/avatar/avatar.module.css index 7da1c485..e9bcd532 100644 --- a/packages/ui/src/components/avatar/avatar.module.css +++ b/packages/ui/src/components/avatar/avatar.module.css @@ -13,7 +13,7 @@ } .avatar-fallback { - @apply from-greyscale-500 to-greyscale-700 dark:from-greyscale-800 dark:to-greyscale-900 bg-gradient-to-br; + @apply from-greyscale-500 to-greyscale-600 dark:from-greyscale-700 dark:to-greyscale-800 bg-gradient-to-b; @apply size-full; @apply text-greyscale-200 dark:text-text-secondary select-none font-semibold; @apply flex-center; @@ -68,7 +68,22 @@ @apply rounded-full; } -.avatar.square, -.avatar.square .avatar-fallback { +.avatar.square.sm, +.avatar.square.sm .avatar-fallback { + @apply rounded-sm; +} + +.avatar.square.md, +.avatar.square.md .avatar-fallback { + @apply rounded-md; +} + +.avatar.square.lg, +.avatar.square.lg .avatar-fallback { @apply rounded-lg; } + +.avatar.square.xl, +.avatar.square.xl .avatar-fallback { + @apply rounded-xl; +} From 48b5936d9edee7164e4c76dcac31f688f66bb36a Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 24 Jul 2026 17:16:17 -0400 Subject: [PATCH 17/20] Improve keyboard navigation when searching --- .../dropdown-menu/dropdown-menu.tsx | 99 ++++++++++++++++--- 1 file changed, 83 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index 2a151682..2ebd8906 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -28,6 +28,9 @@ type DropdownMenuSearchContextValue = { /* Re-focus the search input from outside DropdownMenuSearch */ focusSignal: number; requestFocus: () => void; + /* The Radix menu's id (read off the DOM), so the search input can aria-controls the list */ + listId: string | undefined; + setListId: (id: string | undefined) => void; /* Item registry, used for the optional empty state */ registerItem: (id: string, matches: boolean) => void; unregisterItem: (id: string) => void; @@ -134,6 +137,10 @@ const DropdownMenu = ({ const [focusSignal, setFocusSignal] = React.useState(0); const requestFocus = React.useCallback(() => setFocusSignal((n) => n + 1), []); + // Radix owns the menu's id; Content mirrors it here so the search input can point + // aria-controls at the list it filters + const [listId, setListId] = React.useState(undefined); + // Item registry for the empty state const itemsRef = React.useRef>(new Map()); const [matchCount, setMatchCount] = React.useState(0); @@ -186,6 +193,8 @@ const DropdownMenu = ({ setQuery, focusSignal, requestFocus, + listId, + setListId, registerItem, unregisterItem, matchCount, @@ -197,6 +206,7 @@ const DropdownMenu = ({ query, focusSignal, requestFocus, + listId, registerItem, unregisterItem, matchCount, @@ -280,24 +290,67 @@ const DropdownMenuSub = ({ return {children}; }; +/** Shared selector for the menu items search/keyboard nav jump between */ +const MENU_ITEM_SELECTOR = + '[role="menuitem"]:not([data-disabled]),' + + '[role="menuitemcheckbox"]:not([data-disabled]),' + + '[role="menuitemradio"]:not([data-disabled])'; + /* - * Content - intercepts the first printable key to reveal the search input + * Content - intercepts the first printable key to reveal the search input, and sends + * ArrowUp back to the search input when it's pressed on the first item */ const DropdownMenuContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, onKeyDown, ...props }, ref) => { +>(({ className, sideOffset = 4, onKeyDown, children, ...props }, ref) => { const ctx = useDropdownMenuSearch(); + const setListId = ctx?.setListId; + const searching = !!ctx && ctx.query.trim().length > 0; + const resultCount = ctx?.matchCount ?? 0; + + // Stable ref so React attaches once (mount) / detaches once (unmount) rather than + // flip-flopping setListId every render, which a fresh inline callback would trigger + const composedContentRef = React.useCallback( + (node: HTMLDivElement | null) => { + assignRefs(node, ref); + // Read Radix's generated id rather than override it (the trigger's aria-controls + // depends on it); the search input then points aria-controls at the same list + setListId?.(node?.id || undefined); + }, + [ref, setListId] + ); return ( { onKeyDown?.(event); - if (!ctx?.enabled || event.defaultPrevented) return; + if (!ctx?.enabled) return; + + // ArrowUp on the first item sends focus back to the search input instead of doing + // nothing (the roving focus group doesn't loop). Handled ahead of the + // defaultPrevented bail-out below because that group already calls preventDefault() + // on ArrowUp - for its own empty, non-looping candidate search - before the event + // bubbles up to us. Tab is deliberately left to Radix's standard menu handling. + if (event.key === 'ArrowUp' && ctx.visible) { + const itemTarget = (event.target as HTMLElement | null)?.closest( + MENU_ITEM_SELECTOR + ); + const items = Array.from( + event.currentTarget.querySelectorAll(MENU_ITEM_SELECTOR) + ); + if (itemTarget && items[0] === itemTarget) { + event.preventDefault(); + ctx.requestFocus(); + return; + } + } + + if (event.defaultPrevented) return; const isPrintable = event.key.length === 1 && @@ -323,7 +376,19 @@ const DropdownMenuContent = React.forwardRef< } }} {...props} - /> + > + {/* Polite live region announcing how many items match as the query narrows. + It stays mounted while the menu is open so the update isn't missed; the + zero-match case is left to DropdownMenuEmpty so the two don't double-speak. */} + {ctx?.enabled ? ( +
+ {searching && resultCount > 0 + ? `${resultCount} result${resultCount === 1 ? '' : 's'} available` + : null} +
+ ) : null} + {children} +
); }); @@ -400,7 +465,9 @@ const DropdownMenuSearch = React.forwardRef - {icon ?? } + ctx.setQuery(event.target.value)} onKeyDown={(event) => { onKeyDown?.(event); // Arrow keys move focus into the list - Radix won't do this for us because focus - // is on the input, not a menu item. Jump to the first/last currently-visible item + // is on the input, not a menu item. Jump to the first/last currently-visible item. if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { const menu = event.currentTarget.closest('[role="menu"]'); const items = menu - ? Array.from( - menu.querySelectorAll( - '[role="menuitem"]:not([data-disabled]),' + - '[role="menuitemcheckbox"]:not([data-disabled]),' + - '[role="menuitemradio"]:not([data-disabled])' - ) - ) + ? Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)) : []; if (items.length) { event.preventDefault(); - (event.key === 'ArrowDown' ? items[0] : items[items.length - 1]).focus(); + (event.key === 'ArrowUp' ? items[items.length - 1] : items[0]).focus(); } return; } - // Bubble events to Radix (close / select / tab out) + // Bubble events to Radix for its standard menu handling (close / select / Tab) if (['Enter', 'Escape', 'Tab'].includes(event.key)) return; // Everything else stays in the input so Radix typeahead / shortcuts don't fire From f4144932fede3ad94a39c52d03414c408e4e52ab Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 24 Jul 2026 17:19:33 -0400 Subject: [PATCH 18/20] Add usage recommendation --- packages/demo/src/content/components/dropdown-menu.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index 46a36361..f62bd598 100644 --- a/packages/demo/src/content/components/dropdown-menu.mdx +++ b/packages/demo/src/content/components/dropdown-menu.mdx @@ -187,6 +187,8 @@ Wrap related items in `DropdownMenuGroup` to associate a label with its items fo Add a `DropdownMenuSearch` inside `DropdownMenuContent` to filter items in place. It is opt-in per menu — without it, the menu behaves exactly as before and the built-in typeahead still works. Items hide themselves when they don't match, and matching is done against each item's `textValue`, falling back to its rendered text. +Generally using `DropdownMenuSearch` over the default typeahead is encouraged for _most_ dropdown menus in our apps. + Import the additional parts: ```ts From f8cba39c7bd71e648d108cfb45833d540cc82fbe Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 24 Jul 2026 17:33:03 -0400 Subject: [PATCH 19/20] Adjust dropdown menu separator margin --- .../ui/src/components/dropdown-menu/dropdown-menu.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css b/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css index a1299ac3..e4bed6db 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css @@ -68,7 +68,7 @@ .dropdown-menu-separator { @apply bg-border; - @apply -mx-1 my-1 h-px; + @apply mx-1 my-1 h-px; } .dropdown-menu-shortcut { From 3bc3a0c48c1a13dcc1ce4e4e2e9ec72f38b07bd9 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 24 Jul 2026 17:53:21 -0400 Subject: [PATCH 20/20] Improve submenu search rendering --- .../src/components/demo/dropdown-menu.tsx | 12 ++- .../src/content/components/dropdown-menu.mdx | 16 +++- .../dropdown-menu/dropdown-menu.module.css | 12 +++ .../dropdown-menu/dropdown-menu.tsx | 81 +++++++++++++++++-- 4 files changed, 108 insertions(+), 13 deletions(-) diff --git a/packages/demo/src/components/demo/dropdown-menu.tsx b/packages/demo/src/components/demo/dropdown-menu.tsx index 02b8e36a..4e005dc0 100644 --- a/packages/demo/src/components/demo/dropdown-menu.tsx +++ b/packages/demo/src/components/demo/dropdown-menu.tsx @@ -396,8 +396,16 @@ export const DropdownMenuDemo = ({ Save Page As... Create Shortcut... - Developer Tools - Task Manager + + + Developer Tools + + + Console + Network + Task Manager + + No actions found diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index f62bd598..328a3244 100644 --- a/packages/demo/src/content/components/dropdown-menu.mdx +++ b/packages/demo/src/content/components/dropdown-menu.mdx @@ -216,7 +216,7 @@ By default the search box is hidden and reveals as soon as you start typing — ### Always visible -Pass `alwaysVisible` to show the search box the moment the menu opens instead of waiting for the first keystroke. Filtering works across every item type, including checkbox and radio items. Labels, separators, and submenu triggers hide while a search is active so results stay compact. +Pass `alwaysVisible` to show the search box the moment the menu opens instead of waiting for the first keystroke. Filtering works across every item type, including checkbox and radio items. Labels and separators hide while a search is active so results stay compact, and submenu items are flattened into the main list (see below). @@ -238,7 +238,7 @@ Pass `alwaysVisible` to show the search box the moment the menu opens instead of ### Searching submenus -Items nested in a `DropdownMenuSub` are flattened into the main list while searching, so submenu items appear in the results without opening the submenu. Try searching for "developer" below. +Items nested in a `DropdownMenuSub` are flattened into the main list while searching, so submenu items appear in the results without opening the submenu. Each flattened item is prefixed with its submenu path (e.g. `More Tools › Save Page As…`), dimmed like a keyboard shortcut so the item's own label stays legible. Nested submenus stack the full path. Try searching for "console" below. @@ -256,8 +256,16 @@ Items nested in a `DropdownMenuSub` are flattened into the main list while searc Save Page As... Create Shortcut... - Developer Tools - Task Manager + + + Developer Tools + + + Console + Network + Task Manager + + No actions found diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css b/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css index e4bed6db..86b30647 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css @@ -76,6 +76,18 @@ @apply ml-auto; } +/* Ancestor path prefixed to flattened submenu items while searching (Parent > Child) */ +.dropdown-menu-breadcrumb { + @apply text-text-secondary; + @apply inline-flex items-center gap-1; +} + +/* Keep the separator chevrons small; size-3! beats the item's [&_svg]:size-4 rule + (equal specificity otherwise) */ +.dropdown-menu-breadcrumb svg { + @apply size-3!; +} + .dropdown-menu-content { @apply bg-background-overlay shadow-shadow-overlay border-border-overlay; } diff --git a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx index 2ebd8906..e2e0db9f 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.tsx @@ -47,6 +47,16 @@ const useIsSearching = () => { return !!ctx && ctx.query.trim().length > 0; }; +/* + * Ancestry of SubTrigger contents for the current branch, used to render the "Parent >" + * breadcrumb on flattened submenu items while searching. Separate from the search context + * (which is a single Root-level instance) because ancestry is per-branch and stacks as the + * tree nests. Default [] so items outside any flattened submenu render no breadcrumb. + */ +const DropdownMenuBreadcrumbContext = React.createContext([]); + +const useBreadcrumbAncestry = () => React.useContext(DropdownMenuBreadcrumbContext); + /* Pull plain text out of children so we can match against it */ function getNodeText(node: React.ReactNode): string { if (node == null || typeof node === 'boolean') return ''; @@ -58,19 +68,32 @@ function getNodeText(node: React.ReactNode): string { return ''; } -/** Stable name used to identify DropdownMenuSubContent regardless of reference identity */ +/** Stable names used to identify sub components regardless of reference identity */ const SUB_CONTENT_NAME = 'DropdownMenuSubContent'; +const SUB_TRIGGER_NAME = 'DropdownMenuSubTrigger'; -function isSubContent( - node: React.ReactNode +/* Match an element by its component displayName, skipping host elements like
*/ +function hasDisplayName( + node: React.ReactNode, + name: string ): node is React.ReactElement<{ children?: React.ReactNode }> { return ( React.isValidElement(node) && - typeof node.type !== 'string' && // skip host elements like
- (node.type as { displayName?: string }).displayName === SUB_CONTENT_NAME + typeof node.type !== 'string' && + (node.type as { displayName?: string }).displayName === name ); } +const isSubContent = ( + node: React.ReactNode +): node is React.ReactElement<{ children?: React.ReactNode }> => + hasDisplayName(node, SUB_CONTENT_NAME); + +const isSubTrigger = ( + node: React.ReactNode +): node is React.ReactElement<{ children?: React.ReactNode }> => + hasDisplayName(node, SUB_TRIGGER_NAME); + /* * Find the first DropdownMenuSubContent's children, descending recursively * through fragments, arrays and host elements @@ -96,6 +119,16 @@ function findSubContentChildren(nodes: React.ReactNode): React.ReactNode { return result; } +/* + * Grab the SubTrigger's label text for the breadcrumb. getNodeText drops any leading icon, + * so the breadcrumb stays text-only. Shallow on purpose: Radix requires SubTrigger to be a + * direct child of Sub, and recursing could pick up a nested submenu's trigger instead. + */ +function getSubTriggerLabel(nodes: React.ReactNode): string { + const trigger = React.Children.toArray(nodes).find(isSubTrigger); + return trigger ? getNodeText(trigger.props.children) : ''; +} + /* * Shared logic for every item variant: decide whether the item is visible for the current query * and register its match state (so DropdownMenuEmpty can know when nothing matched) @@ -280,11 +313,20 @@ const DropdownMenuSub = ({ ...props }: React.ComponentPropsWithoutRef) => { const searching = useIsSearching(); + const parentAncestry = useBreadcrumbAncestry(); if (searching) { // Flatten: pull the SubContent's items inline so they participate in the filter - // Recursive so it survives fragments / arrays / host-element wrapping - return <>{findSubContentChildren(children)}; + // (recursive so it survives fragments / arrays / host-element wrapping), and push this + // sub's label onto the ancestry so the flattened items can show a "Parent >" prefix. + // Nested subs in the flattened output re-read this context and append their own crumb. + const label = getSubTriggerLabel(children); + const ancestry = label ? [...parentAncestry, label] : parentAncestry; + return ( + + {findSubContentChildren(children)} + + ); } return {children}; @@ -548,6 +590,28 @@ const DropdownMenuEmpty = ({ }; DropdownMenuEmpty.displayName = 'DropdownMenuEmpty'; +/* + * Breadcrumb prefix for flattened submenu items while searching - shows the ancestor + * submenu path ("Parent > Child") de-emphasized. aria-hidden so the item's accessible + * name stays just its own label; the ancestry is purely visual context. Renders nothing + * for items that aren't inside a flattened submenu (empty ancestry). + */ +const ItemBreadcrumb = () => { + const ancestry = useBreadcrumbAncestry(); + if (!ancestry.length) return null; + + return ( + + ); +}; + /* * Items - each variant hides itself when it doesn't match the active query */ @@ -573,6 +637,7 @@ const DropdownMenuItem = React.forwardRef< )} {...props} > + {children} ); @@ -599,6 +664,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< + {children} ); @@ -624,6 +690,7 @@ const DropdownMenuRadioItem = React.forwardRef< + {children} );