diff --git a/packages/demo/src/components/demo/dropdown-menu.tsx b/packages/demo/src/components/demo/dropdown-menu.tsx index 2bf47e5c..4e005dc0 100644 --- a/packages/demo/src/components/demo/dropdown-menu.tsx +++ b/packages/demo/src/components/demo/dropdown-menu.tsx @@ -15,9 +15,33 @@ import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, + DropdownMenuSearch, + DropdownMenuEmpty, + Avatar, + AvatarFallback, } from "@eqtylab/equality"; import { Settings, User, LogOut } from "lucide-react"; +const MEMBERS = [ + { 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 = { + name: "Name", + email: "Email", + role: "Role", + status: "Status", + created: "Created", + lastActive: "Last active", + team: "Team", + location: "Location", +}; + export const DropdownMenuDemo = ({ variant = "default", }: { @@ -28,12 +52,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 ( @@ -205,7 +243,25 @@ export const DropdownMenuDemo = ({ Create Shortcut... Name Window... - Developer Tools + + + Developer Tools + + + Console + Network + + + + Profiling + + + Performance + Memory + + + + @@ -256,5 +312,108 @@ export const DropdownMenuDemo = ({ ); } + if (variant === "with-search") { + return ( +
+ + + + + + + Team members + {MEMBERS.map((person) => ( + setAssignee(person.name)} + > + + {person.initials} + + {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 + + + Console + Network + Task Manager + + + + + No actions found + + +
+ ); + } + return null; }; 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 ( + ```tsx ... diff --git a/packages/demo/src/content/components/dropdown-menu.mdx b/packages/demo/src/content/components/dropdown-menu.mdx index e882f4b3..328a3244 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,49 @@ 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 + ``` ### With Groups @@ -156,24 +183,117 @@ 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. + +Generally using `DropdownMenuSearch` over the default typeahead is encouraged for _most_ dropdown menus in our apps. + +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 and separators hide while a search is active so results stay compact, and submenu items are flattened into the main list (see below). + + + +```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. 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. + + + +```jsx + + + Cut + Copy + Paste + + + + More Tools + + + Save Page As... + Create Shortcut... + + + Developer Tools + + + Console + Network + 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 +321,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 +347,30 @@ 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` | - | ❌ | +| `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 + +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/demo/src/content/components/filter-dropdown.mdx b/packages/demo/src/content/components/filter-dropdown.mdx index 6c4985d6..c39b9e09 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. For longer lists, `searchable` adds in-menu search. ## Usage @@ -33,17 +33,47 @@ Basic usage: /> ``` -## Example +## Default +## Search + +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. + +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 + +``` + +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` | ❌ | +| `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 81884491..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. +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 @@ -42,12 +42,39 @@ The `label` is shown on the trigger before anything is selected, and as the head +## Search + +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. + +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 + +``` + +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` | — | ❌ | +| `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/avatar/avatar.module.css b/packages/ui/src/components/avatar/avatar.module.css index d2030273..e9bcd532 100644 --- a/packages/ui/src/components/avatar/avatar.module.css +++ b/packages/ui/src/components/avatar/avatar.module.css @@ -13,8 +13,7 @@ } .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 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; @@ -69,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; +} 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..86b30647 100644 --- a/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css +++ b/packages/ui/src/components/dropdown-menu/dropdown-menu.module.css @@ -63,12 +63,12 @@ .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 { @apply bg-border; - @apply -mx-1 my-1 h-px; + @apply mx-1 my-1 h-px; } .dropdown-menu-shortcut { @@ -76,6 +76,40 @@ @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; } + +.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..e2e0db9f 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,252 @@ 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; + /* 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; + 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; +}; + +/* + * 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 ''; + 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 names used to identify sub components regardless of reference identity */ +const SUB_CONTENT_NAME = 'DropdownMenuSubContent'; +const SUB_TRIGGER_NAME = 'DropdownMenuSubTrigger'; + +/* 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' && + (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 + */ +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; +} + +/* + * 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) + */ +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); + + // 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 (!enabled || !registerItem || !unregisterItem) return; + registerItem(id, visible); + return () => unregisterItem(id); + }, [enabled, registerItem, unregisterItem, 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), []); + + // 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); + 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, + listId, + setListId, + registerItem, + unregisterItem, + matchCount, + }), + [ + enabled, + visible, + reveal, + query, + focusSignal, + requestFocus, + listId, + registerItem, + unregisterItem, + matchCount, + ] + ); + + return ( + + + {children} + + + ); +}; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; @@ -21,29 +265,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 +306,441 @@ const DropdownMenuSubContent = React.forwardRef< {...props} /> )); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; +DropdownMenuSubContent.displayName = SUB_CONTENT_NAME; + +const DropdownMenuSub = ({ + children, + ...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), 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}; +}; + +/** 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, 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, ...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) 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 && + !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} + > + {/* 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} +
+
+ ); +}); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; +/* + * 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 + */ +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; +}; + +/* + * 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 ( +
+ + { + assignRefs(node, inputRef, forwardedRef); + }} + data-dropdown-search="" + className={cn(styles['dropdown-menu-search-input'], className)} + value={ctx.query} + placeholder={placeholder} + aria-label={ariaLabel ?? placeholder} + /* The list uses menu/menuitem semantics, so this is a searchbox controlling the + menu - not a combobox, which would imply a listbox of options that doesn't + exist here. Match counts are surfaced by the live region in DropdownMenuContent. */ + role="searchbox" + aria-controls={ctx.listId} + aria-autocomplete="list" + 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(MENU_ITEM_SELECTOR)) + : []; + if (items.length) { + event.preventDefault(); + (event.key === 'ArrowUp' ? items[items.length - 1] : items[0]).focus(); + } + return; + } + + // 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 + event.stopPropagation(); + }} + {...props} + /> +
+ ); + } +); +DropdownMenuSearch.displayName = 'DropdownMenuSearch'; + +/* + * 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, + children, + ...props +}: React.HTMLAttributes) => { + const ctx = useDropdownMenuSearch(); + if (!ctx) return null; + + const query = ctx.query.trim(); + const isEmpty = !!query && ctx.matchCount === 0; + + return ( +
+ {isEmpty ? children : null} +
+ ); +}; +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 + */ 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 +752,14 @@ export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuEmpty, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSearch, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, diff --git a/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx b/packages/ui/src/components/filter-dropdown/filter-dropdown.tsx index 8a626d02..605af33c 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,6 +32,10 @@ interface FilterDropdownProps { buttonClassName?: string; contentClassName?: string; disabled?: boolean; + /* Opt in to in-menu search */ + searchable?: boolean; + searchPlaceholder?: string; + emptyPlaceholder?: string; } const FilterDropdown = ({ @@ -41,6 +47,9 @@ const FilterDropdown = ({ buttonClassName, contentClassName, disabled = false, + searchable = false, + searchPlaceholder = 'Search filters...', + emptyPlaceholder = 'No filters found', }: FilterDropdownProps) => { const hasSelectedFilters = selectedFilters.length > 0; const filteredOptions = options.filter( @@ -67,6 +76,12 @@ const FilterDropdown = ({ align="end" className={cn(styles['dropdown-menu-content'], contentClassName)} > + {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 dbbe2f11..a842c4cb 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,6 +30,10 @@ interface RadioDropdownProps { selectedValue: string; onSelect: (value: string) => void; className?: string; + /* Opt in to in-menu search */ + searchable?: boolean; + searchPlaceholder?: string; + emptyPlaceholder?: string; } const RadioDropdown = ({ @@ -36,6 +42,9 @@ const RadioDropdown = ({ selectedValue, onSelect, className, + searchable = false, + searchPlaceholder = 'Search options...', + emptyPlaceholder = 'No options found', }: RadioDropdownProps) => { const selectedOption = options.find((opt) => opt.value === selectedValue); const hasSelectedCount = selectedOption?.count !== undefined; @@ -57,6 +66,12 @@ const RadioDropdown = ({ + {searchable && ( + <> + + {emptyPlaceholder} + + )} {label}