diff --git a/apps/www/src/components/playground/context-menu-examples.tsx b/apps/www/src/components/playground/context-menu-examples.tsx new file mode 100644 index 000000000..1e3a69ff3 --- /dev/null +++ b/apps/www/src/components/playground/context-menu-examples.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { ContextMenu, Flex, Text } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function ContextMenuExamples() { + return ( + + + + + Right click here + + + Profile + Settings + + Logout + + + + + With icons + + + 📝}>Edit + 📋} trailingIcon={<>⌘C}> + Copy + + + 🗑️}>Delete + + + + + With groups + + + + 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 884a9271b..2b99f2041 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -13,6 +13,7 @@ export * from './collapsible-examples'; export * from './combobox-examples'; export * from './command-examples'; export * from './container-examples'; +export * from './context-menu-examples'; export * from './data-table-examples'; export * from './dialog-examples'; export * from './drawer-examples'; diff --git a/apps/www/src/content/docs/components/context-menu/demo.ts b/apps/www/src/content/docs/components/context-menu/demo.ts new file mode 100644 index 000000000..9643b2f17 --- /dev/null +++ b/apps/www/src/content/docs/components/context-menu/demo.ts @@ -0,0 +1,214 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: any) => { + const contentProps = props.autocomplete + ? ' searchPlaceholder="Search..."' + : ''; + return ` + + + Right click here + + + + Assign member... + Subscribe... + Rename... + + + + Actions + + + Export + + + CSV + PDF + + + Copy + + ⌘⇧D + + }> + Delete... + + + + `; +}; + +export const playground = { + type: 'playground', + controls: { + autocomplete: { + type: 'checkbox', + defaultValue: false + } + }, + getCode +}; + +export const basicDemo = { + type: 'code', + code: ` + + + Right click here + + + Profile + Settings + + Logout + + ` +}; + +export const iconsDemo = { + type: 'code', + code: ` + + + Right click here + + + 📝}>Edit + 📋} trailingIcon={<>⌘C}>Copy + + 🗑️}>Delete + + ` +}; + +export const customDemo = { + type: 'code', + code: ` + + + Right click here + + + + Actions + New File + New Folder + + + + Sort By + Name + Date + + + ` +}; + +export const submenuDemo = { + type: 'code', + code: ` + + + Right click here + + + 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: ` + + + Right click here + + + + 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: ` + + + Right click here + + + Copy + Delete... + + Sub Menu + + Sub Menu Item 1 + Sub Menu Item 2 + Sub Menu Item 3 + + + + ` + } + ] +}; diff --git a/apps/www/src/content/docs/components/context-menu/index.mdx b/apps/www/src/content/docs/components/context-menu/index.mdx new file mode 100644 index 000000000..d570dfbec --- /dev/null +++ b/apps/www/src/content/docs/components/context-menu/index.mdx @@ -0,0 +1,135 @@ +--- +title: Context Menu +description: Displays a menu activated by right-click or long press, such as a set of actions or functions. +source: packages/raystack/components/context-menu +tag: new +--- + +import { + playground, + basicDemo, + iconsDemo, + customDemo, + submenuDemo, + autocompleteDemo, +} from "./demo.ts"; + + + +## Usage + +```tsx +import { ContextMenu } from '@raystack/apsara' +``` + +## API Reference + +The ContextMenu component is composed of several parts, each with their own props. + +### Root + +The root element is the parent component that holds the context menu. Using the `autocomplete` prop, you can enable search functionality. + + + +### Trigger + +The area that opens the context menu on right-click or long press. Renders a `
` element. + + + +### Content + +The container that holds the menu items. When autocomplete is enabled, renders an inline search input inside the popup. + + + +### Item + +Individual clickable options within the menu. Built on top of [Base UI ContextMenu.Item](https://base-ui.com/react/components/context-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 ContextMenu.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 `ContextMenu.SubmenuTrigger` and `ContextMenu.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 `ContextMenu.Content` with a default `sideOffset` of `2`. + + + +## Examples + +### Basic Usage + +A simple context menu with basic functionality. Right-click the trigger area to open. + + + +### 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 `ContextMenu.Submenu`, `ContextMenu.SubmenuTrigger`, and `ContextMenu.SubmenuContent` to create nested menus with multiple levels. + + + +### Autocomplete + +To enable autocomplete, pass the `autocomplete` prop to the ContextMenu 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 `ContextMenu.Submenu`. + + diff --git a/apps/www/src/content/docs/components/context-menu/props.ts b/apps/www/src/content/docs/components/context-menu/props.ts new file mode 100644 index 000000000..fc51c9856 --- /dev/null +++ b/apps/www/src/content/docs/components/context-menu/props.ts @@ -0,0 +1,209 @@ +import { CSSProperties, ReactElement, ReactNode } from 'react'; + +export interface ContextMenuRootProps { + /** 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 should loop focus when navigating with keyboard + * @default false + */ + loopFocus?: boolean; + + /** Whether the component should ignore user interaction + * @default false + */ + disabled?: boolean; +} + +export interface ContextMenuTriggerProps { + /** Render a custom element as the trigger using Base UI's render prop pattern */ + render?: ReactElement; + + /** Additional CSS class names */ + className?: string; +} + +export interface ContextMenuContentProps { + /** 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 ContextMenuItemProps { + /** 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 ContextMenuGroupProps { + /** Additional CSS class names */ + className?: string; +} + +export interface ContextMenuLabelProps { + /** Additional CSS class names */ + className?: string; +} + +export interface ContextMenuSeparatorProps { + /** Additional CSS class names */ + className?: string; +} + +export interface ContextMenuEmptyStateProps { + /** React nodes to render in empty state */ + children?: ReactNode; + + /** Additional CSS class names */ + className?: string; +} + +export interface ContextMenuSubMenuProps { + /** 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 ContextMenuSubTriggerProps { + /** 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 ContextMenuSubContentProps { + /** 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/context-menu/__tests__/context-menu.test.tsx b/packages/raystack/components/context-menu/__tests__/context-menu.test.tsx new file mode 100644 index 000000000..bb7e2b360 --- /dev/null +++ b/packages/raystack/components/context-menu/__tests__/context-menu.test.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { ContextMenu } from '../context-menu'; + +// Mock scrollIntoView for test environment +Object.defineProperty(Element.prototype, 'scrollIntoView', { + value: vi.fn(), + writable: true +}); + +// String constants +const TRIGGER_TEXT = 'Right click here'; +const MENU_ITEMS = [ + { id: 'profile', label: 'Profile' }, + { id: 'settings', label: 'Settings' }, + { id: 'billing', label: 'Billing' }, + { id: 'team', label: 'Team' }, + { id: 'logout', label: 'Logout' } +]; + +interface BasicContextMenuProps { + onClick?: (value: string) => void; + onOpenChange?: (open: boolean) => void; + children?: ReactNode; +} + +const BasicContextMenu = ({ + onClick, + children, + ...props +}: BasicContextMenuProps) => { + return ( + + {TRIGGER_TEXT} + + {MENU_ITEMS.map(item => ( + onClick?.(item.id)}> + {item.label} + + ))} + {children} + + + ); +}; + +const renderAndOpenContextMenu = async (element: React.ReactElement) => { + const { getByText } = render(element); + const trigger = getByText(TRIGGER_TEXT); + await fireEvent.contextMenu(trigger); +}; + +describe('ContextMenu', () => { + describe('Basic Rendering', () => { + it('renders 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 right-clicked', async () => { + await renderAndOpenContextMenu(); + + expect(screen.getByRole('menu')).toBeInTheDocument(); + MENU_ITEMS.forEach(item => { + expect(screen.getByText(item.label)).toBeInTheDocument(); + }); + }); + }); + + describe('Trigger Interaction', () => { + it('opens menu on right-click (contextmenu event)', async () => { + await renderAndOpenContextMenu(); + + 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 renderAndOpenContextMenu(); + + const item = screen.getByText(MENU_ITEMS[0].label); + fireEvent.click(item); + + expect(onClick).toHaveBeenCalled(); + }); + + it('supports disabled items', async () => { + const onClick = vi.fn(); + + await renderAndOpenContextMenu( + + + 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(); + + render(); + + const trigger = screen.getByText(TRIGGER_TEXT); + fireEvent.contextMenu(trigger); + + expect(onOpenChange).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/raystack/components/context-menu/context-menu-content.tsx b/packages/raystack/components/context-menu/context-menu-content.tsx new file mode 100644 index 000000000..bc869a638 --- /dev/null +++ b/packages/raystack/components/context-menu/context-menu-content.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + ContextMenu as ContextMenuPrimitive +} from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { forwardRef, KeyboardEvent, useCallback, useRef } from 'react'; +import styles from '../menu/menu.module.css'; +import { useMenuContext } from '../menu/menu-root'; +import { + dispatchKeyboardEvent, + isElementSubMenuOpen, + isElementSubMenuTrigger, + KEYCODES +} from '../menu/utils'; + +export interface ContextMenuContentProps + extends Omit< + ContextMenuPrimitive.Positioner.Props, + 'render' | 'className' | 'style' + >, + ContextMenuPrimitive.Popup.Props { + searchPlaceholder?: string; +} + +export const ContextMenuContent = forwardRef< + HTMLDivElement, + ContextMenuContentProps +>( + ( + { + className, + children, + searchPlaceholder = 'Search...', + render, + finalFocus, + style, + sideOffset = 4, + align = 'start', + onFocus, + ...positionerProps + }, + ref + ) => { + const { + autocomplete, + inputValue, + onInputValueChange, + inputRef, + isInitialRender, + parent + } = useMenuContext(); + + const focusInput = useCallback(() => { + 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"]'); + if (!item) return; + item.dispatchEvent(new PointerEvent('mousemove', { bubbles: true })); + }, [isInitialRender]); + + const checkAndOpenSubMenu = useCallback(() => { + if (highlightedItem.current[0] === -1) return; + const items = containerRef.current?.querySelectorAll('[role="option"]'); + const item = items?.[highlightedItem.current[0]]; + if (!item || !isElementSubMenuTrigger(item)) return; + dispatchKeyboardEvent(item, KEYCODES.ARROW_RIGHT); + }, []); + + const checkAndCloseSubMenu = useCallback( + (e: KeyboardEvent) => { + if (highlightedItem.current[0] === -1) return; + const items = containerRef.current?.querySelectorAll('[role="option"]'); + const item = items?.[highlightedItem.current[0]]; + if ( + !item || + !isElementSubMenuTrigger(item) || + !isElementSubMenuOpen(item) + ) + return; + dispatchKeyboardEvent(item, KEYCODES.ESCAPE); + e.stopPropagation(); + }, + [] + ); + + const blurStaleMenuItem = useCallback((index: number) => { + const items = containerRef.current?.querySelectorAll('[role="option"]'); + const item = items?.[index]; + if ( + !item || + !isElementSubMenuTrigger(item) || + !isElementSubMenuOpen(item) + ) + return; + dispatchKeyboardEvent(item, KEYCODES.ESCAPE); + item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); + }, []); + + return ( + + + { + focusInput(); + e.stopPropagation(); + highlightFirstItem(); + onFocus?.(e); + } + : undefined + } + > + {autocomplete ? ( + onInputValueChange?.(value)} + autoHighlight={!!inputValue?.length} + mode='none' + loopFocus={false} + onItemHighlighted={(value, eventDetails) => { + if ( + highlightedItem.current[1] === 'pointer' && + eventDetails.reason === 'keyboard' + ) { + blurStaleMenuItem(highlightedItem.current[0]); + } + highlightedItem.current = [ + eventDetails.index, + eventDetails.reason + ]; + }} + > + { + focusInput(); + }} + onKeyDown={e => { + if (e.key === 'ArrowLeft') return; + if (e.key === 'Escape') return checkAndCloseSubMenu(e); + if (e.key === 'ArrowRight' || e.key === 'Enter') + checkAndOpenSubMenu(); + e.stopPropagation(); + }} + tabIndex={-1} + /> + + {children} + + + ) : ( + children + )} + + + + ); + } +); +ContextMenuContent.displayName = 'ContextMenu.Content'; + +export const ContextMenuSubContent = forwardRef< + HTMLDivElement, + ContextMenuContentProps +>(({ ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = 'ContextMenu.SubContent'; diff --git a/packages/raystack/components/context-menu/context-menu-item.tsx b/packages/raystack/components/context-menu/context-menu-item.tsx new file mode 100644 index 000000000..ad0d2ea05 --- /dev/null +++ b/packages/raystack/components/context-menu/context-menu-item.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + ContextMenu as ContextMenuPrimitive +} from '@base-ui/react'; +import { forwardRef } from 'react'; +import { Cell, CellBaseProps } from '../menu/cell'; +import { useMenuContext } from '../menu/menu-root'; +import { getMatch } from '../menu/utils'; + +export interface ContextMenuItemProps + extends ContextMenuPrimitive.Item.Props, + CellBaseProps { + value?: string; +} + +export const ContextMenuItem = forwardRef( + ({ children, value, leadingIcon, trailingIcon, render, ...props }, ref) => { + 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, inputValue)) { + return null; + } + + if (autocomplete) { + return ( + } + {...props} + > + {children} + + ); + } + + return ( + { + e.stopPropagation(); + e.preventDefault(); + e.preventBaseUIHandler(); + }} + > + {children} + + ); + } +); +ContextMenuItem.displayName = 'ContextMenu.Item'; diff --git a/packages/raystack/components/context-menu/context-menu-misc.tsx b/packages/raystack/components/context-menu/context-menu-misc.tsx new file mode 100644 index 000000000..e5728580f --- /dev/null +++ b/packages/raystack/components/context-menu/context-menu-misc.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { Fragment, forwardRef, HTMLAttributes, ReactNode } from 'react'; +import styles from '../menu/menu.module.css'; +import { useMenuContext } from '../menu/menu-root'; + +export type ContextMenuGroupProps = ContextMenuPrimitive.Group.Props; +export const ContextMenuGroup = forwardRef< + HTMLDivElement, + ContextMenuGroupProps +>(({ className, children, ...props }, ref) => { + const { shouldFilter } = useMenuContext(); + + if (shouldFilter) { + return {children}; + } + + return ( + + {children} + + ); +}); +ContextMenuGroup.displayName = 'ContextMenu.Group'; + +export type ContextMenuLabelProps = ContextMenuPrimitive.GroupLabel.Props; +export const ContextMenuLabel = forwardRef< + HTMLDivElement, + ContextMenuLabelProps +>(({ className, ...props }, ref) => { + const { shouldFilter } = useMenuContext(); + + if (shouldFilter) { + return null; + } + + return ( + + ); +}); +ContextMenuLabel.displayName = 'ContextMenu.Label'; + +export const ContextMenuSeparator = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => { + const { shouldFilter } = useMenuContext(); + + if (shouldFilter) { + return null; + } + + return ( +
+ ); +}); +ContextMenuSeparator.displayName = 'ContextMenu.Separator'; + +export const ContextMenuEmptyState = forwardRef< + HTMLDivElement, + HTMLAttributes & { + children: ReactNode; + } +>(({ className, children, ...props }, ref) => ( +
+ {children} +
+)); +ContextMenuEmptyState.displayName = 'ContextMenu.EmptyState'; diff --git a/packages/raystack/components/context-menu/context-menu-root.tsx b/packages/raystack/components/context-menu/context-menu-root.tsx new file mode 100644 index 000000000..331b9ca55 --- /dev/null +++ b/packages/raystack/components/context-menu/context-menu-root.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; +import { useCallback, useRef, useState } from 'react'; +import { MenuContext, useMenuContext } from '../menu/menu-root'; + +export interface NormalContextMenuRootProps + extends ContextMenuPrimitive.Root.Props { + autocomplete?: false; + autocompleteMode?: never; + inputValue?: never; + onInputValueChange?: never; + defaultInputValue?: never; +} + +export interface AutocompleteContextMenuRootProps + extends ContextMenuPrimitive.Root.Props { + autocomplete: true; + autocompleteMode?: 'auto' | 'manual'; + inputValue?: string; + onInputValueChange?: (value: string) => void; + defaultInputValue?: string; +} + +export type ContextMenuRootProps = + | NormalContextMenuRootProps + | AutocompleteContextMenuRootProps; + +export const ContextMenuRoot = ({ + autocomplete, + autocompleteMode = 'auto', + inputValue: providedInputValue, + onInputValueChange, + defaultInputValue = '', + open: providedOpen, + onOpenChange, + defaultOpen = false, + ...props +}: ContextMenuRootProps) => { + 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 inputValue = providedInputValue ?? internalInputValue; + + const setValue = useCallback( + (value: string) => { + setInternalInputValue(value); + onInputValueChange?.(value); + }, + [onInputValueChange] + ); + + const handleOpenChange: ContextMenuPrimitive.Root.Props['onOpenChange'] = + useCallback( + ( + value: boolean, + eventDetails: ContextMenuPrimitive.Root.ChangeEventDetails + ) => { + if (!value && autocomplete) { + setValue(''); + isInitialRender.current = true; + } + setInternalOpen(value); + onOpenChange?.(value, eventDetails); + }, + [onOpenChange, setValue, autocomplete] + ); + + return ( + + + + ); +}; +ContextMenuRoot.displayName = 'ContextMenu'; + +export interface NormalContextMenuSubMenuProps + extends ContextMenuPrimitive.SubmenuRoot.Props { + autocomplete?: false; + autocompleteMode?: never; + inputValue?: never; + onInputValueChange?: never; + defaultInputValue?: never; +} + +export interface AutocompleteContextMenuSubMenuProps + extends ContextMenuPrimitive.SubmenuRoot.Props { + autocomplete: true; + autocompleteMode?: 'auto' | 'manual'; + inputValue?: string; + onInputValueChange?: (value: string) => void; + defaultInputValue?: string; +} + +export type ContextMenuSubMenuProps = + | NormalContextMenuSubMenuProps + | AutocompleteContextMenuSubMenuProps; + +export const ContextMenuSubMenu = ({ + autocomplete, + autocompleteMode = 'auto', + inputValue: providedInputValue, + onInputValueChange, + defaultInputValue = '', + open: providedOpen, + onOpenChange, + defaultOpen = false, + ...props +}: ContextMenuSubMenuProps) => { + const [internalInputValue, setInternalInputValue] = + useState(defaultInputValue); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const open = providedOpen ?? internalOpen; + const parentContext = useMenuContext(); + const inputRef = useRef(null); + const isInitialRender = useRef(true); + const contentRef = useRef(null); + const inputValue = providedInputValue ?? internalInputValue; + + const setValue = useCallback( + (value: string) => { + setInternalInputValue(value); + onInputValueChange?.(value); + }, + [onInputValueChange] + ); + + const handleOpenChange: ContextMenuPrimitive.SubmenuRoot.Props['onOpenChange'] = + useCallback( + ( + value: boolean, + eventDetails: ContextMenuPrimitive.SubmenuRoot.ChangeEventDetails + ) => { + if (!value && autocomplete) { + setValue(''); + isInitialRender.current = true; + } + setInternalOpen(value); + onOpenChange?.(value, eventDetails); + }, + [onOpenChange, setValue, autocomplete] + ); + + return ( + + + + ); +}; +ContextMenuSubMenu.displayName = 'ContextMenu.SubMenu'; diff --git a/packages/raystack/components/context-menu/context-menu-trigger.tsx b/packages/raystack/components/context-menu/context-menu-trigger.tsx new file mode 100644 index 000000000..a09345bfd --- /dev/null +++ b/packages/raystack/components/context-menu/context-menu-trigger.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + ContextMenu as ContextMenuPrimitive +} from '@base-ui/react'; +import { forwardRef } from 'react'; +import { TriangleRightIcon } from '~/icons'; +import { Cell, CellBaseProps } from '../menu/cell'; +import { useMenuContext } from '../menu/menu-root'; +import { getMatch } from '../menu/utils'; + +export interface ContextMenuTriggerProps + extends ContextMenuPrimitive.Trigger.Props {} + +export const ContextMenuTrigger = forwardRef< + HTMLDivElement, + ContextMenuTriggerProps +>(({ children, ...props }, ref) => { + return ( + + {children} + + ); +}); +ContextMenuTrigger.displayName = 'ContextMenu.Trigger'; + +export interface ContextMenuSubTriggerProps + extends ContextMenuPrimitive.SubmenuTrigger.Props, + CellBaseProps { + value?: string; +} + +export const ContextMenuSubTrigger = forwardRef< + HTMLDivElement, + ContextMenuSubTriggerProps +>( + ( + { + children, + value, + trailingIcon = , + leadingIcon, + ...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(); + props?.onPointerEnter?.(e); + }} + onKeyDown={e => { + requestAnimationFrame(() => { + inputRef?.current?.focus(); + }); + props?.onKeyDown?.(e); + }} + /> + ) : ( + cell + ) + } + {...props} + role={parent?.autocomplete ? 'option' : 'menuitem'} + data-slot='menu-subtrigger' + > + {children} + + ); + } +); +ContextMenuSubTrigger.displayName = 'ContextMenu.SubTrigger'; diff --git a/packages/raystack/components/context-menu/context-menu.tsx b/packages/raystack/components/context-menu/context-menu.tsx new file mode 100644 index 000000000..15ce97fce --- /dev/null +++ b/packages/raystack/components/context-menu/context-menu.tsx @@ -0,0 +1,29 @@ +import { + ContextMenuContent, + ContextMenuSubContent +} from './context-menu-content'; +import { ContextMenuItem } from './context-menu-item'; +import { + ContextMenuEmptyState, + ContextMenuGroup, + ContextMenuLabel, + ContextMenuSeparator +} from './context-menu-misc'; +import { ContextMenuRoot, ContextMenuSubMenu } from './context-menu-root'; +import { + ContextMenuSubTrigger, + ContextMenuTrigger +} from './context-menu-trigger'; + +export const ContextMenu = Object.assign(ContextMenuRoot, { + Trigger: ContextMenuTrigger, + Content: ContextMenuContent, + Item: ContextMenuItem, + Group: ContextMenuGroup, + Label: ContextMenuLabel, + Separator: ContextMenuSeparator, + EmptyState: ContextMenuEmptyState, + Submenu: ContextMenuSubMenu, + SubmenuTrigger: ContextMenuSubTrigger, + SubmenuContent: ContextMenuSubContent +}); diff --git a/packages/raystack/components/context-menu/index.ts b/packages/raystack/components/context-menu/index.ts new file mode 100644 index 000000000..f628d09c8 --- /dev/null +++ b/packages/raystack/components/context-menu/index.ts @@ -0,0 +1 @@ +export { ContextMenu } from './context-menu'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 03d3e7308..23dead7d6 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -19,6 +19,7 @@ export * from './components/color-picker'; export { Combobox } from './components/combobox'; export { Command } from './components/command'; export { Container } from './components/container'; +export { ContextMenu } from './components/context-menu'; export { CopyButton } from './components/copy-button'; export { DataTable,