diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 07a636ddb..be8191c57 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, @@ -1540,35 +1540,31 @@ const Page = () => { > Open Sheet - - - - - - - Team Actions - - - Add Member - - Edit Team - - - Settings - Permissions - - Notifications - - - - - Delete Team - - - + + }> + Open Menu + + + + Team Actions + + Add Member + + Edit Team + + + + Settings + Permissions + Notifications + + + Delete Team + + @@ -1678,31 +1674,31 @@ const Page = () => { - - - - - - Team Actions - - Add Member - - Edit Team - - - Settings - Permissions - Notifications - - - - Delete Team - - - + + }> + Open Menu + + + + Team Actions + + Add Member + + Edit Team + + + + Settings + Permissions + Notifications + + + Delete Team + + @@ -1816,35 +1812,31 @@ const Page = () => { - - - - - - - Team Actions - - - Add Member - - Edit Team - - - Settings - Permissions - - Notifications - - - - - Delete Team - - - + + }> + Open Menu + + + + Team Actions + + Add Member + + 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/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 f8fbeffbc..31eeb2ba6 100644 --- a/apps/www/src/components/linear-dropdown-demo.tsx +++ b/apps/www/src/components/linear-dropdown-demo.tsx @@ -1,8 +1,8 @@ -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'; -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 ( @@ -224,30 +219,30 @@ export default function LinearDropdownDemo() { 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; @@ -271,18 +266,16 @@ export default function LinearDropdownDemo() { return ( - setSearchQuery(value)} + onInputValueChange={(value: string) => setSearchQuery(value)} > - - - - - {renderDropdownMenu(dropdownMenuData, searchQuery)} - - + }>Actions + + {renderMenu(menuData, searchQuery)} + + ); } diff --git a/apps/www/src/components/playground/dropdown-menu-examples.tsx b/apps/www/src/components/playground/dropdown-menu-examples.tsx deleted file mode 100644 index b32e25e0f..000000000 --- a/apps/www/src/components/playground/dropdown-menu-examples.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import { Button, DropdownMenu, Flex } from '@raystack/apsara'; -import PlaygroundLayout from './playground-layout'; - -export function DropdownMenuExamples() { - return ( - - - - - - - - Profile - Settings - - Logout - - - - - - - - 📝}>Edit - 📋} trailingIcon={<>⌘C}> - Copy - - - 🗑️}>Delete - - - - - - - - Actions - - New File - New Folder - - - Sort By - - Name - Date - - - - - - ); -} diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index 3f657dd3d..a51d8388c 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -14,7 +14,6 @@ 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 './filter-chip-examples'; export * from './flex-examples'; @@ -26,6 +25,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'; diff --git a/apps/www/src/components/playground/menu-examples.tsx b/apps/www/src/components/playground/menu-examples.tsx new file mode 100644 index 000000000..93569efaa --- /dev/null +++ b/apps/www/src/components/playground/menu-examples.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Button, Flex, Menu } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function MenuExamples() { + return ( + + + + }> + Open Menu + + + Profile + Settings + + Logout + + + + }> + Actions + + + 📝}>Edit + 📋} trailingIcon={<>⌘C}> + Copy + + + 🗑️}>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 deleted file mode 100644 index 1060fdfd6..000000000 --- a/apps/www/src/content/docs/components/dropdown/demo.ts +++ /dev/null @@ -1,447 +0,0 @@ -'use client'; - -import { getPropsString } from '@/lib/utils'; - -export const getCode = (props: any) => { - return ` - - - - - - - Assign member... - Subscribe... - Rename... - - - Actions - - - Export - - - - All (.zip) - - CSV - - - All - 3 Months - 6 Months - - - - - PDF - - - All - 3 Months - 6 Months - - - - - Copy - - ⌘⇧D - - }> - Delete... - - - `; -}; - -export const playground = { - type: 'playground', - controls: { - autocomplete: { - type: 'checkbox', - defaultValue: false - } - }, - getCode -}; - -export const basicDemo = { - type: 'code', - code: ` - - - - - - Profile - Settings - - Logout - - ` -}; -export const iconsDemo = { - type: 'code', - code: ` - - - - - - 📝}>Edit - 📋} trailingIcon={<>⌘C}>Copy - - 🗑️}>Delete - - ` -}; - -export const customDemo = { - type: 'code', - code: ` - - - - - - Actions - - New File - New Folder - - - Sort By - - Name - Date - - - ` -}; - -export const autocompleteDemo = { - type: 'code', - tabs: [ - { - 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 - - ⌘⇧D - - }> - Delete... - - - ` - }, - { - name: 'Manual Autocomplete', - code: ` - function ManualDemo(){ - const items = [ - "Assign member...", - "Subscribe...", - "Rename...", - "Copy", - "Delete...", - ]; - - const [simpleSearchQuery, setSimpleSearchQuery] = React.useState(""); - return setSimpleSearchQuery(value)}> - - - - - {items - .filter(item => item.toLowerCase().includes(simpleSearchQuery)) - .map((item, index) => ( - {item} - ))} - - - }` - } - ] -}; - -export const linearDemo = { - type: 'code', - previewCode: false, - code: ``, - codePreview: [ - { - label: 'index.tsx', - code: `function LinearDropdownDemo() { - const [searchQuery, setSearchQuery] = useState(""); - - const renderDropdownMenu = (items: DropdownMenuItem[], query: string) => { - const filteredItems = filterDropdownMenuItems(items, query); - - if (searchQuery && filteredItems.length === 0) { - return
No results
; - } - - return filteredItems.map((item, index) => { - switch (item.type) { - case "group": - return ( - - {item.label} - {item.items && renderDropdownMenu(item.items, query)} - - ); - case "separator": - return ; - case "submenu": - return ( - - - {item.label} - - - {item.items && renderDropdownMenu(item.items, query)} - - - ); - case "item": - return ( - - {Array.isArray(item.label) - ? item.label.map((part, i) => ( - - {i > 0 && } - {part} - - )) - : item.label} - - ); - default: - return null; - } - }); - }; - - return ( - setSearchQuery(value)}> - - - - - {renderDropdownMenu(dropdownMenuData, searchQuery)} - - - ); -} -` - }, - { - label: 'utils.ts', - code: `function filterDropdownMenuItems( - items: DropdownMenuItem[], - query: string, - path: string[] = [], - isInsideSubmenu = false, -): DropdownMenuItem[] { - if (!query?.length) return items; - const normalizedQuery = query.trim().toLowerCase(); - const results: DropdownMenuItem[] = []; - - for (const item of items) { - if (item.type === "separator") continue; - - if (item.type === "item") { - const fullPath = isInsideSubmenu ? [...path, item.label] : [item.label]; - const flatLabel = fullPath.join(" ").toLowerCase(); - if (flatLabel.includes(normalizedQuery)) { - results.push({ - ...item, - label: fullPath, - } as DropdownMenuItem); - } - } - - if (item.type === "submenu") { - const nested = filterDropdownMenuItems( - item.items, - query, - [...path, item.label], - true, - ); - results.push(...nested); - } - - if (item.type === "group") { - const nested = filterDropdownMenuItems( - item.items, - query, - path, - isInsideSubmenu, - ); - results.push(...nested); - } - } - - return results.slice(0, 8); -}` - }, - { - label: 'data.ts', - code: `type DropdownMenuItem = - | { - type: "item"; - label: string | string[]; - disabled?: boolean; - trailingIcon?: ReactNode; - leadingIcon?: ReactNode; - } - | { type: "separator" } - | { type: "group"; label: string; items: DropdownMenuItem[] } - | { - type: "submenu"; - label: string; - items: DropdownMenuItem[]; - trailingIcon?: ReactNode; - leadingIcon?: ReactNode; - }; - -const dropdownMenuData: DropdownMenuItem[] = [ - { - type: "group", - label: "Heading", - items: [ - { type: "item", label: "Assign member..." }, - { type: "item", label: "Subscribe..." }, - { type: "item", label: "Rename..." }, - ], - }, - { type: "separator" }, - { - type: "group", - label: "Actions", - items: [ - { - type: "submenu", - label: "Export", - items: [ - { - type: "item", - label: "All (.zip)", - leadingIcon: , - }, - { - type: "submenu", - label: "CSV", - leadingIcon: , - items: [ - { - type: "item", - label: "All", - leadingIcon: , - }, - { - type: "item", - label: "3 Months", - leadingIcon: , - }, - { - type: "item", - label: "6 Months", - leadingIcon: , - }, - ], - }, - { - type: "submenu", - label: "PDF", - leadingIcon: , - items: [ - { - type: "item", - label: "All", - leadingIcon: , - }, - { - type: "item", - label: "3 Months", - leadingIcon: , - }, - { - type: "item", - label: "6 Months", - leadingIcon: , - }, - ], - }, - ], - }, - { type: "item", label: "Copy", disabled: true }, - { - type: "item", - label: "Delete...", - trailingIcon: ( - - ⌘⇧D - - ), - }, - ], - }, -];` - } - ] -}; diff --git a/apps/www/src/content/docs/components/dropdown/index.mdx b/apps/www/src/content/docs/components/dropdown/index.mdx deleted file mode 100644 index aa522a35c..000000000 --- a/apps/www/src/content/docs/components/dropdown/index.mdx +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: Dropdown 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 ---- - -import { - playground, - iconsDemo, - customDemo, - basicDemo, - autocompleteDemo, - linearDemo, -} from "./demo.ts"; - - - -## Anatomy - -Import and assemble the component: - -```tsx -import { DropdownMenu } from '@raystack/apsara' - - - - - - - - - - - -``` - -## API Reference - -### Root - -The DropdownMenu 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) - - - -### Trigger - -The button that triggers the dropdown menu. Built on top of [Ariakit MenuButton](https://ariakit.org/reference/menu-button) - -By default, the click event is not propagated. You can override this behavior by passing `stopPropagation={false}`. - - - -### TriggerItem - -`TriggerItem` is a helper component that renders a `DropdownMenu.Trigger` as a `DropdownMenu.MenuItem`. - -Accepts all `DropdownMenu.Item` props. The component is helpful to match styles for sub menu trigger. Use DropdownMenu.Trigger if you want more control. - -### Content - -The container that holds the dropdown menu items. Built on top of [Ariakit Menu](https://ariakit.org/reference/menu) - - - -### Item - -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. - - - -### Group - -A way to group related menu items together. Built on top of [Ariakit MenuGroup](https://ariakit.org/reference/menu-group) - - - -### Label - -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) - - - -### Separator - -Visual divider between menu items or groups. Built on top of [Ariakit MenuSeparator](https://ariakit.org/reference/menu-separator) - - - -### EmptyState - -Placeholder content when there are no menu items to display. - - - -## Examples - -### Basic Usage - -A simple dropdown menu with basic functionality. - - - -### With Icons - -You can add icons to the dropdown items. Supports both leading and trailing icons. - - - -### With Groups and Labels - -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. - -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 - -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. - -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. - - - -## Accessibility - -- Follows the [WAI-ARIA Menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) -- Trigger uses `aria-haspopup` and `aria-expanded` attributes -- Supports keyboard navigation with arrow keys, Enter, and Escape -- Menu items are focusable and support type-ahead selection 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 e9135e35c..000000000 --- a/apps/www/src/content/docs/components/dropdown/props.ts +++ /dev/null @@ -1,134 +0,0 @@ -export interface DropdownMenuRootProps { - /** Enables search functionality within the dropdown 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; - - /** 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 - * @default true - */ - focusLoop?: boolean; - - /** Control the open state of the dropdown - * @default false - */ - open?: boolean; - - /** Callback fired when the dropdown is opened or closed */ - onOpenChange?: (open: boolean) => void; -} - -export interface DropdownMenuTriggerProps { - /** Boolean to merge props onto child element */ - asChild?: boolean; - - /** Whether the dropdown should stop propagation of the click event - * @default true - */ - stopPropagation?: boolean; -} - -export interface DropdownMenuContentProps { - /** Placeholder text for the autocomplete search input - * @default "Search..." - */ - searchPlaceholder?: string; - - /** - * The distance between the popover and the anchor element. - * @default 4 - */ - gutter?: 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 - */ - shift?: number; - - /** Boolean to merge props onto child element */ - asChild?: boolean; -} - -export interface DropdownMenuItemProps { - /** 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; - - /** Boolean to merge props onto child element */ - asChild?: boolean; -} - -export interface DropdownMenuGroupProps { - /** Additional CSS class names */ - className?: string; - - /** Boolean to merge props onto child element */ - asChild?: boolean; -} - -export interface DropdownMenuLabelProps { - /** Additional CSS class names */ - className?: string; - - /** Boolean to merge props onto child element */ - asChild?: boolean; -} - -export interface DropdownMenuSeparatorProps { - /** Additional CSS class names */ - className?: string; - - /** Boolean to merge props onto child element */ - asChild?: boolean; -} - -export interface DropdownMenuEmptyStateProps { - /** 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/menu/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts new file mode 100644 index 000000000..dea12e217 --- /dev/null +++ b/apps/www/src/content/docs/components/menu/demo.ts @@ -0,0 +1,498 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: any) => { + const contentProps = props.autocomplete + ? ' searchPlaceholder="Search..."' + : ''; + return ` + + }> + Actions + + + + Assign member... + Subscribe... + Rename... + + + + Actions + + + Export + + + + + CSV + + + All + 3 Months + 6 Months + + + + + PDF + + + All + 3 Months + 6 Months + + + + + Copy + + ⌘⇧D + + }> + Delete... + + + + `; +}; + +export const playground = { + type: 'playground', + controls: { + autocomplete: { + type: 'checkbox', + defaultValue: false + } + }, + getCode +}; + +export const basicDemo = { + type: 'code', + code: ` + + }> + Open Menu + + + Profile + Settings + + Logout + + ` +}; +export const iconsDemo = { + type: 'code', + code: ` + + }> + Actions + + + 📝}>Edit + 📋} trailingIcon={<>⌘C}>Copy + + 🗑️}>Delete + + ` +}; + +export const customDemo = { + type: 'code', + code: ` + + }> + More + + + + Actions + New File + New Folder + + + + Sort By + Name + Date + + + ` +}; + +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: [ + { + name: 'Default Autocomplete', + code: ` + + }> + Default Autocomplete + + + + Heading + Assign member... + Subscribe... + Rename... + + + + Actions + + Export + + All (.zip) + + CSV + + All + 3 Months + 6 Months + + + + PDF + + All + 3 Months + 6 Months + + + + + Custom Label + + ⌘⇧D + + }> + Delete... + + + + ` + }, + { + name: 'Searchable Submenu', + code: ` + + }> + Searchable Submenu + + + Copy + Delete... + + Sub Menu + + Sub Menu Item 1 + Sub Menu Item 2 + Sub Menu Item 3 + + + + ` + }, + { + name: 'Manual Autocomplete', + code: ` + function ManualDemo(){ + const items = [ + "Assign member...", + "Subscribe...", + "Rename...", + "Copy", + "Delete...", + ]; + + const [simpleSearchQuery, setSimpleSearchQuery] = React.useState(""); + return setSimpleSearchQuery(value)}> + }> + Manual Autocomplete + + + {items + .filter(item => item.toLowerCase().includes(simpleSearchQuery.toLowerCase())) + .map((item, index) => ( + {item} + ))} + + + }` + } + ] +}; + +export const linearDemo = { + type: 'code', + previewCode: false, + code: ``, + codePreview: [ + { + label: 'index.tsx', + code: `function LinearMenuDemo() { + const [searchQuery, setSearchQuery] = useState(""); + + const renderMenu = (items: MenuItem[], query: string) => { + const filteredItems = filterMenuItems(items, query); + + if (searchQuery && filteredItems.length === 0) { + return
No results
; + } + + return filteredItems.map((item, index) => { + switch (item.type) { + case "group": + return ( + + {item.label} + {item.items && renderMenu(item.items, query)} + + ); + case "separator": + return ; + case "submenu": + return ( + + + {item.label} + + + {item.items && renderMenu(item.items, query)} + + + ); + case "item": + return ( + + {Array.isArray(item.label) + ? item.label.map((part, i) => ( + + {i > 0 && } + {part} + + )) + : item.label} + + ); + default: + return null; + } + }); + }; + + return ( + setSearchQuery(value)}> + }> + Actions + + + {renderMenu(menuData, searchQuery)} + + + ); +} +` + }, + { + label: 'utils.ts', + code: `function filterMenuItems( + items: MenuItem[], + query: string, + path: string[] = [], + isInsideSubmenu = false, +): MenuItem[] { + if (!query?.length) return items; + const normalizedQuery = query.trim().toLowerCase(); + const results: MenuItem[] = []; + + for (const item of items) { + if (item.type === "separator") continue; + + if (item.type === "item") { + const fullPath = isInsideSubmenu ? [...path, item.label] : [item.label]; + const flatLabel = fullPath.join(" ").toLowerCase(); + if (flatLabel.includes(normalizedQuery)) { + results.push({ + ...item, + label: fullPath, + } as MenuItem); + } + } + + if (item.type === "submenu") { + const nested = filterMenuItems( + item.items, + query, + [...path, item.label], + true, + ); + results.push(...nested); + } + + if (item.type === "group") { + const nested = filterMenuItems( + item.items, + query, + path, + isInsideSubmenu, + ); + results.push(...nested); + } + } + + return results.slice(0, 8); +}` + }, + { + label: 'data.tsx', + code: `type MenuItem = + | { + type: "item"; + label: string | string[]; + disabled?: boolean; + trailingIcon?: ReactNode; + leadingIcon?: ReactNode; + } + | { type: "separator" } + | { type: "group"; label: string; items: MenuItem[] } + | { + type: "submenu"; + label: string; + items: MenuItem[]; + trailingIcon?: ReactNode; + leadingIcon?: ReactNode; + }; + +const menuData: MenuItem[] = [ + { + type: "group", + label: "Heading", + items: [ + { type: "item", label: "Assign member..." }, + { type: "item", label: "Subscribe..." }, + { type: "item", label: "Rename..." }, + ], + }, + { type: "separator" }, + { + type: "group", + label: "Actions", + items: [ + { + type: "submenu", + label: "Export", + items: [ + { + type: "item", + label: "All (.zip)", + leadingIcon: , + }, + { + type: "submenu", + label: "CSV", + leadingIcon: , + items: [ + { + type: "item", + label: "All", + leadingIcon: , + }, + { + type: "item", + label: "3 Months", + leadingIcon: , + }, + { + type: "item", + label: "6 Months", + leadingIcon: , + }, + ], + }, + { + type: "submenu", + label: "PDF", + leadingIcon: , + items: [ + { + type: "item", + label: "All", + leadingIcon: , + }, + { + type: "item", + label: "3 Months", + leadingIcon: , + }, + { + type: "item", + label: "6 Months", + leadingIcon: , + }, + ], + }, + ], + }, + { type: "item", label: "Copy", disabled: true }, + { + type: "item", + label: "Delete...", + trailingIcon: ( + + ⌘⇧D + + ), + }, + ], + }, +];` + } + ] +}; diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx new file mode 100644 index 000000000..ea4878a64 --- /dev/null +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -0,0 +1,150 @@ +--- +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/menu +tag: new +--- + +import { + playground, + iconsDemo, + customDemo, + basicDemo, + submenuDemo, + autocompleteDemo, + linearDemo, +} from "./demo.ts"; + + + +## Usage + +```tsx +import { Menu } from '@raystack/apsara' +``` + +## API Reference + +The Menu component is composed of several parts, each with their own props. + +### Root + +The root element is the parent component that holds the menu. Using the `autocomplete` prop, you can enable search functionality. + + + +### Trigger + +The button that triggers the 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. + + + +### Content + +The container that holds the menu items. When autocomplete is enabled, renders an inline search input inside the popup with a search input. + + + +### Item + +Individual clickable options within the menu. Built on top of [Base UI Menu.Item](https://base-ui.com/react/components/menu). + +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. + + + +### Group + +A way to group related menu items together. When filtering is active, the group wrapper is removed and items are rendered flat. + + + +### Label + +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. + + + +### Separator + +Visual divider between menu items or groups. Hidden when filtering is active. + + + +### EmptyState + +Placeholder content when there are no menu items to display. + + + +### Submenu + +Wraps a submenu root. Use with `Menu.SubmenuTrigger` and `Menu.SubmenuContent` to create nested menus. + +Supports its own `autocomplete` prop to enable search functionality within the submenu independently from the parent menu. + + + +### SubmenuTrigger + +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. + + + +### SubmenuContent + +The content container for a submenu. Shares the same API as `Menu.Content` with a default `sideOffset` of `2`. + + + +## Examples + +### Basic Usage + +A simple menu with basic functionality. + + + +### With Icons + +You can add icons to the menu items. Supports both leading and trailing icons. + + + +### With Groups and Labels + +Organize related menu items into sections with descriptive headers. + + + +### Submenu + +Use `Menu.Submenu`, `Menu.SubmenuTrigger`, and `Menu.SubmenuContent` 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 (`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. + + + +### Linear inspired Menu + +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/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts new file mode 100644 index 000000000..bda65d862 --- /dev/null +++ b/apps/www/src/content/docs/components/menu/props.ts @@ -0,0 +1,211 @@ +import { CSSProperties, ReactElement, ReactNode } from 'react'; + +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 false + */ + loopFocus?: boolean; +} + +export interface MenuTriggerProps { + /** Render a custom element as the trigger using Base UI's render prop pattern */ + render?: 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?: ReactElement; + + /** Inline styles */ + style?: CSSProperties; + + /** Additional CSS class names */ + className?: string; +} + +export interface MenuItemProps { + /** Icon element to display before item text */ + leadingIcon?: ReactNode; + + /** Icon element to display after item text */ + trailingIcon?: 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?: 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?: 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?: ReactNode; + + /** Icon element to display after trigger text. Defaults to a chevron right icon. */ + trailingIcon?: 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?: ReactElement; + + /** Inline styles */ + style?: CSSProperties; + + /** Additional CSS class names */ + className?: string; +} 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 8b8531786..827d181f1 100644 --- a/packages/raystack/components/combobox/combobox-item.tsx +++ b/packages/raystack/components/combobox/combobox-item.tsx @@ -4,7 +4,7 @@ import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { forwardRef, ReactNode } 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..c59b205f6 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, { isValidElement, 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,22 @@ function AddFilter({ }, [children, appliedFiltersSet, availableFilters]); return availableFilters.length > 0 ? ( - - {trigger} - + + {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/__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.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 0d72a03a8..000000000 --- a/packages/raystack/components/dropdown-menu/dropdown-menu-content.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client'; - -import { Menu, MenuProps, useMenuContext } from '@ariakit/react'; -import { Combobox, ComboboxList } 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 { 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 ( - <> -