Skip to content

Feat/search dropdown#114

Open
giuliana-gladeye wants to merge 4 commits into
mainfrom
feat/search-dropdown
Open

Feat/search dropdown#114
giuliana-gladeye wants to merge 4 commits into
mainfrom
feat/search-dropdown

Conversation

@giuliana-gladeye

@giuliana-gladeye giuliana-gladeye commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Added search to DropdownMenu

Adds an optional search box to our existing DropdownMenu so you can filter items in place. It's opt-in per menu — if a menu doesn't have a <DropdownMenuSearch /> in it, nothing changes and Radix's normal typeahead still works. Everything is wired through a small internal context, so the usage stays the same, you just drop new parts into DropdownMenuContent.

What's new

  • <DropdownMenuSearch /> — a search input you put inside DropdownMenuContent. Hidden by default and reveals when you start typing. Pass alwaysVisible if you want it showing straight away.
  • <DropdownMenuEmpty /> — a "no results" row that only shows when the query doesn't match any items.
  • DropdownMenuItem / CheckboxItem / RadioItem hide themselves when they don't match. Matches on textValue, falling back to the item's text.
  • Submenus flatten while searching — nested items get pulled into the main list so they're searchable too.

Usage

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button size="sm" variant="tertiary">
      {assignee ? `Assigned: ${assignee}` : 'Assign reviewer'}
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent align="start">
    <DropdownMenuSearch placeholder="Search members..." />
    <DropdownMenuLabel>Team members</DropdownMenuLabel>
    {MEMBERS.map((person) => (
      <DropdownMenuItem
        key={person.name}
        textValue={person.name}
        onSelect={() => setAssignee(person.name)}
      >
        <Icon icon={person.icon} size="xs" background="circle" elevation="raised" />
        <span>{person.name}</span>
      </DropdownMenuItem>
    ))}
    <DropdownMenuEmpty>No members found</DropdownMenuEmpty>
  </DropdownMenuContent>
</DropdownMenu>

If an item has an icon/avatar instead of plain text, give it a textValue so it filters properly.

Heads up

Most of this is working around how Radix handles focus and keyboard:

  • We get in front of Radix's typeahead by handling the key on Content and calling preventDefault() — Radix bails when the event's already defaultPrevented.
  • Arrow keys from the search box jump into the list manually, because Radix won't do it when focus is on the input.
  • Submenus flatten by rendering the SubContent's children inline while searching — Radix keeps sub items in a separate popover that isn't mounted during a top-level search, so there's no other way to filter them.
  • Query resets on open, not close — otherwise selecting an item clears the filter mid-close and the full list flashes back before it disappears.

Known gaps

  • ArrowUp on the first item won't go back to the search box (Radix menus don't loop).
  • Flattening only works if the child is our exported DropdownMenuSubContent — wrapping it breaks it.

Extras

  • Updated DropdownMenuLabel styling, since it looked the same as a clickable item making it confusing.
  • Added <DropdownMenuSearch /> to both RadioDropdown and FilterDropdown components. We need to update the docs for them after feat/docs get's merged.
  • Should we add it to SortSelector component too @Shrinks99 ??
Screen.Recording.2026-07-14.at.5.33.05.PM.mov
screencapture-localhost-4321-components-dropdown-menu-2026-07-14-17_32_25

Linear ticket

@tmccoy14 tmccoy14 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the searchable-dropdown feature. Lint, typecheck, and prettier pass; the inline comments below are compliance/cleanup items grouped roughly must-fix → nits.

Two conceptual items that don't map cleanly to a single line:

  • role="menu" composition: the search <input> renders as a descendant of DropdownMenuPrimitive.Content (role="menu"), which per ARIA should contain only menuitem-role descendants. Worth a deliberate decision on the SR pattern (a combobox/listbox composition is the usual guidance).
  • Submenu flattening is brittle by design: matching on displayName === 'DropdownMenuSubContent' and only finding a directly-descended SubContent fails silently if a consumer wraps/aliases it. Already noted in the docs, so acceptable — just flagging.


/*
* TODO: Add searchPlaceholder and emptyPlaceholder to docs
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover TODO + the docs were never updated. searchPlaceholder/emptyPlaceholder are new public props but filter-dropdown.mdx's props table doesn't list them. CLAUDE.md requires each component's MDX to stay in sync. Add both props to the table, then delete this comment.


/*
* TODO: Add searchPlaceholder and emptyPlaceholder to the docs
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as filter-dropdown: leftover TODO and radio-dropdown.mdx is missing the new searchPlaceholder/emptyPlaceholder props. Update the docs table and remove the TODO.

import { useState } from "react";
import {
Avatar,
AvatarFallback,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatar and AvatarFallback (lines 3-4) are imported but never used — the demo uses Icon. ESLint flags both as warnings. Remove them.

return (
<div className={styles['dropdown-menu-search']}>
{icon ?? <SearchIcon className={styles['icon-size']} />}
<input

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessibility: the search <input> has no accessible name — it relies solely on placeholder, which isn't a reliable accessible name. Add an aria-label (default it from placeholder, allow override). CLAUDE.md calls out accessible components specifically.

const query = ctx?.query.trim() ?? '';
if (!ctx || !query || ctx.matchCount > 0) return null;
return (
<div className={cn(styles['dropdown-menu-empty'], className)} {...props}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessibility: DropdownMenuEmpty is a plain div, so screen-reader users aren't told when filtering empties the list. Consider role="status" / aria-live="polite" so the empty state is announced.

*/
const DropdownMenuSearch = ({
className,
placeholder = 'Search…',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ellipsis inconsistency: this default uses Search… (U+2026) but consumers use Search filters... / Search options... (three periods), and the demo mixes both (Search members... vs Search tools…). search-bar uses Search.... Pick one convention repo-wide.

</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuContent align="start">
<DropdownMenuSearch placeholder="Search tools…" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs ordering: the "With Submenu" example (and its live demo) now includes DropdownMenuSearch/DropdownMenuEmpty, but search isn't introduced until the later "Search & filtering" section and this section's prose is only about nesting. Either keep this example search-free or add a forward reference.

reveal: (seed: string) => void;
query: string;
setQuery: (value: string) => void;
/* Re focus the search input from outside DropdownMenuSearch */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: typo "Re focus" → "Re-focus".

if (!ctx || !ctx.enabled) return;
ctx.registerItem(id, visible);
return () => ctx.unregisterItem(id);
}, [ctx, ctx?.enabled, id, visible]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency: this effect lists ctx as a dependency, but the context value memo changes identity on every keystroke (its deps include query and matchCount). So every visible item unregisters + re-registers + triggers a full recount() on each keystroke — O(n²) per keystroke. Depend on the stable registerItem/unregisterItem/enabled values instead of the whole ctx. Fine for small menus, just noting.

/*
* Search input - renders the search input and manages the search context
*/
const DropdownMenuSearch = ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor API inconsistency: DropdownMenuSearch doesn't forwardRef while every sibling part does, so consumers can't get a ref to the input. Consider forwarding it for parity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants