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. 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, 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}