From 3ea0e4a497eaa9976c671df02baac2d121fb4842 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 10 Feb 2026 03:07:52 +0530 Subject: [PATCH 1/6] wip: migrate dropdown --- apps/www/src/app/examples/menu/page.tsx | 29 ++ apps/www/src/app/examples/page.tsx | 124 ++++--- apps/www/src/components/ai/page-actions.tsx | 52 +-- .../src/components/linear-dropdown-demo.tsx | 38 ++- .../playground/dropdown-menu-examples.tsx | 84 +++-- .../content/docs/components/dropdown/demo.ts | 306 +++++++++--------- .../docs/components/dropdown/index.mdx | 82 ++--- .../content/docs/components/dropdown/props.ts | 84 ++--- .../components/breadcrumb/breadcrumb-item.tsx | 35 +- .../components/combobox/combobox-item.tsx | 2 +- .../data-table/components/filters.tsx | 18 +- .../dropdown-menu/dropdown-menu-content.tsx | 11 +- .../dropdown-menu/dropdown-menu-item.tsx | 9 +- .../dropdown-menu/dropdown-menu-trigger.tsx | 2 +- .../components/menu/__tests__/menu.test.tsx | 137 ++++++++ .../raystack/components/menu/cell.module.css | 40 +++ packages/raystack/components/menu/cell.tsx | 28 ++ packages/raystack/components/menu/index.ts | 1 + .../raystack/components/menu/menu-content.tsx | 80 +++++ .../raystack/components/menu/menu-item.tsx | 57 ++++ .../raystack/components/menu/menu-misc.tsx | 77 +++++ .../raystack/components/menu/menu-root.tsx | 214 ++++++++++++ .../components/menu/menu-subtrigger.tsx | 47 +++ .../raystack/components/menu/menu-trigger.tsx | 27 ++ .../raystack/components/menu/menu.module.css | 88 +++++ packages/raystack/components/menu/menu.tsx | 24 ++ packages/raystack/components/menu/utils.ts | 23 ++ .../components/select/select-item.tsx | 2 +- packages/raystack/index.tsx | 2 +- 29 files changed, 1287 insertions(+), 436 deletions(-) create mode 100644 apps/www/src/app/examples/menu/page.tsx create mode 100644 packages/raystack/components/menu/__tests__/menu.test.tsx create mode 100644 packages/raystack/components/menu/cell.module.css create mode 100644 packages/raystack/components/menu/cell.tsx create mode 100644 packages/raystack/components/menu/index.ts create mode 100644 packages/raystack/components/menu/menu-content.tsx create mode 100644 packages/raystack/components/menu/menu-item.tsx create mode 100644 packages/raystack/components/menu/menu-misc.tsx create mode 100644 packages/raystack/components/menu/menu-root.tsx create mode 100644 packages/raystack/components/menu/menu-subtrigger.tsx create mode 100644 packages/raystack/components/menu/menu-trigger.tsx create mode 100644 packages/raystack/components/menu/menu.module.css create mode 100644 packages/raystack/components/menu/menu.tsx create mode 100644 packages/raystack/components/menu/utils.ts diff --git a/apps/www/src/app/examples/menu/page.tsx b/apps/www/src/app/examples/menu/page.tsx new file mode 100644 index 000000000..d1b991c84 --- /dev/null +++ b/apps/www/src/app/examples/menu/page.tsx @@ -0,0 +1,29 @@ +'use client'; +import { Button, Flex, Menu } from '@raystack/apsara'; + +const Page = () => { + return ( + + + + }>Open Menu + + Item 1 + Item 2 + Item 3 + + + + ); +}; + +export default Page; diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index ba7352813..c876f710d 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -9,12 +9,12 @@ import { DataTable, DatePicker, Dialog, - DropdownMenu, EmptyState, Flex, IconButton, Indicator, InputField, + Menu, Navbar, Popover, RangePicker, @@ -1541,35 +1541,29 @@ const Page = () => { > Open Sheet - - - - - - - Team Actions - + + }> + Open Menu + + + Team Actions - Add Member + Add Member - Edit Team - - - Settings - Permissions - - Notifications - - - - - Delete Team - - - + Edit Team + + + Settings + Permissions + Notifications + + + Delete Team + + @@ -1679,31 +1673,29 @@ const Page = () => { - - - - - - Team Actions + + }> + Open Menu + + + Team Actions - Add Member + Add Member - Edit Team - - - Settings - Permissions - Notifications - - - - Delete Team - - - + Edit Team + + + Settings + Permissions + Notifications + + + Delete Team + + @@ -1817,35 +1809,29 @@ const Page = () => { - - - - - - - Team Actions - + + }> + Open Menu + + + Team Actions - Add Member + Add Member - Edit Team - - - Settings - Permissions - - Notifications - - - - - Delete Team - - - + Edit Team + + + Settings + Permissions + Notifications + + + Delete Team + + diff --git a/apps/www/src/components/ai/page-actions.tsx b/apps/www/src/components/ai/page-actions.tsx index f513a0d16..9761ab5a3 100644 --- a/apps/www/src/components/ai/page-actions.tsx +++ b/apps/www/src/components/ai/page-actions.tsx @@ -1,5 +1,5 @@ 'use client'; -import { DropdownMenu } from '@raystack/apsara'; +import { Menu } from '@raystack/apsara'; import { cx } from 'class-variance-authority'; import { buttonVariants } from 'fumadocs-ui/components/ui/button'; import { Check, ChevronDown, Copy, ExternalLinkIcon } from 'lucide-react'; @@ -233,26 +233,28 @@ export function ViewOptions({ }, [markdownUrl]); return ( - - - - - + + + } + > + + + {items.map(item => ( - } > {item.title} - + ))} - - + + ); } diff --git a/apps/www/src/components/linear-dropdown-demo.tsx b/apps/www/src/components/linear-dropdown-demo.tsx index f8fbeffbc..88a4760b3 100644 --- a/apps/www/src/components/linear-dropdown-demo.tsx +++ b/apps/www/src/components/linear-dropdown-demo.tsx @@ -1,4 +1,4 @@ -import { Avatar, Button, DropdownMenu, Flex, Text } from '@raystack/apsara'; +import { Avatar, Button, Flex, Menu, Text } from '@raystack/apsara'; import { Calendar, ChevronRight, Download } from 'lucide-react'; import { Fragment, ReactNode, useState } from 'react'; @@ -224,30 +224,30 @@ export default function LinearDropdownDemo() { switch (item.type) { case 'group': return ( - - {item.label} + + {item.label} {item.items && renderDropdownMenu(item.items, query)} - + ); case 'separator': - return ; + return ; case 'submenu': return ( - - + {item.label} - - + + {item.items && renderDropdownMenu(item.items, query)} - - + + ); case 'item': return ( - )) : item.label} - + ); default: return null; @@ -271,18 +271,16 @@ export default function LinearDropdownDemo() { return ( - setSearchQuery(value)} > - - - - + }>Actions + {renderDropdownMenu(dropdownMenuData, searchQuery)} - - + + ); } diff --git a/apps/www/src/components/playground/dropdown-menu-examples.tsx b/apps/www/src/components/playground/dropdown-menu-examples.tsx index b32e25e0f..4f14a7087 100644 --- a/apps/www/src/components/playground/dropdown-menu-examples.tsx +++ b/apps/www/src/components/playground/dropdown-menu-examples.tsx @@ -1,54 +1,52 @@ 'use client'; -import { Button, DropdownMenu, Flex } from '@raystack/apsara'; +import { Button, Flex, Menu } from '@raystack/apsara'; import PlaygroundLayout from './playground-layout'; export function DropdownMenuExamples() { return ( - + - - - - - - Profile - Settings - - Logout - - - - - - - - 📝}>Edit - 📋} trailingIcon={<>⌘C}> + + }> + Open Menu + + + Profile + Settings + + Logout + + + + }> + Actions + + + 📝}>Edit + 📋} trailingIcon={<>⌘C}> Copy - - - 🗑️}>Delete - - - - - - - - Actions - - New File - New Folder - - - Sort By - - Name - Date - - - + + + 🗑️}>Delete + + + + }>More + + Actions + + New File + New Folder + + + Sort By + + Name + Date + + + ); diff --git a/apps/www/src/content/docs/components/dropdown/demo.ts b/apps/www/src/content/docs/components/dropdown/demo.ts index 1060fdfd6..06d7fa810 100644 --- a/apps/www/src/content/docs/components/dropdown/demo.ts +++ b/apps/www/src/content/docs/components/dropdown/demo.ts @@ -4,57 +4,57 @@ import { getPropsString } from '@/lib/utils'; export const getCode = (props: any) => { return ` - - - - - - - Assign member... - Subscribe... - Rename... - - - Actions - - + + }> + Actions + + + + Assign member... + Subscribe... + Rename... + + + Actions + + Export - - - - All (.zip) - + + + + All (.zip) + CSV - - - All - 3 Months - 6 Months - - - - + + + All + 3 Months + 6 Months + + + + PDF - - - All - 3 Months - 6 Months - - - - - Copy - + + All + 3 Months + 6 Months + + + + + Copy + ⌘⇧D }> Delete... - - - `; + + + `; }; export const playground = { @@ -71,55 +71,55 @@ export const playground = { export const basicDemo = { type: 'code', code: ` - - - - - - Profile - Settings - - Logout - - ` + + }> + Open Menu + + + Profile + Settings + + Logout + + ` }; export const iconsDemo = { type: 'code', code: ` - - - - - - 📝}>Edit - 📋} trailingIcon={<>⌘C}>Copy - - 🗑️}>Delete - - ` + + }> + Actions + + + 📝}>Edit + 📋} trailingIcon={<>⌘C}>Copy + + 🗑️}>Delete + + ` }; export const customDemo = { type: 'code', code: ` - - - - - - Actions - - New File - New Folder - - - Sort By - - Name - Date - - - ` + + }> + More + + + Actions + + New File + New Folder + + + Sort By + + Name + Date + + + ` }; export const autocompleteDemo = { @@ -128,43 +128,43 @@ export const autocompleteDemo = { { name: 'Default Autocomplete', code: ` - - - - - - - Heading - Assign member... - Subscribe... - Rename... - - - Actions - - Export - - All (.zip) - - CSV - - All - 3 Months - 6 Months - - - - PDF - - All - 3 Months - 6 Months - - - - - Copy - + }> + Default Autocomplete + + + + Heading + Assign member... + Subscribe... + Rename... + + + Actions + + Export + + All (.zip) + + CSV + + All + 3 Months + 6 Months + + + + PDF + + All + 3 Months + 6 Months + + + + + Copy + @@ -172,9 +172,9 @@ export const autocompleteDemo = { }> Delete... - - - ` + + + ` }, { name: 'Manual Autocomplete', @@ -189,21 +189,21 @@ export const autocompleteDemo = { ]; const [simpleSearchQuery, setSimpleSearchQuery] = React.useState(""); - return setSimpleSearchQuery(value)}> - - - - + }> + Manual Autocomplete + + {items .filter(item => item.toLowerCase().includes(simpleSearchQuery)) .map((item, index) => ( - {item} + {item} ))} - - + + }` } ] @@ -219,7 +219,7 @@ export const linearDemo = { code: `function LinearDropdownDemo() { const [searchQuery, setSearchQuery] = useState(""); - const renderDropdownMenu = (items: DropdownMenuItem[], query: string) => { + const renderMenu = (items: DropdownMenuItem[], query: string) => { const filteredItems = filterDropdownMenuItems(items, query); if (searchQuery && filteredItems.length === 0) { @@ -230,29 +230,29 @@ export const linearDemo = { switch (item.type) { case "group": return ( - - {item.label} - {item.items && renderDropdownMenu(item.items, query)} - + + {item.label} + {item.items && renderMenu(item.items, query)} + ); case "separator": - return ; + return ; case "submenu": return ( - - + {item.label} - - - {item.items && renderDropdownMenu(item.items, query)} - - + + + {item.items && renderMenu(item.items, query)} + + ); case "item": return ( - )) : item.label} - + ); default: return null; @@ -274,17 +274,17 @@ export const linearDemo = { }; return ( - setSearchQuery(value)}> - - - - - {renderDropdownMenu(dropdownMenuData, searchQuery)} - - + }> + Actions + + + {renderMenu(dropdownMenuData, searchQuery)} + + ); } ` diff --git a/apps/www/src/content/docs/components/dropdown/index.mdx b/apps/www/src/content/docs/components/dropdown/index.mdx index f4c583526..7ae7d55dc 100644 --- a/apps/www/src/content/docs/components/dropdown/index.mdx +++ b/apps/www/src/content/docs/components/dropdown/index.mdx @@ -1,7 +1,7 @@ --- -title: Dropdown Menu +title: Menu description: Displays a menu to the user, such as a set of actions or functions, triggered by a button. -source: packages/raystack/components/dropdown +source: packages/raystack/components/menu --- import { @@ -18,80 +18,88 @@ import { ## Usage ```tsx -import { DropdownMenu } from '@raystack/apsara' +import { Menu } from '@raystack/apsara' ``` -## Dropdown Props +## Menu Props -The DropdownMenu component is composed of several parts, each with their own props. +The Menu component is composed of several parts, each with their own props. -The root element is the parent component that holds the dropdown menu. Using the `autocomplete` prop, you can enable autocomplete functionality. Built on top of [Ariakit MenuProvider](https://ariakit.org/reference/menu-provider) +The root element is the parent component that holds the menu. Using the `autocomplete` prop, you can enable autocomplete functionality. Built on top of [Base UI Menu](https://base-ui.com/react/components/menu) - + -### DropdownMenu.Trigger Props +### Menu.Trigger Props -The button that triggers the dropdown menu. Built on top of [Ariakit MenuButton](https://ariakit.org/reference/menu-button) +The button that triggers the menu. Built on top of [Base UI Menu.Trigger](https://base-ui.com/react/components/menu) By default, the click event is not propagated. You can override this behavior by passing `stopPropagation={false}`. - +Use the `render` prop to render a custom trigger element. -### DropdownMenu.TriggerItem Props + -`TriggerItem` is a helper component that renders a `DropdownMenu.Trigger` as a `DropdownMenu.MenuItem`. +### Menu.Content Props -Accepts all `DropdownMenu.Item` props. The component is helpful to match styles for sub menu trigger. Use DropdownMenu.Trigger if you want more control. +The container that holds the menu items. Built on top of [Base UI Menu.Popup](https://base-ui.com/react/components/menu) -### DropdownMenu.Content Props + -The container that holds the dropdown menu items. Built on top of [Ariakit Menu](https://ariakit.org/reference/menu) +### Menu.Item Props - +Individual clickable options within the menu. Built on top of [Base UI Menu.Item](https://base-ui.com/react/components/menu). -### DropdownMenu.Item Props +Renders as a [Base UI Autocomplete.Item](https://base-ui.com/react/components/autocomplete) when used in an autocomplete menu. By default, the item's `children` is used for matching and selection, which can be overriden by passing a `value` prop. -Individual clickable options within the dropdown menu. Built on top of [Ariakit MenuItem](https://ariakit.org/reference/menu-item). + -Renders as an [Ariakit ComboboxItem](https://ariakit.org/reference/combobox-item) when used in an autocomplete dropdown. By default, the item's `children` is used for matching and selection, which can be overriden by passing a `value` prop. +### Menu.Group Props - +A way to group related menu items together. Built on top of [Base UI Menu.Group](https://base-ui.com/react/components/menu) -### DropdownMenu.Group Props + -A way to group related menu items together. Built on top of [Ariakit MenuGroup](https://ariakit.org/reference/menu-group) +### Menu.Label Props - +Renders a label in a menu group. This component should be wrapped with Menu.Group so the `aria-labelledby` is correctly set on the group element. Built on top of [Base UI Menu.GroupLabel](https://base-ui.com/react/components/menu) -### DropdownMenu.Label Props + -Renders a label in a menu group. This component should be wrapped with DropdownMenu.Group so the `aria-labelledby` is correctly set on the group element. Built on top of [Ariakit MenuGroupLabel](https://ariakit.org/reference/menu-group-label) +### Menu.Separator Props - +Visual divider between menu items or groups. -### DropdownMenu.Separator Props + -Visual divider between menu items or groups. Built on top of [Ariakit MenuSeparator](https://ariakit.org/reference/menu-separator) +### Menu.EmptyState Props - +Placeholder content when there are no menu items to display. -### DropdownMenu.EmptyState Props + -Placeholder content when there are no menu items to display. +### Menu.SubMenu + +Wraps a submenu root. Use with `Menu.SubTrigger` and `Menu.SubContent` to create nested menus. Built on top of [Base UI Menu.SubmenuRoot](https://base-ui.com/react/components/menu) + +### Menu.SubTrigger Props + +The trigger item for a submenu. Renders with a trailing chevron icon by default. Accepts `leadingIcon` and `trailingIcon` props. + +### Menu.SubContent Props - +The content container for a submenu. Same structure as `Menu.Content` but without autocomplete support. ## Examples ### Basic Usage -A simple dropdown menu with basic functionality. +A simple menu with basic functionality. ### With Icons -You can add icons to the dropdown items. Supports both leading and trailing icons. +You can add icons to the menu items. Supports both leading and trailing icons. @@ -103,15 +111,15 @@ Organize related menu items into sections with descriptive headers. ### Autocomplete -To enable autocomplete, pass the `autocomplete` prop to the Dropdown root element. Each menu instance will manage its own autocomplete behavior. +To enable autocomplete, pass the `autocomplete` prop to the Menu root element. Each menu instance will manage its own autocomplete behavior. By default, only the top-level menu items are filtered. For more advanced control, set `autocompleteMode="manual"` and implement your own custom filtering logic. -### Linear inspired Dropdown +### Linear inspired Menu -This is a Linear-inspired dropdown component that supports custom filtering and displays nested options. Users can search through all nested items using a single input field. +This is a Linear-inspired menu component that supports custom filtering and displays nested options. Users can search through all nested items using a single input field. To closely replicate Linear-style filtering, the filtering logic should include result ranking. Using a utility like [match-sorter](https://www.npmjs.com/package/match-sorter) can help achieve this by sorting filtered results based on relevance. diff --git a/apps/www/src/content/docs/components/dropdown/props.ts b/apps/www/src/content/docs/components/dropdown/props.ts index e9135e35c..618e02dec 100644 --- a/apps/www/src/content/docs/components/dropdown/props.ts +++ b/apps/www/src/content/docs/components/dropdown/props.ts @@ -1,5 +1,5 @@ -export interface DropdownMenuRootProps { - /** Enables search functionality within the dropdown menu */ +export interface MenuRootProps { + /** Enables search functionality within the menu */ autocomplete?: boolean; /** Controls the autocomplete behavior mode @@ -18,70 +18,61 @@ export interface DropdownMenuRootProps { /** Callback fired when the search value changes */ onSearch?: (value: string) => void; - /** Placement of the dropdown relative to the trigger - * @default "bottom-start"" - */ - placement?: - | 'top' - | 'top-start' - | 'top-end' - | 'bottom' - | 'bottom-start' - | 'bottom-end' - | 'left' - | 'left-start' - | 'left-end' - | 'right' - | 'right-start' - | 'right-end'; - - /** Whether the dropdown should loop focus when navigating with keyboard + /** Whether the menu should loop focus when navigating with keyboard * @default true */ - focusLoop?: boolean; + loopFocus?: boolean; - /** Control the open state of the dropdown + /** Control the open state of the menu * @default false */ open?: boolean; - /** Callback fired when the dropdown is opened or closed */ + /** Callback fired when the menu is opened or closed */ onOpenChange?: (open: boolean) => void; + + /** Whether the menu is modal (traps focus and blocks outside interaction) + * @default true + */ + modal?: boolean; } -export interface DropdownMenuTriggerProps { - /** Boolean to merge props onto child element */ - asChild?: boolean; +export interface MenuTriggerProps { + /** Render a custom element as the trigger using Base UI's render prop pattern */ + render?: React.ReactElement; - /** Whether the dropdown should stop propagation of the click event + /** Whether the menu should stop propagation of the click event * @default true */ stopPropagation?: boolean; } -export interface DropdownMenuContentProps { +export interface MenuContentProps { /** Placeholder text for the autocomplete search input * @default "Search..." */ searchPlaceholder?: string; /** - * The distance between the popover and the anchor element. + * The distance between the popup and the anchor element. * @default 4 */ - gutter?: number; + sideOffset?: number; /** - * The skidding of the popover along the anchor element. Can be set to negative values to make the popover shift to the opposite side. - * @default 0 + * The side of the anchor element to place the popup. + * @default "bottom" */ - shift?: number; + side?: 'top' | 'bottom' | 'left' | 'right'; - /** Boolean to merge props onto child element */ - asChild?: boolean; + /** + * The alignment of the popup relative to the anchor element. + * @default "start" + */ + align?: 'start' | 'center' | 'end'; } -export interface DropdownMenuItemProps { +export interface MenuItemProps { /** Icon element to display before item text */ leadingIcon?: React.ReactNode; @@ -97,35 +88,26 @@ export interface DropdownMenuItemProps { /** Additional CSS class names */ className?: string; - /** Boolean to merge props onto child element */ - asChild?: boolean; + /** Render a custom element using Base UI's render prop pattern */ + render?: React.ReactElement; } -export interface DropdownMenuGroupProps { +export interface MenuGroupProps { /** Additional CSS class names */ className?: string; - - /** Boolean to merge props onto child element */ - asChild?: boolean; } -export interface DropdownMenuLabelProps { +export interface MenuLabelProps { /** Additional CSS class names */ className?: string; - - /** Boolean to merge props onto child element */ - asChild?: boolean; } -export interface DropdownMenuSeparatorProps { +export interface MenuSeparatorProps { /** Additional CSS class names */ className?: string; - - /** Boolean to merge props onto child element */ - asChild?: boolean; } -export interface DropdownMenuEmptyStateProps { +export interface MenuEmptyStateProps { /** React nodes to render in empty state */ children?: React.ReactNode; diff --git a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx index fdc5cf872..0b3471ac2 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx @@ -2,20 +2,19 @@ import { ChevronDownIcon } from '@radix-ui/react-icons'; import { cx } from 'class-variance-authority'; -import { +import React, { + cloneElement, + forwardRef, HTMLAttributes, ReactElement, - ReactEventHandler, - ReactNode, - cloneElement, - forwardRef + ReactNode } from 'react'; -import { DropdownMenu } from '../dropdown-menu'; +import { Menu } from '../menu'; import styles from './breadcrumb.module.css'; export interface BreadcrumbDropdownItem { label: string; - onClick?: ReactEventHandler; + onClick?: React.MouseEventHandler; } export interface BreadcrumbItemProps extends HTMLAttributes { @@ -55,27 +54,23 @@ export const BreadcrumbItem = forwardRef< if (dropdownItems) { return ( - - + + {label} - - + + {dropdownItems.map((dropdownItem, dropdownIndex) => ( - {dropdownItem.label} - + ))} - - + + ); } return ( diff --git a/packages/raystack/components/combobox/combobox-item.tsx b/packages/raystack/components/combobox/combobox-item.tsx index 162c283fb..85e8e3fae 100644 --- a/packages/raystack/components/combobox/combobox-item.tsx +++ b/packages/raystack/components/combobox/combobox-item.tsx @@ -4,7 +4,7 @@ import { ComboboxItem as AriakitComboboxItem } from '@ariakit/react'; import { cx } from 'class-variance-authority'; import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; import { Checkbox } from '../checkbox'; -import { getMatch } from '../dropdown-menu/utils'; +import { getMatch } from '../menu/utils'; import { Text } from '../text'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; diff --git a/packages/raystack/components/data-table/components/filters.tsx b/packages/raystack/components/data-table/components/filters.tsx index 4573f7e9c..b720189c3 100644 --- a/packages/raystack/components/data-table/components/filters.tsx +++ b/packages/raystack/components/data-table/components/filters.tsx @@ -1,13 +1,13 @@ 'use client'; -import { ReactNode, useMemo } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import { FilterIcon } from '~/icons'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; -import { DropdownMenu } from '../../dropdown-menu'; import { FilterChip } from '../../filter-chip'; import { Flex } from '../../flex'; import { IconButton } from '../../icon-button'; +import { Menu } from '../../menu'; import { DataTableColumn } from '../data-table.types'; import { useDataTable } from '../hooks/useDataTable'; import { useFilters } from '../hooks/useFilters'; @@ -63,20 +63,20 @@ function AddFilter({ }, [children, appliedFiltersSet, availableFilters]); return availableFilters.length > 0 ? ( - - {trigger} - + + {} + {availableFilters?.map(column => { const columnDef = column.columnDef; const id = columnDef.accessorKey || column.id; return ( - onAddFilter(column)}> + onAddFilter(column)}> {columnDef.header || id} - + ); })} - - + + ) : null; } diff --git a/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx b/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx index 0d72a03a8..87cc72f09 100644 --- a/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx +++ b/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx @@ -1,12 +1,17 @@ 'use client'; -import { Menu, MenuProps, useMenuContext } from '@ariakit/react'; -import { Combobox, ComboboxList } from '@ariakit/react'; +import { + Combobox, + ComboboxList, + Menu, + MenuProps, + useMenuContext +} from '@ariakit/react'; import { cx } from 'class-variance-authority'; import { Slot, VisuallyHidden } from 'radix-ui'; import { ElementRef, forwardRef, useEffect, useRef, useState } from 'react'; -import { useDropdownContext } from './dropdown-menu-root'; import styles from './dropdown-menu.module.css'; +import { useDropdownContext } from './dropdown-menu-root'; import { WithAsChild } from './types'; export interface MenuContentProps extends WithAsChild { diff --git a/packages/raystack/components/dropdown-menu/dropdown-menu-item.tsx b/packages/raystack/components/dropdown-menu/dropdown-menu-item.tsx index 20035f807..1e97e2c50 100644 --- a/packages/raystack/components/dropdown-menu/dropdown-menu-item.tsx +++ b/packages/raystack/components/dropdown-menu/dropdown-menu-item.tsx @@ -1,7 +1,12 @@ 'use client'; -import { MenuItem, MenuItemProps, useMenuContext } from '@ariakit/react'; -import { ComboboxItem, ComboboxItemProps } from '@ariakit/react'; +import { + ComboboxItem, + ComboboxItemProps, + MenuItem, + MenuItemProps, + useMenuContext +} from '@ariakit/react'; import { Slot } from 'radix-ui'; import { forwardRef } from 'react'; import { Cell, CellBaseProps } from './cell'; diff --git a/packages/raystack/components/dropdown-menu/dropdown-menu-trigger.tsx b/packages/raystack/components/dropdown-menu/dropdown-menu-trigger.tsx index e0226559b..74930a504 100644 --- a/packages/raystack/components/dropdown-menu/dropdown-menu-trigger.tsx +++ b/packages/raystack/components/dropdown-menu/dropdown-menu-trigger.tsx @@ -2,7 +2,7 @@ import { MenuButton, MenuButtonProps } from '@ariakit/react'; import { Slot } from 'radix-ui'; -import { PointerEvent, forwardRef } from 'react'; +import { forwardRef, PointerEvent } from 'react'; import { TriangleRightIcon } from '~/icons'; import { DropdownMenuItem, DropdownMenuItemProps } from './dropdown-menu-item'; import { useDropdownContext } from './dropdown-menu-root'; diff --git a/packages/raystack/components/menu/__tests__/menu.test.tsx b/packages/raystack/components/menu/__tests__/menu.test.tsx new file mode 100644 index 000000000..ceb2617c1 --- /dev/null +++ b/packages/raystack/components/menu/__tests__/menu.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../../button/button'; +import { Menu } from '../menu'; +import { MenuRootProps } from '../menu-root'; + +// Mock scrollIntoView for test environment +Object.defineProperty(Element.prototype, 'scrollIntoView', { + value: vi.fn(), + writable: true +}); + +// String constants +const TRIGGER_TEXT = 'Open Menu'; +const MENU_ITEMS = [ + { id: 'profile', label: 'Profile' }, + { id: 'settings', label: 'Settings' }, + { id: 'billing', label: 'Billing' }, + { id: 'team', label: 'Team' }, + { id: 'logout', label: 'Logout' } +]; + +const BasicDropdown = ({ + onClick, + children, + ...props +}: MenuRootProps & { onClick?: (value: string) => void }) => { + return ( + + }> + {TRIGGER_TEXT} + + + {MENU_ITEMS.map(item => ( + onClick?.(item.id)}> + {item.label} + + ))} + {children} + + + ); +}; + +const renderAndOpenDropdown = async (Dropdown: React.ReactElement) => { + await fireEvent.click(render(Dropdown).getByText(TRIGGER_TEXT)); +}; + +describe('Menu', () => { + describe('Basic Rendering', () => { + it('renders dropdown trigger', () => { + render(); + expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument(); + }); + + it('renders with custom className on trigger', () => { + render( + + Custom Trigger + + Menu Item + + + ); + + const trigger = screen.getByText('Custom Trigger'); + expect(trigger).toHaveClass('custom-trigger'); + }); + + it('does not show content initially', () => { + render(); + MENU_ITEMS.forEach(item => { + expect(screen.queryByText(item.label)).not.toBeInTheDocument(); + }); + }); + + it('shows content when opened', async () => { + await renderAndOpenDropdown(); + + expect(screen.getByRole('menu')).toBeInTheDocument(); + MENU_ITEMS.forEach(item => { + expect(screen.getByText(item.label)).toBeInTheDocument(); + }); + }); + }); + + describe('Trigger Interaction', () => { + it('opens menu when trigger is clicked', async () => { + await renderAndOpenDropdown(); + + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByText(MENU_ITEMS[0].label)).toBeInTheDocument(); + }); + }); + + describe('Menu Items', () => { + it('handles item clicks with onClick', async () => { + const onClick = vi.fn(); + + await renderAndOpenDropdown(); + + const item = screen.getByText(MENU_ITEMS[0].label); + fireEvent.click(item); + + expect(onClick).toHaveBeenCalled(); + }); + + it('supports disabled items', async () => { + const onClick = vi.fn(); + + await renderAndOpenDropdown( + + + Disabled Item + + + ); + + const disabledItem = screen.getByTestId('disabled-item'); + expect(disabledItem).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + describe('Controlled State', () => { + it('calls onOpenChange when state changes', async () => { + const onOpenChange = vi.fn(); + + await render(); + + const trigger = screen.getByText(TRIGGER_TEXT); + fireEvent.click(trigger); + + expect(onOpenChange).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/raystack/components/menu/cell.module.css b/packages/raystack/components/menu/cell.module.css new file mode 100644 index 000000000..80068251f --- /dev/null +++ b/packages/raystack/components/menu/cell.module.css @@ -0,0 +1,40 @@ +.cell { + position: relative; + padding: var(--rs-space-3); + display: flex; + align-items: center; + gap: var(--rs-space-3); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.cell[data-highlighted] { + outline: none; + cursor: pointer; + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-base-primary-hover); +} + +.cell[aria-disabled] { + opacity: 0.6; + pointer-events: none; +} + +.leadingIcon { + display: flex; + align-items: center; + color: var(--rs-color-foreground-base-secondary); +} + +.trailingIcon { + display: flex; + align-items: center; + color: var(--rs-color-foreground-base-secondary); + margin-left: auto; +} diff --git a/packages/raystack/components/menu/cell.tsx b/packages/raystack/components/menu/cell.tsx new file mode 100644 index 000000000..55652fe87 --- /dev/null +++ b/packages/raystack/components/menu/cell.tsx @@ -0,0 +1,28 @@ +import { cx } from 'class-variance-authority'; +import { forwardRef, HTMLAttributes, ReactNode } from 'react'; +import styles from './cell.module.css'; + +export type CellBaseProps = { + leadingIcon?: ReactNode; + trailingIcon?: ReactNode; +}; +export type CellProps = HTMLAttributes & + CellBaseProps & { + type?: 'select' | 'item'; + }; + +export const Cell = forwardRef( + ( + { className, children, leadingIcon, trailingIcon, type = 'item', ...props }, + ref + ) => ( +
+ {leadingIcon && {leadingIcon}} + {children} + {trailingIcon && ( + {trailingIcon} + )} +
+ ) +); +Cell.displayName = 'Menu.Cell'; diff --git a/packages/raystack/components/menu/index.ts b/packages/raystack/components/menu/index.ts new file mode 100644 index 000000000..01435c7b0 --- /dev/null +++ b/packages/raystack/components/menu/index.ts @@ -0,0 +1 @@ +export { Menu } from './menu'; diff --git a/packages/raystack/components/menu/menu-content.tsx b/packages/raystack/components/menu/menu-content.tsx new file mode 100644 index 000000000..c381f9148 --- /dev/null +++ b/packages/raystack/components/menu/menu-content.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + Menu as MenuPrimitive +} from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { forwardRef } from 'react'; +import styles from './menu.module.css'; +import { useMenuContext } from './menu-root'; + +export interface MenuContentProps extends MenuPrimitive.Popup.Props { + searchPlaceholder?: string; + sideOffset?: number; + side?: MenuPrimitive.Positioner.Props['side']; + align?: MenuPrimitive.Positioner.Props['align']; +} + +export const MenuContent = forwardRef( + ( + { + className, + children, + searchPlaceholder = 'Search...', + sideOffset = 4, + side, + align, + ...props + }, + ref + ) => { + const { autocomplete } = useMenuContext(); + + return ( + + + + {autocomplete ? ( + <> + { + e.stopPropagation(); + }} + tabIndex={-1} + /> + + {children} + + + ) : ( + children + )} + + + + ); + } +); +MenuContent.displayName = 'Menu.Content'; + +export const MenuSubContent = forwardRef( + ({ ...props }, ref) => +); +MenuSubContent.displayName = 'Menu.SubContent'; diff --git a/packages/raystack/components/menu/menu-item.tsx b/packages/raystack/components/menu/menu-item.tsx new file mode 100644 index 000000000..bc27f513d --- /dev/null +++ b/packages/raystack/components/menu/menu-item.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + Menu as MenuPrimitive +} from '@base-ui/react'; +import { forwardRef } from 'react'; +import { Cell, CellBaseProps } from './cell'; +import { useMenuContext } from './menu-root'; +import { getMatch } from './utils'; + +export interface MenuItemProps extends MenuPrimitive.Item.Props, CellBaseProps { + value?: string; +} + +export const MenuItem = forwardRef( + ({ children, value, leadingIcon, trailingIcon, render, ...props }, ref) => { + const { autocomplete, searchValue, shouldFilter } = useMenuContext(); + + const cell = render ?? ( + + ); + const commonProps = { + ref, + render: render ?? ( + + ), + children + }; + + // In auto mode, hide items that don't match the search value + if (shouldFilter && !getMatch(value, children, searchValue)) { + return null; + } + + if (autocomplete) { + return ( + + ); + } + + return ( + + // ) : ( + // cell + // ) + // } + /> + ); + } +); +MenuItem.displayName = 'Menu.Item'; diff --git a/packages/raystack/components/menu/menu-misc.tsx b/packages/raystack/components/menu/menu-misc.tsx new file mode 100644 index 000000000..929f8b80c --- /dev/null +++ b/packages/raystack/components/menu/menu-misc.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { cx } from 'class-variance-authority'; +import { Fragment, forwardRef, HTMLAttributes, ReactNode } from 'react'; +import styles from './menu.module.css'; +import { useMenuContext } from './menu-root'; + +export const MenuGroup = forwardRef( + ({ className, children, ...props }, ref) => { + const { shouldFilter } = useMenuContext(); + + if (shouldFilter) { + return {children}; + } + + return ( + + {children} + + ); + } +); +MenuGroup.displayName = 'Menu.Group'; + +export const MenuLabel = forwardRef< + HTMLDivElement, + MenuPrimitive.GroupLabel.Props +>(({ className, ...props }, ref) => { + const { shouldFilter } = useMenuContext(); + + if (shouldFilter) { + return null; + } + + return ( + + ); +}); +MenuLabel.displayName = 'Menu.Label'; + +export const MenuSeparator = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => { + const { shouldFilter } = useMenuContext(); + + if (shouldFilter) { + return null; + } + + return ( +
+ ); +}); +MenuSeparator.displayName = 'Menu.Separator'; + +export const MenuEmptyState = forwardRef< + HTMLDivElement, + HTMLAttributes & { + children: ReactNode; + } +>(({ className, children, ...props }, ref) => ( +
+ {children} +
+)); +MenuEmptyState.displayName = 'Menu.EmptyState'; diff --git a/packages/raystack/components/menu/menu-root.tsx b/packages/raystack/components/menu/menu-root.tsx new file mode 100644 index 000000000..23f067961 --- /dev/null +++ b/packages/raystack/components/menu/menu-root.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + Menu as MenuPrimitive +} from '@base-ui/react'; +import { createContext, useCallback, useContext, useState } from 'react'; + +interface CommonProps { + autocomplete?: boolean; + autocompleteMode?: 'auto' | 'manual'; + searchValue?: string; +} + +interface MenuContextValue extends CommonProps { + parent?: CommonProps; + // onSearch?: (value: string) => void; +} + +interface UseMenuContextReturn extends MenuContextValue { + shouldFilter?: boolean; + parent?: CommonProps & { + shouldFilter?: boolean; + }; +} + +/** + Root context to manage the Menu control + @remarks Only for internal usage. + */ +export const MenuContext = createContext( + undefined +); + +export const useMenuContext = (): UseMenuContextReturn => { + const context = useContext(MenuContext); + if (!context) return {}; + + const shouldFilter = !!( + context?.autocomplete && + context?.autocompleteMode === 'auto' && + context?.searchValue?.length + ); + + const shouldFilterParent = !!( + context?.parent?.autocomplete && + context?.parent?.autocompleteMode === 'auto' && + context?.parent?.searchValue?.length + ); + + return { + ...context, + shouldFilter, + parent: context?.parent && { + ...context.parent, + shouldFilter: shouldFilterParent + } + }; +}; + +export interface MenuRootBaseProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: MenuPrimitive.Root.Props['onOpenChange']; + modal?: boolean; + loopFocus?: boolean; +} + +export interface NormalMenuRootProps extends MenuPrimitive.Root.Props { + autocomplete?: false; + autocompleteMode?: never; + searchValue?: never; + onSearch?: never; + defaultSearchValue?: never; +} + +export interface AutocompleteMenuRootProps + extends MenuPrimitive.Root.Props, + CommonProps { + autocomplete: true; + onSearch?: (value: string) => void; + defaultSearchValue?: string; +} + +export type MenuRootProps = NormalMenuRootProps | AutocompleteMenuRootProps; + +export const MenuRoot = ({ + autocomplete, + autocompleteMode = 'auto', + searchValue: providedSearchValue, + onSearch, + defaultSearchValue = '', + ...props +}: MenuRootProps) => { + const [internalSearchValue, setInternalSearchValue] = + useState(defaultSearchValue); + const parentContext = useMenuContext(); + + const searchValue = providedSearchValue ?? internalSearchValue; + + const setValue = useCallback( + (value: string) => { + setInternalSearchValue(value); + onSearch?.(value); + }, + [onSearch] + ); + + const element = ; + + console.log('menu root'); + + return ( + + {autocomplete ? ( + setValue(value)} + autoHighlight + > + {element} + + ) : ( + element + )} + + ); +}; +MenuRoot.displayName = 'Menu'; + +export interface NormalMenuSubMenuProps + extends MenuPrimitive.SubmenuRoot.Props { + autocomplete?: false; + autocompleteMode?: never; + searchValue?: never; + onSearch?: never; + defaultSearchValue?: never; +} + +export interface AutocompleteMenuSubMenuProps + extends MenuPrimitive.SubmenuRoot.Props { + autocomplete: true; + autocompleteMode?: 'auto' | 'manual'; + searchValue?: string; + onSearch?: (value: string) => void; + defaultSearchValue?: string; +} + +export type MenuSubMenuProps = + | NormalMenuSubMenuProps + | AutocompleteMenuSubMenuProps; + +export const MenuSubMenu = ({ + autocomplete, + autocompleteMode = 'auto', + searchValue: providedSearchValue, + onSearch, + defaultSearchValue = '', + ...props +}: MenuSubMenuProps) => { + const [internalSearchValue, setInternalSearchValue] = + useState(defaultSearchValue); + const parentContext = useMenuContext(); + + const searchValue = providedSearchValue ?? internalSearchValue; + + const setValue = useCallback( + (value: string) => { + setInternalSearchValue(value); + onSearch?.(value); + }, + [onSearch] + ); + + const element = ; + + return ( + + {autocomplete ? ( + setValue(value)} + autoHighlight + open + > + {element} + + ) : ( + element + )} + + ); +}; +MenuSubMenu.displayName = 'Menu.SubMenu'; diff --git a/packages/raystack/components/menu/menu-subtrigger.tsx b/packages/raystack/components/menu/menu-subtrigger.tsx new file mode 100644 index 000000000..99a143b14 --- /dev/null +++ b/packages/raystack/components/menu/menu-subtrigger.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { forwardRef } from 'react'; +import { TriangleRightIcon } from '~/icons'; +import { Cell, CellBaseProps } from './cell'; +import { useMenuContext } from './menu-root'; +import { getMatch } from './utils'; + +export interface MenuSubTriggerProps + extends MenuPrimitive.SubmenuTrigger.Props, + CellBaseProps { + value?: string; +} + +export const MenuSubTrigger = forwardRef( + ( + { + children, + value, + trailingIcon = , + leadingIcon, + ...props + }, + ref + ) => { + const { parent } = useMenuContext(); + + if ( + parent?.shouldFilter && + !getMatch(value, children, parent?.searchValue) + ) { + return null; + } + + return ( + } + {...props} + > + {children} + + ); + } +); +MenuSubTrigger.displayName = 'Menu.SubTrigger'; diff --git a/packages/raystack/components/menu/menu-trigger.tsx b/packages/raystack/components/menu/menu-trigger.tsx new file mode 100644 index 000000000..1da76b4e1 --- /dev/null +++ b/packages/raystack/components/menu/menu-trigger.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Menu as MenuPrimitive } from '@base-ui/react'; +import { forwardRef } from 'react'; + +export interface MenuTriggerProps extends MenuPrimitive.Trigger.Props { + stopPropagation?: boolean; +} + +export const MenuTrigger = forwardRef( + ({ children, stopPropagation = true, onClick, ...props }, ref) => { + console.log('menu trigger'); + return ( + { + if (stopPropagation) e.stopPropagation(); + onClick?.(e); + }} + {...props} + > + {children} + + ); + } +); +MenuTrigger.displayName = 'Menu.Trigger'; diff --git a/packages/raystack/components/menu/menu.module.css b/packages/raystack/components/menu/menu.module.css new file mode 100644 index 000000000..29b30396a --- /dev/null +++ b/packages/raystack/components/menu/menu.module.css @@ -0,0 +1,88 @@ +.content { + overflow: hidden; + z-index: var(--rs-z-index-portal); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + box-sizing: border-box; + min-width: 80px; + + padding: var(--rs-space-2); + background-color: var(--rs-color-background-base-primary); + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-lifted); + border: 0.5px solid var(--rs-color-border-base-primary); + color: var(--rs-color-foreground-base-primary); + min-width: var(--popover-anchor-width); +} +.content:focus, +.content:focus-visible { + outline: none; +} + +.comboboxContainer { + padding: 0; +} + +.comboboxContent { + padding: var(--rs-space-2); +} + +.comboboxContent:empty { + padding: 0; +} + +.comboboxInput { + width: 100%; + background: transparent; + color: var(--rs-color-foreground-base-primary); + padding: var(--rs-space-3) var(--rs-space-4); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.comboboxContainer:has(.comboboxContent:empty) .comboboxInput { + border-bottom: none; +} + +.comboboxInput:focus { + outline: none; +} + +.label { + padding: var(--rs-space-2) var(--rs-space-3); + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); +} + +.separator { + height: 1px; + margin: var(--rs-space-2) calc(var(--rs-space-3) * -1); + background: var(--rs-color-border-base-primary); +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--rs-space-4); + text-align: center; +} + +.leadingIcon { + display: flex; + align-items: center; + color: var(--rs-color-foreground-base-secondary); +} + +.trailingIcon { + display: flex; + align-items: center; + color: var(--rs-color-foreground-base-secondary); + margin-left: auto; +} diff --git a/packages/raystack/components/menu/menu.tsx b/packages/raystack/components/menu/menu.tsx new file mode 100644 index 000000000..471c44422 --- /dev/null +++ b/packages/raystack/components/menu/menu.tsx @@ -0,0 +1,24 @@ +import { MenuContent, MenuSubContent } from './menu-content'; +import { MenuItem } from './menu-item'; +import { + MenuEmptyState, + MenuGroup, + MenuLabel, + MenuSeparator +} from './menu-misc'; +import { MenuRoot, MenuSubMenu } from './menu-root'; +import { MenuSubTrigger } from './menu-subtrigger'; +import { MenuTrigger } from './menu-trigger'; + +export const Menu = Object.assign(MenuRoot, { + Trigger: MenuTrigger, + Content: MenuContent, + Item: MenuItem, + Group: MenuGroup, + Label: MenuLabel, + Separator: MenuSeparator, + EmptyState: MenuEmptyState, + SubMenu: MenuSubMenu, + SubTrigger: MenuSubTrigger, + SubContent: MenuSubContent +}); diff --git a/packages/raystack/components/menu/utils.ts b/packages/raystack/components/menu/utils.ts new file mode 100644 index 000000000..5d0e9825e --- /dev/null +++ b/packages/raystack/components/menu/utils.ts @@ -0,0 +1,23 @@ +import { ReactNode } from 'react'; + +export const getMatch = ( + value?: string, + children?: ReactNode, + search?: string +) => { + if (!search?.length) return true; + const childrenValue = getChildrenValue(children)?.toLowerCase(); + + return ( + value?.toLowerCase().includes(search.toLowerCase()) || + childrenValue?.includes(search.toLowerCase()) + ); +}; + +export const getChildrenValue = (children?: ReactNode) => { + if (typeof children === 'string') return children; + if (typeof children === 'object' && children !== null) { + return children.toString(); + } + return null; +}; diff --git a/packages/raystack/components/select/select-item.tsx b/packages/raystack/components/select/select-item.tsx index a499d5ee7..6676b655a 100644 --- a/packages/raystack/components/select/select-item.tsx +++ b/packages/raystack/components/select/select-item.tsx @@ -5,7 +5,7 @@ import { cx } from 'class-variance-authority'; import { Select as SelectPrimitive } from 'radix-ui'; import { ElementRef, forwardRef, useLayoutEffect } from 'react'; import { Checkbox } from '../checkbox'; -import { getMatch } from '../dropdown-menu/utils'; +import { getMatch } from '../menu/utils'; import { Text } from '../text'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 24d5d72e9..20696b59e 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -28,7 +28,6 @@ export { useDataTable } from './components/data-table'; export { Dialog } from './components/dialog'; -export { DropdownMenu } from './components/dropdown-menu'; export { EmptyState } from './components/empty-state'; export { FilterChip } from './components/filter-chip'; export { Flex } from './components/flex'; @@ -41,6 +40,7 @@ export { InputField } from './components/input-field'; export { Label } from './components/label'; export { Link } from './components/link'; export { List } from './components/list'; +export { Menu } from './components/menu'; export { Navbar } from './components/navbar'; export { Popover } from './components/popover'; export { Radio } from './components/radio'; From c89b0e59c719cc1dbb9263597cd97ba575ef1be7 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 18 Feb 2026 18:04:58 +0530 Subject: [PATCH 2/6] wip: filterable menu --- .../raystack/components/menu/menu-content.tsx | 39 ++++++++--- .../raystack/components/menu/menu-item.tsx | 38 ++++++----- .../raystack/components/menu/menu-root.tsx | 67 ++++++++----------- .../components/menu/menu-subtrigger.tsx | 33 ++++++++- .../raystack/components/menu/menu-trigger.tsx | 4 +- 5 files changed, 110 insertions(+), 71 deletions(-) diff --git a/packages/raystack/components/menu/menu-content.tsx b/packages/raystack/components/menu/menu-content.tsx index c381f9148..99aed60b0 100644 --- a/packages/raystack/components/menu/menu-content.tsx +++ b/packages/raystack/components/menu/menu-content.tsx @@ -1,11 +1,11 @@ 'use client'; import { - Autocomplete as AutocompletePrimitive, + Combobox as ComboboxPrimitive, Menu as MenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { forwardRef } from 'react'; +import { forwardRef, useRef } from 'react'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; @@ -29,7 +29,8 @@ export const MenuContent = forwardRef( }, ref ) => { - const { autocomplete } = useMenuContext(); + const { autocomplete, searchValue, onSearch } = useMenuContext(); + const inputRef = useRef(null); return ( @@ -47,22 +48,42 @@ export const MenuContent = forwardRef( )} {...props} role={autocomplete ? 'dialog' : undefined} + onFocus={e => { + console.log('focus'); + inputRef.current?.focus(); + }} > {autocomplete ? ( - <> - onSearch?.(value)} + autoHighlight + highlightItemOnHover + > + { + if (e.key !== 'Escape') { + e.stopPropagation(); + } + }} + onBlurCapture={e => { + console.log('blur'); e.stopPropagation(); + e.preventDefault(); + // e.preventBaseUIHandler(); }} - tabIndex={-1} /> - + {children} - - + + ) : ( children )} diff --git a/packages/raystack/components/menu/menu-item.tsx b/packages/raystack/components/menu/menu-item.tsx index bc27f513d..ff91ee81b 100644 --- a/packages/raystack/components/menu/menu-item.tsx +++ b/packages/raystack/components/menu/menu-item.tsx @@ -1,7 +1,7 @@ 'use client'; import { - Autocomplete as AutocompletePrimitive, + Combobox as ComboboxPrimitive, Menu as MenuPrimitive } from '@base-ui/react'; import { forwardRef } from 'react'; @@ -20,13 +20,6 @@ export const MenuItem = forwardRef( const cell = render ?? ( ); - const commonProps = { - ref, - render: render ?? ( - - ), - children - }; // In auto mode, hide items that don't match the search value if (shouldFilter && !getMatch(value, children, searchValue)) { @@ -35,22 +28,31 @@ export const MenuItem = forwardRef( if (autocomplete) { return ( - + } + // render={cell} + {...props} + > + {children} + ); } return ( - // ) : ( - // cell - // ) - // } - /> + onFocus={e => { + e.stopPropagation(); + e.preventDefault(); + e.preventBaseUIHandler(); + }} + > + {children} + ); } ); diff --git a/packages/raystack/components/menu/menu-root.tsx b/packages/raystack/components/menu/menu-root.tsx index 23f067961..d25062895 100644 --- a/packages/raystack/components/menu/menu-root.tsx +++ b/packages/raystack/components/menu/menu-root.tsx @@ -1,9 +1,6 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - Menu as MenuPrimitive -} from '@base-ui/react'; +import { Menu as MenuPrimitive } from '@base-ui/react'; import { createContext, useCallback, useContext, useState } from 'react'; interface CommonProps { @@ -14,7 +11,7 @@ interface CommonProps { interface MenuContextValue extends CommonProps { parent?: CommonProps; - // onSearch?: (value: string) => void; + onSearch?: (value: string) => void; } interface UseMenuContextReturn extends MenuContextValue { @@ -106,9 +103,16 @@ export const MenuRoot = ({ [onSearch] ); - const element = ; - - console.log('menu root'); + const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = + useCallback( + (open: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { + if (!open && autocomplete) { + setValue(''); + } + props.onOpenChange?.(open, eventDetails); + }, + [props.onOpenChange, setValue, autocomplete] + ); return ( - {autocomplete ? ( - setValue(value)} - autoHighlight - > - {element} - - ) : ( - element - )} + ); }; @@ -182,7 +174,15 @@ export const MenuSubMenu = ({ [onSearch] ); - const element = ; + const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = + useCallback( + (open: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { + if (!open && autocomplete) { + setValue(''); + } + }, + [setValue, autocomplete] + ); return ( - {autocomplete ? ( - setValue(value)} - autoHighlight - open - > - {element} - - ) : ( - element - )} + ); }; diff --git a/packages/raystack/components/menu/menu-subtrigger.tsx b/packages/raystack/components/menu/menu-subtrigger.tsx index 99a143b14..d165dd77b 100644 --- a/packages/raystack/components/menu/menu-subtrigger.tsx +++ b/packages/raystack/components/menu/menu-subtrigger.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Combobox } from '@base-ui/react'; import { Menu as MenuPrimitive } from '@base-ui/react/menu'; import { forwardRef } from 'react'; import { TriangleRightIcon } from '~/icons'; @@ -32,11 +33,39 @@ export const MenuSubTrigger = forwardRef( ) { return null; } - + // if (parent?.autocomplete) { + // return ( + // + // } + // /> + // } + // {...props} + // > + // {children} + // + // ); + // } return ( } + render={ + parent?.autocomplete ? ( + + } + /> + ) : ( + + ) + } {...props} > {children} diff --git a/packages/raystack/components/menu/menu-trigger.tsx b/packages/raystack/components/menu/menu-trigger.tsx index 1da76b4e1..551604cf6 100644 --- a/packages/raystack/components/menu/menu-trigger.tsx +++ b/packages/raystack/components/menu/menu-trigger.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Menu as MenuPrimitive } from '@base-ui/react'; +import { Combobox, Menu as MenuPrimitive } from '@base-ui/react'; import { forwardRef } from 'react'; +import { useMenuContext } from './menu-root'; export interface MenuTriggerProps extends MenuPrimitive.Trigger.Props { stopPropagation?: boolean; @@ -9,7 +10,6 @@ export interface MenuTriggerProps extends MenuPrimitive.Trigger.Props { export const MenuTrigger = forwardRef( ({ children, stopPropagation = true, onClick, ...props }, ref) => { - console.log('menu trigger'); return ( Date: Thu, 19 Feb 2026 01:04:02 +0530 Subject: [PATCH 3/6] wip: migrate dropdown --- .../raystack/components/menu/menu-content.tsx | 64 +++++++++++------- .../raystack/components/menu/menu-item.tsx | 8 ++- .../components/menu/menu-subtrigger.tsx | 65 ++++++++++++++++--- 3 files changed, 101 insertions(+), 36 deletions(-) diff --git a/packages/raystack/components/menu/menu-content.tsx b/packages/raystack/components/menu/menu-content.tsx index 99aed60b0..db898994e 100644 --- a/packages/raystack/components/menu/menu-content.tsx +++ b/packages/raystack/components/menu/menu-content.tsx @@ -1,11 +1,11 @@ 'use client'; import { - Combobox as ComboboxPrimitive, + Autocomplete as AutocompletePrimitive, Menu as MenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { forwardRef, useRef } from 'react'; +import { forwardRef, useCallback, useRef } from 'react'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; @@ -14,11 +14,13 @@ export interface MenuContentProps extends MenuPrimitive.Popup.Props { sideOffset?: number; side?: MenuPrimitive.Positioner.Props['side']; align?: MenuPrimitive.Positioner.Props['align']; + name?: string; } export const MenuContent = forwardRef( ( { + name, className, children, searchPlaceholder = 'Search...', @@ -31,6 +33,10 @@ export const MenuContent = forwardRef( ) => { const { autocomplete, searchValue, onSearch } = useMenuContext(); const inputRef = useRef(null); + const focusInput = useCallback(() => { + if (document?.activeElement !== inputRef.current) + inputRef.current?.focus(); + }, []); return ( @@ -48,42 +54,52 @@ export const MenuContent = forwardRef( )} {...props} role={autocomplete ? 'dialog' : undefined} - onFocus={e => { - console.log('focus'); - inputRef.current?.focus(); - }} + onFocus={ + autocomplete + ? e => { + console.log('focus ', name); + focusInput(); + e.stopPropagation(); + } + : undefined + } + // onMouseEnter={e => { + // console.log('mouse enter'); + // inputRef.current?.focus(); + // e.stopPropagation(); + // }} + data-menu > {autocomplete ? ( - onSearch?.(value)} - autoHighlight - highlightItemOnHover + value={searchValue} + onValueChange={(value: string) => onSearch?.(value)} + autoHighlight={searchValue?.length} + mode='none' + loopFocus={false} > - { - if (e.key !== 'Escape') { - e.stopPropagation(); - } - }} - onBlurCapture={e => { - console.log('blur'); + if ( + e.key === 'Escape' || + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' + ) + return; + console.log('key down', e.key); e.stopPropagation(); - e.preventDefault(); - // e.preventBaseUIHandler(); }} /> - + {children} - - + + ) : ( children )} diff --git a/packages/raystack/components/menu/menu-item.tsx b/packages/raystack/components/menu/menu-item.tsx index ff91ee81b..fc8059f20 100644 --- a/packages/raystack/components/menu/menu-item.tsx +++ b/packages/raystack/components/menu/menu-item.tsx @@ -1,7 +1,7 @@ 'use client'; import { - Combobox as ComboboxPrimitive, + Autocomplete as AutocompletePrimitive, Menu as MenuPrimitive } from '@base-ui/react'; import { forwardRef } from 'react'; @@ -28,15 +28,17 @@ export const MenuItem = forwardRef( if (autocomplete) { return ( - } // render={cell} {...props} + // aria-selected={false} + // data-selected={undefined} > {children} - + ); } diff --git a/packages/raystack/components/menu/menu-subtrigger.tsx b/packages/raystack/components/menu/menu-subtrigger.tsx index d165dd77b..8bfb2d0f4 100644 --- a/packages/raystack/components/menu/menu-subtrigger.tsx +++ b/packages/raystack/components/menu/menu-subtrigger.tsx @@ -1,6 +1,9 @@ 'use client'; -import { Combobox } from '@base-ui/react'; +import { + Autocomplete as AutocompletePrimitive, + Combobox +} from '@base-ui/react'; import { Menu as MenuPrimitive } from '@base-ui/react/menu'; import { forwardRef } from 'react'; import { TriangleRightIcon } from '~/icons'; @@ -33,40 +36,84 @@ export const MenuSubTrigger = forwardRef( ) { return null; } + // if (parent?.autocomplete) { // return ( // - // } - // /> + // // } - // {...props} + // // render={ + // // + // // } + // // /> + // // } + // // aria-selected={false} + // // data-selected={undefined} + // onKeyDown={e => { + // console.log('sub trigger key down', e.key); + // // if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + // // e.stopPropagation(); + // // e.preventDefault(); + // // } + // }} // > // {children} // // ); // } + + // return ( + // } + // > + // {children} + // + // ); + return ( } + onKeyDown={e => { + console.log('sub trigger key down', e.key); + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.stopPropagation(); + e.preventDefault(); + } + }} /> ) : ( ) } {...props} + role={parent?.autocomplete ? 'option' : undefined} + // tabIndex={undefined} + // data-selected={undefined} + // onFocus={e => { + // console.log('sub trigger focus'); + // // e.stopPropagation(); + // // e.preventDefault(); + // // e.preventBaseUIHandler(); + // }} + // onBlur={e => { + // console.log('sub trigger blur'); + // // e.stopPropagation(); + // // e.preventDefault(); + // // e.preventBaseUIHandler(); + // }} > {children} From 228591c38faaa5bcfc1a2dce1ca12e3b32ae3d87 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 19 Feb 2026 04:50:15 +0530 Subject: [PATCH 4/6] feat: migrate menu to base ui --- .../raystack/components/menu/cell.module.css | 3 +- .../raystack/components/menu/menu-content.tsx | 155 ++++++++++++++---- .../raystack/components/menu/menu-item.tsx | 4 +- .../raystack/components/menu/menu-root.tsx | 145 ++++++++++------ .../components/menu/menu-subtrigger.tsx | 123 -------------- .../raystack/components/menu/menu-trigger.tsx | 70 +++++++- packages/raystack/components/menu/menu.tsx | 9 +- packages/raystack/components/menu/utils.ts | 29 ++++ 8 files changed, 320 insertions(+), 218 deletions(-) delete mode 100644 packages/raystack/components/menu/menu-subtrigger.tsx diff --git a/packages/raystack/components/menu/cell.module.css b/packages/raystack/components/menu/cell.module.css index 80068251f..53f2f5390 100644 --- a/packages/raystack/components/menu/cell.module.css +++ b/packages/raystack/components/menu/cell.module.css @@ -10,7 +10,8 @@ letter-spacing: var(--rs-letter-spacing-small); } -.cell[data-highlighted] { +.cell[data-highlighted], +.cell[data-popup-open] { outline: none; cursor: pointer; font-weight: var(--rs-font-weight-regular); diff --git a/packages/raystack/components/menu/menu-content.tsx b/packages/raystack/components/menu/menu-content.tsx index db898994e..06e934760 100644 --- a/packages/raystack/components/menu/menu-content.tsx +++ b/packages/raystack/components/menu/menu-content.tsx @@ -8,42 +8,111 @@ import { cx } from 'class-variance-authority'; import { forwardRef, useCallback, useRef } from 'react'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; +import { + dispatchKeyboardEvent, + isElementSubMenuOpen, + isElementSubMenuTrigger, + KEYCODES +} from './utils'; -export interface MenuContentProps extends MenuPrimitive.Popup.Props { +export interface MenuContentProps + extends Omit< + MenuPrimitive.Positioner.Props, + 'render' | 'className' | 'style' + >, + MenuPrimitive.Popup.Props { searchPlaceholder?: string; - sideOffset?: number; - side?: MenuPrimitive.Positioner.Props['side']; - align?: MenuPrimitive.Positioner.Props['align']; - name?: string; } export const MenuContent = forwardRef( ( { - name, className, children, searchPlaceholder = 'Search...', + render, + finalFocus, + style, sideOffset = 4, - side, - align, - ...props + align = 'start', + onFocus, + ...positionerProps }, ref ) => { - const { autocomplete, searchValue, onSearch } = useMenuContext(); - const inputRef = useRef(null); + const { + autocomplete, + inputValue, + onInputValueChange, + inputRef, + isInitialRender, + parent + } = useMenuContext(); + const focusInput = useCallback(() => { - if (document?.activeElement !== inputRef.current) - inputRef.current?.focus(); + if (document?.activeElement !== inputRef?.current) + inputRef?.current?.focus(); + }, [inputRef]); + const highlightedItem = useRef< + [index: number, reason: 'keyboard' | 'pointer' | 'none'] + >([-1, 'none']); + const containerRef = useRef(null); + + const highlightFirstItem = useCallback(() => { + if (!isInitialRender?.current) return; + isInitialRender.current = false; + const item = containerRef.current?.querySelector( + '[role="option"]:nth-child(1)' + ); + if (!item) return; + item.dispatchEvent(new PointerEvent('mousemove', { bubbles: true })); + }, [isInitialRender]); + + const checkAndOpenSubMenu = useCallback(() => { + if (highlightedItem.current[0] === -1) return; + + const item = containerRef.current?.querySelector( + `[role="option"]:nth-child(${highlightedItem.current[0] + 1})` + ); + if (!item || !isElementSubMenuTrigger(item)) return; + dispatchKeyboardEvent(item, KEYCODES.ARROW_RIGHT); + }, []); + + const checkAndCloseSubMenu = useCallback((e: KeyboardEvent) => { + if (highlightedItem.current[0] === -1) return; + const item = containerRef.current?.querySelector( + `[role="option"]:nth-child(${highlightedItem.current[0] + 1})` + ); + if ( + !item || + !isElementSubMenuTrigger(item) || + !isElementSubMenuOpen(item) + ) + return; + dispatchKeyboardEvent(item, KEYCODES.ESCAPE); + e.stopPropagation(); + }, []); + + const blurStaleMenuItem = useCallback((index: number) => { + const item = containerRef.current?.querySelector( + `[role="option"]:nth-child(${index + 1})` + ); + if ( + !item || + !isElementSubMenuTrigger(item) || + !isElementSubMenuOpen(item) + ) + return; + dispatchKeyboardEvent(item, KEYCODES.ESCAPE); + item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); }, []); return ( ( autocomplete && styles.comboboxContainer, className )} - {...props} - role={autocomplete ? 'dialog' : undefined} + style={style} + render={render} + finalFocus={finalFocus} + role={autocomplete ? 'dialog' : 'menu'} onFocus={ - autocomplete + autocomplete || parent?.autocomplete ? e => { - console.log('focus ', name); focusInput(); e.stopPropagation(); + highlightFirstItem(); + onFocus?.(e); } : undefined } - // onMouseEnter={e => { - // console.log('mouse enter'); - // inputRef.current?.focus(); - // e.stopPropagation(); - // }} - data-menu > {autocomplete ? ( onSearch?.(value)} - autoHighlight={searchValue?.length} + value={inputValue} + onValueChange={(value: string) => onInputValueChange?.(value)} + autoHighlight={!!inputValue?.length} mode='none' loopFocus={false} + onItemHighlighted={(value, eventDetails) => { + if ( + highlightedItem.current[1] === 'pointer' && + eventDetails.reason === 'keyboard' + ) { + // focus moved using keyboard after using pointer + blurStaleMenuItem(highlightedItem.current[0]); + } + highlightedItem.current = [ + eventDetails.index, + eventDetails.reason + ]; + }} > { + focusInput(); + }} onKeyDown={e => { - if ( - e.key === 'Escape' || - e.key === 'ArrowLeft' || - e.key === 'ArrowRight' - ) - return; - console.log('key down', e.key); + if (e.key === 'ArrowLeft') return; + if (e.key === 'Escape') + return checkAndCloseSubMenu(e.nativeEvent); + if (e.key === 'ArrowRight' || e.key === 'Enter') + checkAndOpenSubMenu(); e.stopPropagation(); }} + tabIndex={-1} /> - + {children} diff --git a/packages/raystack/components/menu/menu-item.tsx b/packages/raystack/components/menu/menu-item.tsx index fc8059f20..d8c1a0446 100644 --- a/packages/raystack/components/menu/menu-item.tsx +++ b/packages/raystack/components/menu/menu-item.tsx @@ -15,14 +15,14 @@ export interface MenuItemProps extends MenuPrimitive.Item.Props, CellBaseProps { export const MenuItem = forwardRef( ({ children, value, leadingIcon, trailingIcon, render, ...props }, ref) => { - const { autocomplete, searchValue, shouldFilter } = useMenuContext(); + const { autocomplete, inputValue, shouldFilter } = useMenuContext(); const cell = render ?? ( ); // In auto mode, hide items that don't match the search value - if (shouldFilter && !getMatch(value, children, searchValue)) { + if (shouldFilter && !getMatch(value, children, inputValue)) { return null; } diff --git a/packages/raystack/components/menu/menu-root.tsx b/packages/raystack/components/menu/menu-root.tsx index d25062895..ef1f6d2f0 100644 --- a/packages/raystack/components/menu/menu-root.tsx +++ b/packages/raystack/components/menu/menu-root.tsx @@ -1,17 +1,27 @@ 'use client'; import { Menu as MenuPrimitive } from '@base-ui/react'; -import { createContext, useCallback, useContext, useState } from 'react'; +import { + createContext, + useCallback, + useContext, + useRef, + useState +} from 'react'; interface CommonProps { autocomplete?: boolean; autocompleteMode?: 'auto' | 'manual'; - searchValue?: string; + inputValue?: string; + inputRef?: React.RefObject; + contentRef?: React.RefObject; + isInitialRender?: React.RefObject; + open?: boolean; } interface MenuContextValue extends CommonProps { parent?: CommonProps; - onSearch?: (value: string) => void; + onInputValueChange?: (value: string) => void; } interface UseMenuContextReturn extends MenuContextValue { @@ -36,13 +46,13 @@ export const useMenuContext = (): UseMenuContextReturn => { const shouldFilter = !!( context?.autocomplete && context?.autocompleteMode === 'auto' && - context?.searchValue?.length + context?.inputValue?.length ); const shouldFilterParent = !!( context?.parent?.autocomplete && context?.parent?.autocompleteMode === 'auto' && - context?.parent?.searchValue?.length + context?.parent?.inputValue?.length ); return { @@ -66,17 +76,17 @@ export interface MenuRootBaseProps { export interface NormalMenuRootProps extends MenuPrimitive.Root.Props { autocomplete?: false; autocompleteMode?: never; - searchValue?: never; - onSearch?: never; - defaultSearchValue?: never; + inputValue?: never; + onInputValueChange?: never; + defaultInputValue?: never; } export interface AutocompleteMenuRootProps extends MenuPrimitive.Root.Props, CommonProps { autocomplete: true; - onSearch?: (value: string) => void; - defaultSearchValue?: string; + onInputValueChange?: (value: string) => void; + defaultInputValue?: string; } export type MenuRootProps = NormalMenuRootProps | AutocompleteMenuRootProps; @@ -84,47 +94,64 @@ export type MenuRootProps = NormalMenuRootProps | AutocompleteMenuRootProps; export const MenuRoot = ({ autocomplete, autocompleteMode = 'auto', - searchValue: providedSearchValue, - onSearch, - defaultSearchValue = '', + inputValue: providedInputValue, + onInputValueChange, + defaultInputValue = '', + open: providedOpen, + onOpenChange, + defaultOpen = false, ...props }: MenuRootProps) => { - const [internalSearchValue, setInternalSearchValue] = - useState(defaultSearchValue); - const parentContext = useMenuContext(); + const [internalInputValue, setInternalInputValue] = + useState(defaultInputValue); + + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const open = providedOpen ?? internalOpen; + const inputRef = useRef(null); + const contentRef = useRef(null); + const isInitialRender = useRef(true); - const searchValue = providedSearchValue ?? internalSearchValue; + const inputValue = providedInputValue ?? internalInputValue; const setValue = useCallback( (value: string) => { - setInternalSearchValue(value); - onSearch?.(value); + setInternalInputValue(value); + onInputValueChange?.(value); }, - [onSearch] + [onInputValueChange] ); const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = useCallback( - (open: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { - if (!open && autocomplete) { + (value: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { + if (!value && autocomplete) { setValue(''); + isInitialRender.current = true; } - props.onOpenChange?.(open, eventDetails); + setInternalOpen(value); + onOpenChange?.(value, eventDetails); }, - [props.onOpenChange, setValue, autocomplete] + [onOpenChange, setValue, autocomplete] ); return ( - + ); }; @@ -134,18 +161,18 @@ export interface NormalMenuSubMenuProps extends MenuPrimitive.SubmenuRoot.Props { autocomplete?: false; autocompleteMode?: never; - searchValue?: never; - onSearch?: never; - defaultSearchValue?: never; + inputValue?: never; + onInputValueChange?: never; + defaultInputValue?: never; } export interface AutocompleteMenuSubMenuProps extends MenuPrimitive.SubmenuRoot.Props { autocomplete: true; autocompleteMode?: 'auto' | 'manual'; - searchValue?: string; - onSearch?: (value: string) => void; - defaultSearchValue?: string; + inputValue?: string; + onInputValueChange?: (value: string) => void; + defaultInputValue?: string; } export type MenuSubMenuProps = @@ -155,46 +182,64 @@ export type MenuSubMenuProps = export const MenuSubMenu = ({ autocomplete, autocompleteMode = 'auto', - searchValue: providedSearchValue, - onSearch, - defaultSearchValue = '', + inputValue: providedInputValue, + onInputValueChange, + defaultInputValue = '', + open: providedOpen, + onOpenChange, + defaultOpen = false, ...props }: MenuSubMenuProps) => { - const [internalSearchValue, setInternalSearchValue] = - useState(defaultSearchValue); + const [internalInputValue, setInternalInputValue] = + useState(defaultInputValue); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const open = providedOpen ?? internalOpen; const parentContext = useMenuContext(); - - const searchValue = providedSearchValue ?? internalSearchValue; + const inputRef = useRef(null); + const isInitialRender = useRef(true); + const contentRef = useRef(null); + const inputValue = providedInputValue ?? internalInputValue; const setValue = useCallback( (value: string) => { - setInternalSearchValue(value); - onSearch?.(value); + setInternalInputValue(value); + onInputValueChange?.(value); }, - [onSearch] + [onInputValueChange] ); const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = useCallback( - (open: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { - if (!open && autocomplete) { + (value: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { + if (!value && autocomplete) { setValue(''); + isInitialRender.current = true; } + setInternalOpen(value); + onOpenChange?.(value, eventDetails); }, - [setValue, autocomplete] + [onOpenChange, setValue, autocomplete] ); return ( - + ); }; diff --git a/packages/raystack/components/menu/menu-subtrigger.tsx b/packages/raystack/components/menu/menu-subtrigger.tsx deleted file mode 100644 index 8bfb2d0f4..000000000 --- a/packages/raystack/components/menu/menu-subtrigger.tsx +++ /dev/null @@ -1,123 +0,0 @@ -'use client'; - -import { - Autocomplete as AutocompletePrimitive, - Combobox -} from '@base-ui/react'; -import { Menu as MenuPrimitive } from '@base-ui/react/menu'; -import { forwardRef } from 'react'; -import { TriangleRightIcon } from '~/icons'; -import { Cell, CellBaseProps } from './cell'; -import { useMenuContext } from './menu-root'; -import { getMatch } from './utils'; - -export interface MenuSubTriggerProps - extends MenuPrimitive.SubmenuTrigger.Props, - CellBaseProps { - value?: string; -} - -export const MenuSubTrigger = forwardRef( - ( - { - children, - value, - trailingIcon = , - leadingIcon, - ...props - }, - ref - ) => { - const { parent } = useMenuContext(); - - if ( - parent?.shouldFilter && - !getMatch(value, children, parent?.searchValue) - ) { - return null; - } - - // if (parent?.autocomplete) { - // return ( - // - // } - // // render={ - // // - // // } - // // /> - // // } - // // aria-selected={false} - // // data-selected={undefined} - // onKeyDown={e => { - // console.log('sub trigger key down', e.key); - // // if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { - // // e.stopPropagation(); - // // e.preventDefault(); - // // } - // }} - // > - // {children} - // - // ); - // } - - // return ( - // } - // > - // {children} - // - // ); - - return ( - - } - onKeyDown={e => { - console.log('sub trigger key down', e.key); - if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { - e.stopPropagation(); - e.preventDefault(); - } - }} - /> - ) : ( - - ) - } - {...props} - role={parent?.autocomplete ? 'option' : undefined} - // tabIndex={undefined} - // data-selected={undefined} - // onFocus={e => { - // console.log('sub trigger focus'); - // // e.stopPropagation(); - // // e.preventDefault(); - // // e.preventBaseUIHandler(); - // }} - // onBlur={e => { - // console.log('sub trigger blur'); - // // e.stopPropagation(); - // // e.preventDefault(); - // // e.preventBaseUIHandler(); - // }} - > - {children} - - ); - } -); -MenuSubTrigger.displayName = 'Menu.SubTrigger'; diff --git a/packages/raystack/components/menu/menu-trigger.tsx b/packages/raystack/components/menu/menu-trigger.tsx index 551604cf6..2936100a0 100644 --- a/packages/raystack/components/menu/menu-trigger.tsx +++ b/packages/raystack/components/menu/menu-trigger.tsx @@ -1,8 +1,12 @@ 'use client'; -import { Combobox, Menu as MenuPrimitive } from '@base-ui/react'; +import { Autocomplete as AutocompletePrimitive } from '@base-ui/react'; +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; import { forwardRef } from 'react'; +import { TriangleRightIcon } from '~/icons'; +import { Cell, CellBaseProps } from './cell'; import { useMenuContext } from './menu-root'; +import { getMatch } from './utils'; export interface MenuTriggerProps extends MenuPrimitive.Trigger.Props { stopPropagation?: boolean; @@ -25,3 +29,67 @@ export const MenuTrigger = forwardRef( } ); MenuTrigger.displayName = 'Menu.Trigger'; + +export interface MenuSubTriggerProps + extends MenuPrimitive.SubmenuTrigger.Props, + CellBaseProps { + value?: string; +} + +export const MenuSubTrigger = forwardRef( + ( + { + children, + value, + trailingIcon = , + leadingIcon, + onPointerEnter, + onKeyDown, + ...props + }, + ref + ) => { + const { parent, inputRef } = useMenuContext(); + + if ( + parent?.shouldFilter && + !getMatch(value, children, parent?.inputValue) + ) { + return null; + } + + const cell = ; + return ( + { + if (document?.activeElement !== parent?.inputRef?.current) + parent?.inputRef?.current?.focus(); + onPointerEnter?.(e); + }} + onKeyDown={e => { + requestAnimationFrame(() => { + inputRef?.current?.focus(); + }); + onKeyDown?.(e); + }} + /> + ) : ( + cell + ) + } + {...props} + role={parent?.autocomplete ? 'option' : 'menuitem'} + data-slot='menu-subtrigger' + > + {children} + + ); + } +); +MenuSubTrigger.displayName = 'Menu.SubTrigger'; diff --git a/packages/raystack/components/menu/menu.tsx b/packages/raystack/components/menu/menu.tsx index 471c44422..51f4d3443 100644 --- a/packages/raystack/components/menu/menu.tsx +++ b/packages/raystack/components/menu/menu.tsx @@ -7,8 +7,7 @@ import { MenuSeparator } from './menu-misc'; import { MenuRoot, MenuSubMenu } from './menu-root'; -import { MenuSubTrigger } from './menu-subtrigger'; -import { MenuTrigger } from './menu-trigger'; +import { MenuSubTrigger, MenuTrigger } from './menu-trigger'; export const Menu = Object.assign(MenuRoot, { Trigger: MenuTrigger, @@ -18,7 +17,7 @@ export const Menu = Object.assign(MenuRoot, { Label: MenuLabel, Separator: MenuSeparator, EmptyState: MenuEmptyState, - SubMenu: MenuSubMenu, - SubTrigger: MenuSubTrigger, - SubContent: MenuSubContent + Submenu: MenuSubMenu, + SubmenuTrigger: MenuSubTrigger, + SubmenuContent: MenuSubContent }); diff --git a/packages/raystack/components/menu/utils.ts b/packages/raystack/components/menu/utils.ts index 5d0e9825e..49d26b418 100644 --- a/packages/raystack/components/menu/utils.ts +++ b/packages/raystack/components/menu/utils.ts @@ -21,3 +21,32 @@ export const getChildrenValue = (children?: ReactNode) => { } return null; }; + +export const KEYCODES: Record = { + ARROW_RIGHT: ['ArrowRight', 39], + ESCAPE: ['Escape', 27] +}; + +export const dispatchKeyboardEvent = ( + element: Element, + key: (typeof KEYCODES)[keyof typeof KEYCODES] +) => { + const [keyName, keyCode] = key; + return element.dispatchEvent( + new KeyboardEvent('keydown', { + key: keyName, + code: keyName, + keyCode: keyCode, + which: keyCode, + bubbles: true + }) + ); +}; + +export const isElementSubMenuTrigger = (element: Element) => { + return element.getAttribute('data-slot') === 'menu-subtrigger'; +}; + +export const isElementSubMenuOpen = (element: Element) => { + return element.hasAttribute('data-popup-open'); +}; From bba532ccb8de265d35f1859fa4ac72fea0d6950f Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 19 Feb 2026 05:09:35 +0530 Subject: [PATCH 5/6] feat: update docs and usage of menu --- apps/www/src/app/examples/menu/page.tsx | 59 ++++- apps/www/src/app/examples/page.tsx | 54 ++-- apps/www/src/components/demo/demo.tsx | 4 +- .../src/components/linear-dropdown-demo.tsx | 53 ++-- apps/www/src/components/playground/index.ts | 10 +- ...wn-menu-examples.tsx => menu-examples.tsx} | 6 +- .../content/docs/components/dropdown/props.ts | 116 -------- .../components/{dropdown => menu}/demo.ts | 172 +++++++----- .../components/{dropdown => menu}/index.mdx | 43 ++- .../src/content/docs/components/menu/props.ts | 209 +++++++++++++++ .../__tests__/dropdown-menu.test.tsx | 249 ------------------ .../components/dropdown-menu/cell.module.css | 40 --- .../components/dropdown-menu/cell.tsx | 32 --- .../dropdown-menu/dropdown-menu-content.tsx | 94 ------- .../dropdown-menu/dropdown-menu-item.tsx | 70 ----- .../dropdown-menu/dropdown-menu-misc.tsx | 89 ------- .../dropdown-menu/dropdown-menu-root.tsx | 140 ---------- .../dropdown-menu/dropdown-menu-trigger.tsx | 75 ------ .../dropdown-menu/dropdown-menu.module.css | 88 ------- .../dropdown-menu/dropdown-menu.tsx | 27 -- .../components/dropdown-menu/index.ts | 1 - .../components/dropdown-menu/types.ts | 3 - .../components/dropdown-menu/utils.ts | 23 -- .../raystack/components/menu/menu-content.tsx | 1 + .../raystack/components/menu/menu-root.tsx | 7 +- .../raystack/components/menu/menu.module.css | 6 +- pnpm-lock.yaml | 3 +- 27 files changed, 481 insertions(+), 1193 deletions(-) rename apps/www/src/components/playground/{dropdown-menu-examples.tsx => menu-examples.tsx} (92%) delete mode 100644 apps/www/src/content/docs/components/dropdown/props.ts rename apps/www/src/content/docs/components/{dropdown => menu}/demo.ts (72%) rename apps/www/src/content/docs/components/{dropdown => menu}/index.mdx (61%) create mode 100644 apps/www/src/content/docs/components/menu/props.ts delete mode 100644 packages/raystack/components/dropdown-menu/__tests__/dropdown-menu.test.tsx delete mode 100644 packages/raystack/components/dropdown-menu/cell.module.css delete mode 100644 packages/raystack/components/dropdown-menu/cell.tsx delete mode 100644 packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx delete mode 100644 packages/raystack/components/dropdown-menu/dropdown-menu-item.tsx delete mode 100644 packages/raystack/components/dropdown-menu/dropdown-menu-misc.tsx delete mode 100644 packages/raystack/components/dropdown-menu/dropdown-menu-root.tsx delete mode 100644 packages/raystack/components/dropdown-menu/dropdown-menu-trigger.tsx delete mode 100644 packages/raystack/components/dropdown-menu/dropdown-menu.module.css delete mode 100644 packages/raystack/components/dropdown-menu/dropdown-menu.tsx delete mode 100644 packages/raystack/components/dropdown-menu/index.ts delete mode 100644 packages/raystack/components/dropdown-menu/types.ts delete mode 100644 packages/raystack/components/dropdown-menu/utils.ts diff --git a/apps/www/src/app/examples/menu/page.tsx b/apps/www/src/app/examples/menu/page.tsx index d1b991c84..1acc35317 100644 --- a/apps/www/src/app/examples/menu/page.tsx +++ b/apps/www/src/app/examples/menu/page.tsx @@ -13,13 +13,60 @@ const Page = () => { direction='column' gap={8} > - + + }> + Basic Menu + + + Profile + Settings + + Logout + + Sub Menu + + Sub Menu Item 1 + Sub Menu Item 2 + Sub Menu Item 3 + + + + + + + }> + Searchable Menu + + + Copy + Delete... + + Sub Menu + + Sub Menu Item 1 + Sub Menu Item 2 + Sub Menu Item 3 + + + Delete... + + - }>Open Menu - - Item 1 - Item 2 - Item 3 + }> + Searchable Menu + + + Copy + Delete... + + Sub Menu + + Sub Menu Item 1 + Sub Menu Item 2 + Sub Menu Item 3 + + + Delete... diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index c876f710d..5a8aab348 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -1546,14 +1546,16 @@ const Page = () => { Open Menu - Team Actions - - Add Member - - Edit Team + + Team Actions + + Add Member + + Edit Team + Settings @@ -1678,14 +1680,16 @@ const Page = () => { Open Menu - Team Actions - - Add Member - - Edit Team + + Team Actions + + Add Member + + Edit Team + Settings @@ -1814,14 +1818,16 @@ const Page = () => { Open Menu - Team Actions - - Add Member - - Edit Team + + Team Actions + + Add Member + + Edit Team + Settings diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 9d133fbf6..83d2b8d66 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -13,7 +13,7 @@ import { Home, Info, Laugh, X } from 'lucide-react'; import NextLink from 'next/link'; import { Suspense } from 'react'; import DataTableDemo from '../datatable-demo'; -import LinearDropdownDemo from '../linear-dropdown-demo'; +import LinearMenuDemo from '../linear-dropdown-demo'; import PopoverColorPicker from '../popover-color-picker'; import DemoPlayground from './demo-playground'; import DemoPreview from './demo-preview'; @@ -25,7 +25,7 @@ export default function Demo(props: DemoProps) { scope = { ...Apsara, DataTableDemo, - LinearDropdownDemo, + LinearMenuDemo, PopoverColorPicker, Info, X, diff --git a/apps/www/src/components/linear-dropdown-demo.tsx b/apps/www/src/components/linear-dropdown-demo.tsx index 88a4760b3..31eeb2ba6 100644 --- a/apps/www/src/components/linear-dropdown-demo.tsx +++ b/apps/www/src/components/linear-dropdown-demo.tsx @@ -2,7 +2,7 @@ import { Avatar, Button, Flex, Menu, Text } from '@raystack/apsara'; import { Calendar, ChevronRight, Download } from 'lucide-react'; import { Fragment, ReactNode, useState } from 'react'; -type DropdownMenuItem = +type MenuItem = | { type: 'item'; label: string | string[]; @@ -11,16 +11,16 @@ type DropdownMenuItem = leadingIcon?: ReactNode; } | { type: 'separator' } - | { type: 'group'; label: string; items: DropdownMenuItem[] } + | { type: 'group'; label: string; items: MenuItem[] } | { type: 'submenu'; label: string; - items: DropdownMenuItem[]; + items: MenuItem[]; trailingIcon?: ReactNode; leadingIcon?: ReactNode; }; -const dropdownMenuData: DropdownMenuItem[] = [ +const menuData: MenuItem[] = [ { type: 'group', label: 'Actions', @@ -158,15 +158,15 @@ const dropdownMenuData: DropdownMenuItem[] = [ } ]; -function filterDropdownMenuItems( - items: DropdownMenuItem[], +function filterMenuItems( + items: MenuItem[], query: string, path: string[] = [], isInsideSubmenu = false -): DropdownMenuItem[] { +): MenuItem[] { if (!query?.length) return items; const normalizedQuery = query.trim().toLowerCase(); - const results: DropdownMenuItem[] = []; + const results: MenuItem[] = []; for (const item of items) { if (item.type === 'separator') continue; @@ -178,12 +178,12 @@ function filterDropdownMenuItems( results.push({ ...item, label: fullPath - } as DropdownMenuItem); + } as MenuItem); } } if (item.type === 'submenu') { - const nested = filterDropdownMenuItems( + const nested = filterMenuItems( item.items, query, [...path, item.label], @@ -193,12 +193,7 @@ function filterDropdownMenuItems( } if (item.type === 'group') { - const nested = filterDropdownMenuItems( - item.items, - query, - path, - isInsideSubmenu - ); + const nested = filterMenuItems(item.items, query, path, isInsideSubmenu); results.push(...nested); } } @@ -206,11 +201,11 @@ function filterDropdownMenuItems( return results.slice(0, 8); } -export default function LinearDropdownDemo() { +export default function LinearMenuDemo() { const [searchQuery, setSearchQuery] = useState(''); - const renderDropdownMenu = (items: DropdownMenuItem[], query: string) => { - const filteredItems = filterDropdownMenuItems(items, query); + const renderMenu = (items: MenuItem[], query: string) => { + const filteredItems = filterMenuItems(items, query); if (searchQuery && filteredItems.length === 0) { return ( @@ -226,24 +221,24 @@ export default function LinearDropdownDemo() { return ( {item.label} - {item.items && renderDropdownMenu(item.items, query)} + {item.items && renderMenu(item.items, query)} ); case 'separator': return ; case 'submenu': return ( - - + {item.label} - - - {item.items && renderDropdownMenu(item.items, query)} - - + + + {item.items && renderMenu(item.items, query)} + + ); case 'item': return ( @@ -274,11 +269,11 @@ export default function LinearDropdownDemo() { setSearchQuery(value)} + onInputValueChange={(value: string) => setSearchQuery(value)} > }>Actions - {renderDropdownMenu(dropdownMenuData, searchQuery)} + {renderMenu(menuData, searchQuery)} diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index f853044ee..465892b60 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -1,3 +1,4 @@ +export * from './amount-examples'; export * from './announcement-bar-examples'; export * from './avatar-examples'; export * from './badge-examples'; @@ -12,10 +13,9 @@ export * from './command-examples'; export * from './container-examples'; export * from './data-table-examples'; export * from './dialog-examples'; -export * from './dropdown-menu-examples'; export * from './empty-state-examples'; -export * from './flex-examples'; export * from './filter-chip-examples'; +export * from './flex-examples'; export * from './headline-examples'; export * from './icon-button-examples'; export * from './image-examples'; @@ -24,6 +24,7 @@ export * from './input-field-examples'; export * from './label-examples'; export * from './link-examples'; export * from './list-examples'; +export * from './menu-examples'; export * from './popover-examples'; export * from './radio-examples'; export * from './search-examples'; @@ -31,14 +32,13 @@ export * from './select-examples'; export * from './separator-examples'; export * from './sheet-examples'; export * from './sidebar-examples'; +export * from './skeleton-examples'; export * from './slider-examples'; export * from './spinner-examples'; export * from './switch-examples'; export * from './table-examples'; export * from './tabs-examples'; -export * from './text-examples'; export * from './text-area-examples'; +export * from './text-examples'; export * from './toast-examples'; export * from './tooltip-examples'; -export * from './skeleton-examples'; -export * from './amount-examples'; diff --git a/apps/www/src/components/playground/dropdown-menu-examples.tsx b/apps/www/src/components/playground/menu-examples.tsx similarity index 92% rename from apps/www/src/components/playground/dropdown-menu-examples.tsx rename to apps/www/src/components/playground/menu-examples.tsx index 4f14a7087..93569efaa 100644 --- a/apps/www/src/components/playground/dropdown-menu-examples.tsx +++ b/apps/www/src/components/playground/menu-examples.tsx @@ -3,7 +3,7 @@ import { Button, Flex, Menu } from '@raystack/apsara'; import PlaygroundLayout from './playground-layout'; -export function DropdownMenuExamples() { +export function MenuExamples() { return ( @@ -34,14 +34,14 @@ export function DropdownMenuExamples() { }>More - Actions + Actions New File New Folder - Sort By + Sort By Name Date diff --git a/apps/www/src/content/docs/components/dropdown/props.ts b/apps/www/src/content/docs/components/dropdown/props.ts deleted file mode 100644 index 618e02dec..000000000 --- a/apps/www/src/content/docs/components/dropdown/props.ts +++ /dev/null @@ -1,116 +0,0 @@ -export interface MenuRootProps { - /** Enables search functionality within the menu */ - autocomplete?: boolean; - - /** Controls the autocomplete behavior mode - * - "auto": Automatically filters items as user types - * - "manual": Requires explicit filtering through onSearch callback - * @default "auto" - */ - autocompleteMode?: 'auto' | 'manual'; - - /** Current search value for autocomplete */ - searchValue?: string; - - /** Initial search value for autocomplete */ - defaultSearchValue?: string; - - /** Callback fired when the search value changes */ - onSearch?: (value: string) => void; - - /** Whether the menu should loop focus when navigating with keyboard - * @default true - */ - loopFocus?: boolean; - - /** Control the open state of the menu - * @default false - */ - open?: boolean; - - /** Callback fired when the menu is opened or closed */ - onOpenChange?: (open: boolean) => void; - - /** Whether the menu is modal (traps focus and blocks outside interaction) - * @default true - */ - modal?: boolean; -} - -export interface MenuTriggerProps { - /** Render a custom element as the trigger using Base UI's render prop pattern */ - render?: React.ReactElement; - - /** Whether the menu should stop propagation of the click event - * @default true - */ - stopPropagation?: boolean; -} - -export interface MenuContentProps { - /** Placeholder text for the autocomplete search input - * @default "Search..." - */ - searchPlaceholder?: string; - - /** - * The distance between the popup and the anchor element. - * @default 4 - */ - sideOffset?: number; - - /** - * The side of the anchor element to place the popup. - * @default "bottom" - */ - side?: 'top' | 'bottom' | 'left' | 'right'; - - /** - * The alignment of the popup relative to the anchor element. - * @default "start" - */ - align?: 'start' | 'center' | 'end'; -} - -export interface MenuItemProps { - /** Icon element to display before item text */ - leadingIcon?: React.ReactNode; - - /** Icon element to display after item text */ - trailingIcon?: React.ReactNode; - - /** Whether the item is disabled */ - disabled?: boolean; - - /** Value of the item to be used for autocomplete */ - value?: string; - - /** Additional CSS class names */ - className?: string; - - /** Render a custom element using Base UI's render prop pattern */ - render?: React.ReactElement; -} - -export interface MenuGroupProps { - /** Additional CSS class names */ - className?: string; -} - -export interface MenuLabelProps { - /** Additional CSS class names */ - className?: string; -} - -export interface MenuSeparatorProps { - /** Additional CSS class names */ - className?: string; -} - -export interface MenuEmptyStateProps { - /** React nodes to render in empty state */ - children?: React.ReactNode; - - /** Additional CSS class names */ - className?: string; -} diff --git a/apps/www/src/content/docs/components/dropdown/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts similarity index 72% rename from apps/www/src/content/docs/components/dropdown/demo.ts rename to apps/www/src/content/docs/components/menu/demo.ts index 06d7fa810..bee994491 100644 --- a/apps/www/src/content/docs/components/dropdown/demo.ts +++ b/apps/www/src/content/docs/components/menu/demo.ts @@ -3,47 +3,51 @@ import { getPropsString } from '@/lib/utils'; export const getCode = (props: any) => { + const contentProps = props.autocomplete + ? ' searchPlaceholder="Search..."' + : ''; return ` }> Actions - + Assign member... Subscribe... Rename... + Actions - - + + Export - - - + + + All (.zip) - + CSV - - + + All 3 Months 6 Months - - - - + + + + PDF - - + + All 3 Months 6 Months - - - - + + + + Copy { }> Delete... + `; }; @@ -107,14 +112,14 @@ export const customDemo = { More - Actions + Actions New File New Folder - Sort By + Sort By Name Date @@ -122,6 +127,30 @@ export const customDemo = { ` }; +export const submenuDemo = { + type: 'code', + code: ` + + }> + Basic Menu + + + Profile + Settings + + Logout + + Sub Menu + + Sub Menu Item 1 + Sub Menu Item 2 + Sub Menu Item 3 + + + + ` +}; + export const autocompleteDemo = { type: 'code', tabs: [ @@ -140,29 +169,30 @@ export const autocompleteDemo = { Rename... + Actions - - Export - + + Export + All (.zip) - - CSV - + + CSV + All 3 Months 6 Months - - - - PDF - + + + + PDF + All 3 Months 6 Months - - - - + + + + Copy Delete... + + + ` + }, + { + name: 'Searchable Submenu', + code: ` + + }> + Searchable Submenu + + + Copy + Delete... + + Sub Menu + + Sub Menu Item 1 + Sub Menu Item 2 + Sub Menu Item 3 + + ` }, @@ -192,7 +244,7 @@ export const autocompleteDemo = { return setSimpleSearchQuery(value)}> + onInputValueChange={value => setSimpleSearchQuery(value)}> }> Manual Autocomplete @@ -212,15 +264,15 @@ export const autocompleteDemo = { export const linearDemo = { type: 'code', previewCode: false, - code: ``, + code: ``, codePreview: [ { label: 'index.tsx', - code: `function LinearDropdownDemo() { + code: `function LinearMenuDemo() { const [searchQuery, setSearchQuery] = useState(""); - const renderMenu = (items: DropdownMenuItem[], query: string) => { - const filteredItems = filterDropdownMenuItems(items, query); + const renderMenu = (items: MenuItem[], query: string) => { + const filteredItems = filterMenuItems(items, query); if (searchQuery && filteredItems.length === 0) { return
No results
; @@ -239,16 +291,16 @@ export const linearDemo = { return ; case "submenu": return ( - - + {item.label} - - + + {item.items && renderMenu(item.items, query)} - - + + ); case "item": return ( @@ -277,12 +329,12 @@ export const linearDemo = { setSearchQuery(value)}> + onInputValueChange={(value: string) => setSearchQuery(value)}> }> Actions - {renderMenu(dropdownMenuData, searchQuery)} + {renderMenu(menuData, searchQuery)} ); @@ -291,15 +343,15 @@ export const linearDemo = { }, { label: 'utils.ts', - code: `function filterDropdownMenuItems( - items: DropdownMenuItem[], + code: `function filterMenuItems( + items: MenuItem[], query: string, path: string[] = [], isInsideSubmenu = false, -): DropdownMenuItem[] { +): MenuItem[] { if (!query?.length) return items; const normalizedQuery = query.trim().toLowerCase(); - const results: DropdownMenuItem[] = []; + const results: MenuItem[] = []; for (const item of items) { if (item.type === "separator") continue; @@ -311,12 +363,12 @@ export const linearDemo = { results.push({ ...item, label: fullPath, - } as DropdownMenuItem); + } as MenuItem); } } if (item.type === "submenu") { - const nested = filterDropdownMenuItems( + const nested = filterMenuItems( item.items, query, [...path, item.label], @@ -326,7 +378,7 @@ export const linearDemo = { } if (item.type === "group") { - const nested = filterDropdownMenuItems( + const nested = filterMenuItems( item.items, query, path, @@ -341,7 +393,7 @@ export const linearDemo = { }, { label: 'data.ts', - code: `type DropdownMenuItem = + code: `type MenuItem = | { type: "item"; label: string | string[]; @@ -350,16 +402,16 @@ export const linearDemo = { leadingIcon?: ReactNode; } | { type: "separator" } - | { type: "group"; label: string; items: DropdownMenuItem[] } + | { type: "group"; label: string; items: MenuItem[] } | { type: "submenu"; label: string; - items: DropdownMenuItem[]; + items: MenuItem[]; trailingIcon?: ReactNode; leadingIcon?: ReactNode; }; -const dropdownMenuData: DropdownMenuItem[] = [ +const menuData: MenuItem[] = [ { type: "group", label: "Heading", diff --git a/apps/www/src/content/docs/components/dropdown/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx similarity index 61% rename from apps/www/src/content/docs/components/dropdown/index.mdx rename to apps/www/src/content/docs/components/menu/index.mdx index 7ae7d55dc..9516faaa4 100644 --- a/apps/www/src/content/docs/components/dropdown/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -9,6 +9,7 @@ import { iconsDemo, customDemo, basicDemo, + submenuDemo, autocompleteDemo, linearDemo, } from "./demo.ts"; @@ -25,13 +26,13 @@ import { Menu } from '@raystack/apsara' The Menu component is composed of several parts, each with their own props. -The root element is the parent component that holds the menu. Using the `autocomplete` prop, you can enable autocomplete functionality. Built on top of [Base UI Menu](https://base-ui.com/react/components/menu) +The root element is the parent component that holds the menu. Using the `autocomplete` prop, you can enable search functionality. ### Menu.Trigger Props -The button that triggers the menu. Built on top of [Base UI Menu.Trigger](https://base-ui.com/react/components/menu) +The button that triggers the menu. By default, the click event is not propagated. You can override this behavior by passing `stopPropagation={false}`. @@ -41,7 +42,7 @@ Use the `render` prop to render a custom trigger element. ### Menu.Content Props -The container that holds the menu items. Built on top of [Base UI Menu.Popup](https://base-ui.com/react/components/menu) +The container that holds the menu items. When autocomplete is enabled, renders an inline search input inside the popup with a search input. @@ -49,25 +50,25 @@ The container that holds the menu items. Built on top of [Base UI Menu.Popup](ht Individual clickable options within the menu. Built on top of [Base UI Menu.Item](https://base-ui.com/react/components/menu). -Renders as a [Base UI Autocomplete.Item](https://base-ui.com/react/components/autocomplete) when used in an autocomplete menu. By default, the item's `children` is used for matching and selection, which can be overriden by passing a `value` prop. +Renders as a `role='option'`when used in an autocomplete menu. By default, the item's `children` text content is used for matching, which can be overridden by passing a `value` prop. ### Menu.Group Props -A way to group related menu items together. Built on top of [Base UI Menu.Group](https://base-ui.com/react/components/menu) +A way to group related menu items together. When filtering is active, the group wrapper is removed and items are rendered flat. ### Menu.Label Props -Renders a label in a menu group. This component should be wrapped with Menu.Group so the `aria-labelledby` is correctly set on the group element. Built on top of [Base UI Menu.GroupLabel](https://base-ui.com/react/components/menu) +Renders a label in a menu group. This component should be wrapped with Menu.Group so the `aria-labelledby` is correctly set on the group element. Hidden when filtering is active. ### Menu.Separator Props -Visual divider between menu items or groups. +Visual divider between menu items or groups. Hidden when filtering is active. @@ -77,17 +78,27 @@ Placeholder content when there are no menu items to display. -### Menu.SubMenu +### Menu.SubMenu Props -Wraps a submenu root. Use with `Menu.SubTrigger` and `Menu.SubContent` to create nested menus. Built on top of [Base UI Menu.SubmenuRoot](https://base-ui.com/react/components/menu) +Wraps a submenu root. Use with `Menu.SubTrigger` and `Menu.SubContent` to create nested menus. + +Supports its own `autocomplete` prop to enable search functionality within the submenu independently from the parent menu. + + ### Menu.SubTrigger Props The trigger item for a submenu. Renders with a trailing chevron icon by default. Accepts `leadingIcon` and `trailingIcon` props. +When inside a searchable parent menu, the `value` prop can be used for autocomplete matching. + + + ### Menu.SubContent Props -The content container for a submenu. Same structure as `Menu.Content` but without autocomplete support. +The content container for a submenu. Shares the same API as `Menu.Content` with a default `sideOffset` of `2`. + + ## Examples @@ -109,11 +120,21 @@ Organize related menu items into sections with descriptive headers. +### Submenu + +Use `Menu.SubMenu`, `Menu.SubTrigger`, and `Menu.SubContent` to create nested menus with multiple levels. + + + ### Autocomplete To enable autocomplete, pass the `autocomplete` prop to the Menu root element. Each menu instance will manage its own autocomplete behavior. -By default, only the top-level menu items are filtered. For more advanced control, set `autocompleteMode="manual"` and implement your own custom filtering logic. +By default (`autocompleteMode="auto"`), items are automatically filtered as the user types. The filter matches against the item's `value` prop or its `children` text content. + +For submenus, you can independently enable autocomplete by passing `autocomplete` to `Menu.SubMenu`. + +For more advanced control, set `autocompleteMode="manual"` and implement your own custom filtering logic using the `onInputValueChange` callback. diff --git a/apps/www/src/content/docs/components/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts new file mode 100644 index 000000000..f8a9efb41 --- /dev/null +++ b/apps/www/src/content/docs/components/menu/props.ts @@ -0,0 +1,209 @@ +export interface MenuRootProps { + /** Enables search functionality within the menu */ + autocomplete?: boolean; + + /** Controls the autocomplete behavior mode + * - "auto": Automatically filters items as user types + * - "manual": Requires explicit filtering through onInputValueChange callback + * @default "auto" + */ + autocompleteMode?: 'auto' | 'manual'; + + /** Current search input value (controlled) */ + inputValue?: string; + + /** Initial search input value (uncontrolled) + * @default "" + */ + defaultInputValue?: string; + + /** Callback fired when the search input value changes */ + onInputValueChange?: (value: string) => void; + + /** Control the open state of the menu */ + open?: boolean; + + /** Whether the menu is open by default (uncontrolled) + * @default false + */ + defaultOpen?: boolean; + + /** Callback fired when the menu is opened or closed */ + onOpenChange?: (open: boolean) => void; + + /** Whether the menu is modal (traps focus and blocks outside interaction) + * @default true + */ + modal?: boolean; + + /** Whether the menu should loop focus when navigating with keyboard + * @default true + */ + loopFocus?: boolean; +} + +export interface MenuTriggerProps { + /** Render a custom element as the trigger using Base UI's render prop pattern */ + render?: React.ReactElement; + + /** Whether the menu should stop propagation of the click event + * @default true + */ + stopPropagation?: boolean; +} + +export interface MenuContentProps { + /** Placeholder text for the autocomplete search input + * @default "Search..." + */ + searchPlaceholder?: string; + + /** + * The distance between the popup and the anchor element. + * @default 4 + */ + sideOffset?: number; + + /** + * The side of the anchor element to place the popup. + * @default "bottom" + */ + side?: 'top' | 'bottom' | 'left' | 'right'; + + /** + * The alignment of the popup relative to the anchor element. + * @default "start" + */ + align?: 'start' | 'center' | 'end'; + + /** Render a custom element using Base UI's render prop pattern */ + render?: React.ReactElement; + + /** Inline styles */ + style?: React.CSSProperties; + + /** Additional CSS class names */ + className?: string; +} + +export interface MenuItemProps { + /** Icon element to display before item text */ + leadingIcon?: React.ReactNode; + + /** Icon element to display after item text */ + trailingIcon?: React.ReactNode; + + /** Whether the item is disabled */ + disabled?: boolean; + + /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + value?: string; + + /** Additional CSS class names */ + className?: string; + + /** Render a custom element using Base UI's render prop pattern */ + render?: React.ReactElement; +} + +export interface MenuGroupProps { + /** Additional CSS class names */ + className?: string; +} + +export interface MenuLabelProps { + /** Additional CSS class names */ + className?: string; +} + +export interface MenuSeparatorProps { + /** Additional CSS class names */ + className?: string; +} + +export interface MenuEmptyStateProps { + /** React nodes to render in empty state */ + children?: React.ReactNode; + + /** Additional CSS class names */ + className?: string; +} + +export interface MenuSubMenuProps { + /** Enables search functionality within the submenu */ + autocomplete?: boolean; + + /** Controls the autocomplete behavior mode for the submenu + * - "auto": Automatically filters items as user types + * - "manual": Requires explicit filtering through onInputValueChange callback + * @default "auto" + */ + autocompleteMode?: 'auto' | 'manual'; + + /** Current search input value (controlled) */ + inputValue?: string; + + /** Initial search input value (uncontrolled) + * @default "" + */ + defaultInputValue?: string; + + /** Callback fired when the search input value changes */ + onInputValueChange?: (value: string) => void; + + /** Control the open state of the submenu */ + open?: boolean; + + /** Whether the submenu is open by default (uncontrolled) + * @default false + */ + defaultOpen?: boolean; + + /** Callback fired when the submenu is opened or closed */ + onOpenChange?: (open: boolean) => void; +} + +export interface MenuSubTriggerProps { + /** Icon element to display before trigger text */ + leadingIcon?: React.ReactNode; + + /** Icon element to display after trigger text. Defaults to a chevron right icon. */ + trailingIcon?: React.ReactNode; + + /** Value used for autocomplete matching when inside a searchable parent menu */ + value?: string; +} + +export interface MenuSubContentProps { + /** Placeholder text for the autocomplete search input + * @default "Search..." + */ + searchPlaceholder?: string; + + /** + * The distance between the popup and the anchor element. + * @default 2 + */ + sideOffset?: number; + + /** + * The side of the anchor element to place the popup. + * @default "bottom" + */ + side?: 'top' | 'bottom' | 'left' | 'right'; + + /** + * The alignment of the popup relative to the anchor element. + * @default "start" + */ + align?: 'start' | 'center' | 'end'; + + /** Render a custom element using Base UI's render prop pattern */ + render?: React.ReactElement; + + /** Inline styles */ + style?: React.CSSProperties; + + /** Additional CSS class names */ + className?: string; +} diff --git a/packages/raystack/components/dropdown-menu/__tests__/dropdown-menu.test.tsx b/packages/raystack/components/dropdown-menu/__tests__/dropdown-menu.test.tsx deleted file mode 100644 index 111a7b645..000000000 --- a/packages/raystack/components/dropdown-menu/__tests__/dropdown-menu.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import { Button } from '../../button/button'; -import { DropdownMenu } from '../dropdown-menu'; -import { DropdownMenuRootProps } from '../dropdown-menu-root'; - -// Mock scrollIntoView for test environment -Object.defineProperty(Element.prototype, 'scrollIntoView', { - value: vi.fn(), - writable: true -}); - -// String constants -const TRIGGER_TEXT = 'Open Menu'; -const MENU_ITEMS = [ - { id: 'profile', label: 'Profile' }, - { id: 'settings', label: 'Settings' }, - { id: 'billing', label: 'Billing' }, - { id: 'team', label: 'Team' }, - { id: 'logout', label: 'Logout' } -]; - -const BasicDropdown = ({ - onClick, - children, - ...props -}: DropdownMenuRootProps & { onClick?: (value: string) => void }) => { - return ( - - - - - - {MENU_ITEMS.map(item => ( - onClick?.(item.id)}> - {item.label} - - ))} - {children} - - - ); -}; - -const renderAndOpenDropdown = async (Dropdown: React.ReactElement) => { - await fireEvent.click(render(Dropdown).getByText(TRIGGER_TEXT)); -}; - -describe('DropdownMenu', () => { - describe('Basic Rendering', () => { - it('renders dropdown trigger', () => { - render(); - expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument(); - }); - - it('renders with custom className on trigger', () => { - render( - - - Custom Trigger - - - Menu Item - - - ); - - const trigger = screen.getByText('Custom Trigger'); - expect(trigger).toHaveClass('custom-trigger'); - }); - - it('does not show content initially', () => { - render(); - MENU_ITEMS.forEach(item => { - expect(screen.queryByText(item.label)).not.toBeInTheDocument(); - }); - }); - - it('shows content when opened', async () => { - await renderAndOpenDropdown(); - - expect(screen.getByRole('menu')).toBeInTheDocument(); - MENU_ITEMS.forEach(item => { - expect(screen.getByText(item.label)).toBeInTheDocument(); - }); - }); - - it('renders in portal', async () => { - await renderAndOpenDropdown(); - - const content = screen.getByRole('menu'); - expect(content.closest('body')).toBe(document.body); - }); - }); - - describe('Trigger Interaction', () => { - it('opens menu when trigger is clicked', async () => { - await renderAndOpenDropdown(); - - expect(screen.getByRole('menu')).toBeInTheDocument(); - expect(screen.getByText(MENU_ITEMS[0].label)).toBeInTheDocument(); - }); - }); - - describe('Menu Items', () => { - it('handles item clicks with onClick', async () => { - const onClick = vi.fn(); - - await renderAndOpenDropdown(); - - const item = screen.getByText(MENU_ITEMS[0].label); - fireEvent.click(item); - - expect(onClick).toHaveBeenCalled(); - }); - - it('closes menu after item selection by default', async () => { - await renderAndOpenDropdown(); - - const item = screen.getByText(MENU_ITEMS[0].label); - fireEvent.click(item); - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - }); - }); - - it('supports disabled items', async () => { - const onClick = vi.fn(); - - await renderAndOpenDropdown( - - - Disabled Item - - - ); - - const disabledItem = screen.getByTestId('disabled-item'); - expect(disabledItem).toHaveAttribute('aria-disabled', 'true'); - - fireEvent.click(disabledItem); - expect(onClick).not.toHaveBeenCalled(); - }); - }); - - describe('Controlled State', () => { - it('calls onOpenChange when state changes', async () => { - const onOpenChange = vi.fn(); - - await render(); - - const trigger = screen.getByText(TRIGGER_TEXT); - fireEvent.click(trigger); - - expect(onOpenChange).toHaveBeenCalledWith(true); - }); - }); - - describe('Autocomplete Mode', () => { - it('renders search input in autocomplete mode', async () => { - await renderAndOpenDropdown(); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); - const searchInput = screen.getByRole('combobox'); - expect(searchInput).toBeInTheDocument(); - expect(searchInput).toHaveAttribute('placeholder', 'Search...'); - }); - - it('filters items based on search', async () => { - const user = userEvent.setup(); - - await renderAndOpenDropdown(); - - const searchInput = screen.getByPlaceholderText('Search...'); - await user.type(searchInput, 'pro'); - - const menuItems = await screen.findAllByRole('option'); - expect(menuItems.length).toBe(1); - expect(menuItems[0].textContent).toBe('Profile'); - }); - }); - - describe('Keyboard Navigation', () => { - it('opens with Enter key', async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByText(TRIGGER_TEXT); - trigger.focus(); - await user.keyboard('{Enter}'); - - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - it('opens with Space key', async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByText(TRIGGER_TEXT); - trigger.focus(); - await user.keyboard('[Space]'); - - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - it('closes with Escape key', async () => { - const user = userEvent.setup(); - await renderAndOpenDropdown(); - - await user.keyboard('{ArrowDown}{Escape}'); - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - }); - }); - - it('navigates items with arrow keys', async () => { - const user = userEvent.setup(); - await renderAndOpenDropdown(); - - const items = await screen.findAllByRole('menuitem'); - await user.hover(items[0]); - - await user.keyboard('{ArrowDown}'); - expect(items[1]).toHaveAttribute('data-active-item', 'true'); - }); - - it('selects item with Enter key', async () => { - const user = userEvent.setup(); - const onClick = vi.fn(); - - await renderAndOpenDropdown(); - const items = await screen.findAllByRole('menuitem'); - await user.hover(items[0]); - - await user.keyboard('{ArrowDown}{Enter}'); - - expect(onClick).toHaveBeenCalledWith(MENU_ITEMS[1].id); - expect(onClick).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/raystack/components/dropdown-menu/cell.module.css b/packages/raystack/components/dropdown-menu/cell.module.css deleted file mode 100644 index 1f53c0b30..000000000 --- a/packages/raystack/components/dropdown-menu/cell.module.css +++ /dev/null @@ -1,40 +0,0 @@ -.cell { - position: relative; - padding: var(--rs-space-3); - display: flex; - align-items: center; - gap: var(--rs-space-3); - font-weight: var(--rs-font-weight-regular); - font-size: var(--rs-font-size-small); - line-height: var(--rs-line-height-small); - letter-spacing: var(--rs-letter-spacing-small); -} - -.cell[data-active-item] { - outline: none; - cursor: pointer; - font-weight: var(--rs-font-weight-regular); - font-size: var(--rs-font-size-small); - line-height: var(--rs-line-height-small); - letter-spacing: var(--rs-letter-spacing-small); - border-radius: var(--rs-radius-2); - background: var(--rs-color-background-base-primary-hover); -} - -.cell[aria-disabled] { - opacity: 0.6; - pointer-events: none; -} - -.leadingIcon { - display: flex; - align-items: center; - color: var(--rs-color-foreground-base-secondary); -} - -.trailingIcon { - display: flex; - align-items: center; - color: var(--rs-color-foreground-base-secondary); - margin-left: auto; -} diff --git a/packages/raystack/components/dropdown-menu/cell.tsx b/packages/raystack/components/dropdown-menu/cell.tsx deleted file mode 100644 index b6fc3a7cc..000000000 --- a/packages/raystack/components/dropdown-menu/cell.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { forwardRef, HTMLAttributes, ReactNode } from "react"; -import { cx } from "class-variance-authority"; -import styles from "./cell.module.css"; -import { Checkbox } from "../checkbox"; - -export type CellBaseProps = { - leadingIcon?: ReactNode; - trailingIcon?: ReactNode; -}; -export type CellProps = HTMLAttributes & - CellBaseProps & { - type?: "select" | "item"; - }; - -export const Cell = forwardRef( - ( - { className, children, leadingIcon, trailingIcon, type = "item", ...props }, - ref, - ) => ( -
- {type == "select" && } - {leadingIcon && {leadingIcon}} - {children} - {trailingIcon && ( - {trailingIcon} - )} -
- ), -); diff --git a/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx b/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx deleted file mode 100644 index 87cc72f09..000000000 --- a/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client'; - -import { - Combobox, - ComboboxList, - Menu, - MenuProps, - useMenuContext -} from '@ariakit/react'; -import { cx } from 'class-variance-authority'; -import { Slot, VisuallyHidden } from 'radix-ui'; -import { ElementRef, forwardRef, useEffect, useRef, useState } from 'react'; -import styles from './dropdown-menu.module.css'; -import { useDropdownContext } from './dropdown-menu-root'; -import { WithAsChild } from './types'; - -export interface MenuContentProps extends WithAsChild { - searchPlaceholder?: string; -} - -export const DropdownMenuContent = forwardRef< - ElementRef, - MenuContentProps ->( - ( - { className, children, asChild, searchPlaceholder = 'Search...', ...props }, - ref - ) => { - const menu = useMenuContext(); - const { autocomplete } = useDropdownContext(); - const isSubMenu = !!menu?.parent; - const comboboxRef = useRef(null); - const visuallyHiddenRef = useRef(null); - const [isInsideRadixDialog, setIsInsideRadixDialog] = useState(false); - - /* - * This is a workaround to fix focus lock issue when the dropdown menu is inside a Radix Dialog. - * TODO: Use Radix.Popover for the dropdown popover - */ - useEffect(() => { - setIsInsideRadixDialog( - !!visuallyHiddenRef.current?.closest("[role='dialog']") - ); - }, []); - - return ( - <> -