diff --git a/packages/react/src/combobox/input/input.tsx b/packages/react/src/combobox/input/input.tsx index c2496699..4b9017dc 100644 --- a/packages/react/src/combobox/input/input.tsx +++ b/packages/react/src/combobox/input/input.tsx @@ -85,7 +85,8 @@ export const ComboboxInput = React.forwardRef< const focusOwnerStore = useFocusOwner() const isInsideInputWrapper = useIsInsideInputWrapper() - const disabled = disabledProp ?? comboboxContext.disabled + const disabled = + (disabledProp ?? comboboxContext.disabled) || popupMenuContext.disabled const placeholder = placeholderProp ?? comboboxContext.placeholder // Get open state diff --git a/packages/react/src/combobox/root/root.tsx b/packages/react/src/combobox/root/root.tsx index 61462f91..5ad76bf0 100644 --- a/packages/react/src/combobox/root/root.tsx +++ b/packages/react/src/combobox/root/root.tsx @@ -6,9 +6,11 @@ import type { VirtualItem } from '../../internal/listbox/index.js' import type { PopupMenuOpenChangeReason } from '../../internal/popup-menu/events.js' import { PopupMenuProviders, + type PopupMenuRootActions, type UsePopupMenuRootParams, usePopupMenuRoot, } from '../../internal/popup-menu/index.js' +import { REASONS } from '../../utils/events/index.js' import { defaultItemEquality, type ItemEqualityComparer, @@ -37,7 +39,10 @@ type ComboboxValueType< export interface ComboboxRootProps< Value = unknown, Multiple extends boolean | undefined = false, -> extends Omit { +> extends Omit< + PopoverRootProps, + 'open' | 'onOpenChange' | 'defaultOpen' | 'actionsRef' + > { // ===== Open State ===== /** * Whether the combobox is open. @@ -61,6 +66,14 @@ export interface ComboboxRootProps< */ defaultOpen?: boolean + /** + * A ref to imperative actions. + * - `close`: closes the menu imperatively. + * - `unmount`: unmounts the popup imperatively (when keep-mounted mode is enabled). + * - `setDisabled`: enables/disables the menu imperatively. + */ + actionsRef?: React.RefObject + // ===== Single Selection ===== /** * Current selected value (single-select mode). @@ -296,6 +309,7 @@ export function ComboboxRoot< // Open state open: openProp, onOpenChange, + actionsRef, defaultOpen = false, // Single selection value: valueProp, @@ -318,7 +332,7 @@ export function ComboboxRoot< name, form, required, - disabled = false, + disabled: disabledProp = false, placeholder = 'Search...', items, // Behavior @@ -382,6 +396,8 @@ export function ComboboxRoot< closeAll, virtualization, handleOpenChange: baseHandleOpenChange, + disabled: menuDisabled, + setDisabled, } = usePopupMenuRoot({ // Cast to generic type - component handles type safety via narrowed types onOpenChange: @@ -391,8 +407,25 @@ export function ComboboxRoot< items: virtualItems, onHighlightChange: onHighlightChange as unknown as UsePopupMenuRootParams['onHighlightChange'], + disabled: disabledProp, }) + const popoverActionsRef = React.useRef(null) + + React.useImperativeHandle( + actionsRef, + () => ({ + close: () => { + popoverActionsRef.current?.close() + }, + unmount: () => { + popoverActionsRef.current?.unmount() + }, + setDisabled, + }), + [setDisabled], + ) + // Sync controlled open prop to store store.useControlledProp('open', openProp, defaultOpen) @@ -407,6 +440,10 @@ export function ComboboxRoot< reason?: ComboboxOpenChangeEventDetails['reason'], event?: Event, ) => { + if (menuDisabled && reason !== REASONS.imperativeAction) { + return + } + // When trying to close, check if input has focus if (!newOpen && inputRef.current) { const activeElement = document.activeElement @@ -422,7 +459,7 @@ export function ComboboxRoot< event, ) }, - [baseHandleOpenChange], + [baseHandleOpenChange, menuDisabled], ) // Handle animation complete - clear search and hide input if clearSearchOnClose is 'after-exit' @@ -549,7 +586,7 @@ export function ComboboxRoot< // ===== Open/Close Helpers ===== const openCombobox = React.useCallback(() => { - if (!disabled) { + if (!menuDisabled) { // If opening with a selected value (single-select), show all items initially. // Otherwise, use active filtering. if (!multiple && value != null) { @@ -559,7 +596,7 @@ export function ComboboxRoot< } store.setOpen(true) } - }, [disabled, store, multiple, value, setFilterMode]) + }, [menuDisabled, store, multiple, value, setFilterMode]) const closeCombobox = React.useCallback( (reason?: ComboboxOpenChangeEventDetails['reason'], event?: Event) => { @@ -606,7 +643,7 @@ export function ComboboxRoot< name, form, required, - disabled, + disabled: menuDisabled, placeholder, items, itemTextRegistry: itemTextRegistryRef.current, @@ -640,7 +677,7 @@ export function ComboboxRoot< name, form, required, - disabled, + menuDisabled, placeholder, items, registerItemText, @@ -712,6 +749,7 @@ export function ComboboxRoot< store={store} focusOwnerStore={focusOwnerStore} openChainStore={openChainStore} + disabled={menuDisabled} depth={0} closeAll={closeAll} registerSurface={registerSurface} @@ -726,6 +764,7 @@ export function ComboboxRoot< onOpenChange={handlePopoverOpenChange} onOpenChangeComplete={handleOpenChangeComplete} modal={modal} + actionsRef={actionsRef ? popoverActionsRef : undefined} > {children} @@ -741,5 +780,5 @@ export namespace ComboboxRoot { > extends ComboboxRootProps {} export type OpenChangeEventDetails = ComboboxOpenChangeEventDetails export type HighlightChangeEventDetails = ComboboxHighlightChangeEventDetails - export type Actions = Popover.Root.Actions + export type Actions = PopupMenuRootActions } diff --git a/packages/react/src/context-menu/root/root.test.tsx b/packages/react/src/context-menu/root/root.test.tsx index 3302069b..a2b11b32 100644 --- a/packages/react/src/context-menu/root/root.test.tsx +++ b/packages/react/src/context-menu/root/root.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ContextMenu } from '../index.js' // ============================================================================ @@ -128,6 +128,104 @@ function ControlledContextMenu({ ) } +function ContextMenuWithImperativeActions() { + const actionsRef = React.useRef(null) + + return ( +
+ + + + + + + Right-click here + + + + + + + + Item 1 + + + + + + + +
+ ) +} + +interface ContextMenuDisableHandle { + setDisabled: (disabled: boolean) => void +} + +const ContextMenuWithInputAndImperativeActions = React.forwardRef< + ContextMenuDisableHandle, + { + dataInput?: boolean + } +>(function ContextMenuWithInputAndImperativeActions( + { dataInput = false }, + forwardedRef, +) { + const actionsRef = React.useRef(null) + + React.useImperativeHandle( + forwardedRef, + () => ({ + setDisabled: (disabled) => actionsRef.current?.setDisabled(disabled), + }), + [], + ) + + return ( + + + Right-click here + + + + + + {dataInput ? ( + + ) : ( + + )} + + + Item 1 + + + + + + + ) +}) + // ============================================================================ // Helper Functions // ============================================================================ @@ -747,6 +845,99 @@ describe('', () => { }) }) + describe('imperative actions', () => { + it('blocks opening when disabled imperatively and reopens when re-enabled', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('disable-menu')) + await rightClick(screen.getByTestId('trigger')) + + expect(screen.queryByTestId('surface')).not.toBeInTheDocument() + + await user.click(screen.getByTestId('enable-menu')) + await rightClick(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + }) + + it('can close imperatively even while disabled', async () => { + const user = userEvent.setup() + render() + + await rightClick(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('disable-menu')) + await user.click(screen.getByTestId('close-menu')) + + await waitFor(() => { + expect(screen.queryByTestId('surface')).not.toBeInTheDocument() + }) + }) + + it('disables Input when setDisabled(true) is called', async () => { + const actions = React.createRef() + + render() + + await rightClick(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + const input = screen.getByTestId('menu-input') + expect(input).not.toBeDisabled() + + act(() => { + actions.current?.setDisabled(true) + }) + + expect(input).toBeDisabled() + + act(() => { + actions.current?.setDisabled(false) + }) + + expect(input).not.toBeDisabled() + }) + + it('disables DataInput when setDisabled(true) is called', async () => { + const actions = React.createRef() + + render( + , + ) + + await rightClick(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + const dataInput = screen.getByTestId('menu-data-input') + expect(dataInput).not.toBeDisabled() + + act(() => { + actions.current?.setDisabled(true) + }) + + expect(dataInput).toBeDisabled() + + act(() => { + actions.current?.setDisabled(false) + }) + + expect(dataInput).not.toBeDisabled() + }) + }) + describe('ARIA attributes', () => { it('items have correct role', async () => { render() diff --git a/packages/react/src/context-menu/root/root.tsx b/packages/react/src/context-menu/root/root.tsx index 1410a1dd..307fab4a 100644 --- a/packages/react/src/context-menu/root/root.tsx +++ b/packages/react/src/context-menu/root/root.tsx @@ -6,6 +6,7 @@ import type { VirtualItem } from '../../internal/listbox/index.js' import type { GetQualifiedRowIdFn } from '../../internal/popup-menu/deep-search/types.js' import { PopupMenuProviders, + type PopupMenuRootActions, type UsePopupMenuRootParams, usePopupMenuRoot, type VirtualAnchor, @@ -97,6 +98,14 @@ export interface ContextMenuRootProps { */ onOpenChangeComplete?: (open: boolean) => void + /** + * A ref to imperative actions. + * - `close`: closes the menu imperatively. + * - `unmount`: unmounts the popup imperatively (when keep-mounted mode is enabled). + * - `setDisabled`: enables/disables the menu imperatively. + */ + actionsRef?: React.RefObject + /** * Function to generate qualified unique IDs for rows. * Defined once at the root level and applied to all surfaces (root and submenus). @@ -175,10 +184,11 @@ export function ContextMenuRoot(props: ContextMenuRoot.Props) { virtualized = false, items: itemsProp, onHighlightChange, - disabled = false, + disabled: disabledProp = false, modal = true, closeOnOutsidePress = 'pointerdown', onOpenChangeComplete: onOpenChangeCompleteProp, + actionsRef, getQualifiedRowId, children, } = props @@ -192,6 +202,8 @@ export function ContextMenuRoot(props: ContextMenuRoot.Props) { closeAll, virtualization, handleOpenChange, + disabled: menuDisabled, + setDisabled, } = usePopupMenuRoot({ // Cast to generic type - component handles type safety via narrowed types onOpenChange: @@ -202,8 +214,25 @@ export function ContextMenuRoot(props: ContextMenuRoot.Props) { onHighlightChange: onHighlightChange as unknown as UsePopupMenuRootParams['onHighlightChange'], closeOnOutsidePress, + disabled: disabledProp, }) + const popoverActionsRef = React.useRef(null) + + React.useImperativeHandle( + actionsRef, + () => ({ + close: () => { + popoverActionsRef.current?.close() + }, + unmount: () => { + popoverActionsRef.current?.unmount() + }, + setDisabled, + }), + [setDisabled], + ) + // Sync controlled open prop to store store.useControlledProp('open', openProp, defaultOpen) @@ -225,9 +254,9 @@ export function ContextMenuRoot(props: ContextMenuRoot.Props) { // Open the menu const openMenu = React.useCallback(() => { - if (disabled) return + if (menuDisabled) return store.setOpen(true) - }, [store, disabled]) + }, [store, menuDisabled]) // Close the menu const closeMenu = React.useCallback(() => { @@ -272,10 +301,10 @@ export function ContextMenuRoot(props: ContextMenuRoot.Props) { setAnchorPosition, openMenu, closeMenu, - disabled, + disabled: menuDisabled, open, }), - [setAnchorPosition, openMenu, closeMenu, disabled, open], + [setAnchorPosition, openMenu, closeMenu, menuDisabled, open], ) return ( @@ -284,6 +313,7 @@ export function ContextMenuRoot(props: ContextMenuRoot.Props) { store={store} focusOwnerStore={focusOwnerStore} openChainStore={openChainStore} + disabled={menuDisabled} depth={0} closeAll={closeAll} registerSurface={registerSurface} @@ -299,6 +329,7 @@ export function ContextMenuRoot(props: ContextMenuRoot.Props) { onOpenChange={handlePopoverOpenChange} onOpenChangeComplete={handleOpenChangeComplete} modal={modal} + actionsRef={actionsRef ? popoverActionsRef : undefined} > {children} @@ -312,4 +343,5 @@ export namespace ContextMenuRoot { export type OpenChangeEventDetails = ContextMenuOpenChangeEventDetails export type HighlightChangeEventDetails = ContextMenuHighlightChangeEventDetails + export type Actions = PopupMenuRootActions } diff --git a/packages/react/src/dropdown-menu/root/root.test.tsx b/packages/react/src/dropdown-menu/root/root.test.tsx index 9d6f566a..89578d73 100644 --- a/packages/react/src/dropdown-menu/root/root.test.tsx +++ b/packages/react/src/dropdown-menu/root/root.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' @@ -338,6 +338,73 @@ function DropdownMenuWithSubmenu() { ) } +interface SubmenuActionsTestHandle { + disableSubmenu: () => void + enableSubmenu: () => void +} + +type DropdownMenuWithSubmenuImperativeActionsProps = {} + +const DropdownMenuWithSubmenuImperativeActions = React.forwardRef< + SubmenuActionsTestHandle, + DropdownMenuWithSubmenuImperativeActionsProps +>(function DropdownMenuWithSubmenuImperativeActions(_, forwardedRef) { + const submenuActionsRef = React.useRef( + null, + ) + + React.useImperativeHandle( + forwardedRef, + () => ({ + disableSubmenu: () => submenuActionsRef.current?.setDisabled(true), + enableSubmenu: () => submenuActionsRef.current?.setDisabled(false), + }), + [], + ) + + return ( + + + Open Menu + + + + + + + + Item 1 + + + + More Options + + + + + + + + Sub Item 1 + + + + + + + + + Item 2 + + + + + + + + ) +}) + function DropdownMenuWithNestedSubmenus() { return ( @@ -492,6 +559,106 @@ function ControlledDropdownMenu({ ) } +function DropdownMenuWithImperativeActions() { + const actionsRef = React.useRef(null) + + return ( +
+ + + + + + + Open Menu + + + + + + + + Item 1 + + + + + + + +
+ ) +} + +interface DropdownMenuDisableHandle { + setDisabled: (disabled: boolean) => void +} + +const DropdownMenuWithInputAndImperativeActions = React.forwardRef< + DropdownMenuDisableHandle, + { + dataInput?: boolean + } +>(function DropdownMenuWithInputAndImperativeActions( + { dataInput = false }, + forwardedRef, +) { + const actionsRef = React.useRef(null) + + React.useImperativeHandle( + forwardedRef, + () => ({ + setDisabled: (disabled) => actionsRef.current?.setDisabled(disabled), + }), + [], + ) + + return ( + + + Open Menu + + + + + + {dataInput ? ( + + ) : ( + + )} + + + + Item 1 + + + + + + + + ) +}) + function DropdownMenuWithNestedSubpages() { return ( @@ -765,6 +932,101 @@ describe('', () => { }) }) + describe('imperative actions', () => { + it('blocks opening when disabled imperatively and reopens when re-enabled', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('disable-menu')) + await user.click(screen.getByTestId('trigger')) + + expect(screen.queryByTestId('surface')).not.toBeInTheDocument() + + await user.click(screen.getByTestId('enable-menu')) + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + }) + + it('can close imperatively even while disabled', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('disable-menu')) + await user.click(screen.getByTestId('close-menu')) + + await waitFor(() => { + expect(screen.queryByTestId('surface')).not.toBeInTheDocument() + }) + }) + + it('disables Input when setDisabled(true) is called', async () => { + const user = userEvent.setup() + const actions = React.createRef() + + render() + + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + const input = screen.getByTestId('menu-input') + expect(input).not.toBeDisabled() + + act(() => { + actions.current?.setDisabled(true) + }) + + expect(input).toBeDisabled() + + act(() => { + actions.current?.setDisabled(false) + }) + + expect(input).not.toBeDisabled() + }) + + it('disables DataInput when setDisabled(true) is called', async () => { + const user = userEvent.setup() + const actions = React.createRef() + + render( + , + ) + + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + const dataInput = screen.getByTestId('menu-data-input') + expect(dataInput).not.toBeDisabled() + + act(() => { + actions.current?.setDisabled(true) + }) + + expect(dataInput).toBeDisabled() + + act(() => { + actions.current?.setDisabled(false) + }) + + expect(dataInput).not.toBeDisabled() + }) + }) + describe('item selection', () => { it('closes menu when item is clicked (closeOnClick=true by default)', async () => { const user = userEvent.setup() @@ -1881,6 +2143,69 @@ describe('', () => { }) }) + describe('imperative actions', () => { + it('setDisabled only affects submenu and not root menu interactions', async () => { + const user = userEvent.setup() + const actions = React.createRef() + render() + + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + act(() => { + actions.current?.disableSubmenu() + }) + + expect(screen.getByTestId('trigger')).not.toHaveAttribute( + 'data-disabled', + ) + + expect(screen.getByTestId('submenu-trigger')).toHaveAttribute( + 'aria-disabled', + 'true', + ) + + await user.click(screen.getByTestId('submenu-trigger')) + expect(screen.queryByTestId('submenu-surface')).not.toBeInTheDocument() + + await user.click(screen.getByTestId('item-1')) + await waitFor(() => { + expect(screen.queryByTestId('surface')).not.toBeInTheDocument() + }) + + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('surface')).toBeInTheDocument() + }) + + act(() => { + actions.current?.enableSubmenu() + }) + + expect(screen.getByTestId('submenu-trigger')).not.toHaveAttribute( + 'aria-disabled', + ) + + const reopenedList = screen.getByRole('listbox') + reopenedList.focus() + await user.keyboard('{ArrowDown}') + + expect(screen.getByTestId('submenu-trigger')).toHaveAttribute( + 'data-highlighted', + ) + + await user.keyboard('{ArrowRight}') + + await waitFor(() => { + expect(screen.getByTestId('submenu-surface')).toBeInTheDocument() + }) + }) + }) + describe('data attributes', () => { it('submenu trigger has data-submenu-trigger', async () => { const user = userEvent.setup() diff --git a/packages/react/src/dropdown-menu/root/root.tsx b/packages/react/src/dropdown-menu/root/root.tsx index 447a4384..57198edb 100644 --- a/packages/react/src/dropdown-menu/root/root.tsx +++ b/packages/react/src/dropdown-menu/root/root.tsx @@ -1,11 +1,12 @@ 'use client' import { Popover, type PopoverRootProps } from '@base-ui/react/popover' -import { useCallback, useRef } from 'react' +import { useCallback, useImperativeHandle, useRef } from 'react' import type { VirtualItem } from '../../internal/listbox/index.js' import type { GetQualifiedRowIdFn } from '../../internal/popup-menu/deep-search/types.js' import { PopupMenuProviders, + type PopupMenuRootActions, type UsePopupMenuRootParams, usePopupMenuRoot, } from '../../internal/popup-menu/index.js' @@ -15,7 +16,10 @@ import type { } from '../events.js' export interface DropdownMenuRootProps - extends Omit { + extends Omit< + PopoverRootProps, + 'open' | 'onOpenChange' | 'defaultOpen' | 'actionsRef' + > { /** * Whether the dropdown menu is open. * Use for controlled mode. @@ -51,6 +55,12 @@ export interface DropdownMenuRootProps */ modal?: boolean | 'trap-focus' + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean + /** * Whether virtualization mode is enabled. * When true, items should provide an explicit `index` prop and @@ -93,6 +103,14 @@ export interface DropdownMenuRootProps */ onOpenChangeComplete?: (open: boolean) => void + /** + * A ref to imperative actions. + * - `close`: closes the menu imperatively. + * - `unmount`: unmounts the popup imperatively (when keep-mounted mode is enabled). + * - `setDisabled`: enables/disables the menu imperatively. + */ + actionsRef?: React.RefObject + /** * Function to generate qualified unique IDs for rows. * Defined once at the root level and applied to all surfaces (root and submenus). @@ -128,11 +146,13 @@ export function DropdownMenuRoot(props: DropdownMenuRoot.Props) { onOpenChange, defaultOpen = false, modal = true, + disabled = false, virtualized = false, items: itemsProp, onHighlightChange, closeOnOutsidePress = 'pointerdown', onOpenChangeComplete: onOpenChangeCompleteProp, + actionsRef, getQualifiedRowId, children, ...rest @@ -147,6 +167,8 @@ export function DropdownMenuRoot(props: DropdownMenuRoot.Props) { closeAll, virtualization, handleOpenChange, + disabled: menuDisabled, + setDisabled, } = usePopupMenuRoot({ // Cast to generic type - component handles type safety via narrowed types onOpenChange: @@ -157,8 +179,25 @@ export function DropdownMenuRoot(props: DropdownMenuRoot.Props) { onHighlightChange: onHighlightChange as unknown as UsePopupMenuRootParams['onHighlightChange'], closeOnOutsidePress, + disabled, }) + const popoverActionsRef = useRef(null) + + useImperativeHandle( + actionsRef, + () => ({ + close: () => { + popoverActionsRef.current?.close() + }, + unmount: () => { + popoverActionsRef.current?.unmount() + }, + setDisabled, + }), + [setDisabled], + ) + // Sync controlled open prop to store store.useControlledProp('open', openProp, defaultOpen) @@ -202,6 +241,7 @@ export function DropdownMenuRoot(props: DropdownMenuRoot.Props) { store={store} focusOwnerStore={focusOwnerStore} openChainStore={openChainStore} + disabled={menuDisabled} depth={0} closeAll={closeAll} registerSurface={registerSurface} @@ -217,6 +257,7 @@ export function DropdownMenuRoot(props: DropdownMenuRoot.Props) { onOpenChange={handlePopoverOpenChange} onOpenChangeComplete={handleOpenChangeComplete} modal={modal} + actionsRef={actionsRef ? popoverActionsRef : undefined} > {children} @@ -229,5 +270,5 @@ export namespace DropdownMenuRoot { export type OpenChangeEventDetails = DropdownMenuOpenChangeEventDetails export type HighlightChangeEventDetails = DropdownMenuHighlightChangeEventDetails - export type Actions = Popover.Root.Actions + export type Actions = PopupMenuRootActions } diff --git a/packages/react/src/dropdown-menu/trigger/trigger.tsx b/packages/react/src/dropdown-menu/trigger/trigger.tsx index 35be313e..2df585d4 100644 --- a/packages/react/src/dropdown-menu/trigger/trigger.tsx +++ b/packages/react/src/dropdown-menu/trigger/trigger.tsx @@ -118,8 +118,14 @@ export const DropdownMenuTrigger = React.forwardRef< >(function DropdownMenuTrigger(props, forwardedRef) { const { disabled, openOnHover, delay, closeDelay, ...rest } = props - const { store, closeAll, closeOnOutsidePress } = usePopupMenuContext() + const { + store, + closeAll, + closeOnOutsidePress, + disabled: rootDisabled, + } = usePopupMenuContext() const isOpen = store.useState('open') + const isDisabled = disabled || rootDisabled // We need to intercept pointerdown before it reaches Popover.Trigger // This ref tracks the element so we can add a one-time click blocker @@ -171,14 +177,14 @@ export const DropdownMenuTrigger = React.forwardRef< return ( ( } diff --git a/packages/react/src/internal/popup-menu/components/checkbox-item/checkbox-item.tsx b/packages/react/src/internal/popup-menu/components/checkbox-item/checkbox-item.tsx index ea711cbe..799f7f04 100644 --- a/packages/react/src/internal/popup-menu/components/checkbox-item/checkbox-item.tsx +++ b/packages/react/src/internal/popup-menu/components/checkbox-item/checkbox-item.tsx @@ -138,7 +138,7 @@ export const PopupMenuCheckboxItem = React.forwardRef< defaultChecked = false, onCheckedChange, keywords, - disabled = false, + disabled: disabledProp = false, onSelect, forceMount = false, closeOnClick = false, @@ -182,7 +182,7 @@ export const PopupMenuCheckboxItem = React.forwardRef< const item = usePopupMenuItem({ id, keywords, - disabled, + disabled: disabledProp, forceMount, shortcut, forceOrder, @@ -191,6 +191,8 @@ export const PopupMenuCheckboxItem = React.forwardRef< children, }) + const disabled = item.disabled + // Register the select handler that toggles checked state // Note: closeOnClick is handled by usePopupMenuItem's onAfterSelect React.useEffect(() => { diff --git a/packages/react/src/internal/popup-menu/components/input/input.tsx b/packages/react/src/internal/popup-menu/components/input/input.tsx index ed52fa2f..6461fa76 100644 --- a/packages/react/src/internal/popup-menu/components/input/input.tsx +++ b/packages/react/src/internal/popup-menu/components/input/input.tsx @@ -9,6 +9,7 @@ import { useMaybeComponentName, } from '../../contexts/component-name-context.js' import { useFocusOwner } from '../../contexts/focus-owner-context.js' +import { usePopupMenuContext } from '../../contexts/popup-menu-context.js' import { useMaybeSubmenuContext } from '../../contexts/submenu-context.js' import { useMaybeSubpageContext } from '../../contexts/subpage-context.js' import { usePopupMenuKeyboard } from '../../hooks/use-popup-menu-keyboard.js' @@ -59,6 +60,7 @@ export const PopupMenuInput = React.forwardRef< value: controlledValue, onValueChange, hideUntilActive = false, + disabled: disabledProp = false, render, className, style, @@ -70,6 +72,8 @@ export const PopupMenuInput = React.forwardRef< const { depth, closeAll } = useListboxContext() const submenuContext = useMaybeSubmenuContext() const subpageContext = useMaybeSubpageContext() + const { disabled: popupMenuDisabled } = usePopupMenuContext() + const disabled = popupMenuDisabled || disabledProp const focusOwnerStore = useFocusOwner() const internalRef = React.useRef(null) @@ -139,6 +143,7 @@ export const PopupMenuInput = React.forwardRef< submenuContext, subpageContext, enabled: true, + disabled, enableTypeToSearch: false, onKeyDown, closeAll, @@ -172,6 +177,7 @@ export const PopupMenuInput = React.forwardRef< autoComplete: 'off', autoCorrect: 'off', spellCheck: false, + disabled, className, style, value: displayValue, diff --git a/packages/react/src/internal/popup-menu/components/item/item.tsx b/packages/react/src/internal/popup-menu/components/item/item.tsx index 4dde3285..fcf1929f 100644 --- a/packages/react/src/internal/popup-menu/components/item/item.tsx +++ b/packages/react/src/internal/popup-menu/components/item/item.tsx @@ -100,7 +100,7 @@ export const PopupMenuItem = React.forwardRef< id, value, keywords, - disabled = false, + disabled: disabledProp = false, onSelect, forceMount = false, closeOnClick = true, @@ -121,7 +121,7 @@ export const PopupMenuItem = React.forwardRef< id, value, keywords, - disabled, + disabled: disabledProp, forceMount, shortcut, forceOrder, @@ -131,6 +131,8 @@ export const PopupMenuItem = React.forwardRef< children, }) + const disabled = item.disabled + const state: PopupMenuItem.State = React.useMemo( () => ({ highlighted: item.isHighlighted, diff --git a/packages/react/src/internal/popup-menu/components/list/list.tsx b/packages/react/src/internal/popup-menu/components/list/list.tsx index 88a84b72..c8fcbd41 100644 --- a/packages/react/src/internal/popup-menu/components/list/list.tsx +++ b/packages/react/src/internal/popup-menu/components/list/list.tsx @@ -15,6 +15,7 @@ import { useMaybeComponentName, } from '../../contexts/component-name-context.js' import { useFocusOwner } from '../../contexts/focus-owner-context.js' +import { usePopupMenuContext } from '../../contexts/popup-menu-context.js' import { useMaybeSubmenuContext } from '../../contexts/submenu-context.js' import { useMaybeSubpageContext } from '../../contexts/subpage-context.js' import { usePopupMenuKeyboard } from '../../hooks/use-popup-menu-keyboard.js' @@ -93,6 +94,7 @@ export const PopupMenuList = React.forwardRef< const { depth, closeAll } = useListboxContext() const submenuContext = useMaybeSubmenuContext() const subpageContext = useMaybeSubpageContext() + const { disabled: popupMenuDisabled } = usePopupMenuContext() const focusOwnerStore = useFocusOwner() const comboboxContext = useMaybeComboboxContext() const internalRef = React.useRef(null) @@ -148,7 +150,7 @@ export const PopupMenuList = React.forwardRef< // When there's no Input, the List should receive focus and handle keyboard nav // Note: Auto-focus is handled by Surface when it becomes the focus owner - const shouldHandleKeyboard = !hasInput + const shouldHandleKeyboard = !hasInput && !popupMenuDisabled // Use centralized keyboard navigation hook const { handleKeyDown } = usePopupMenuKeyboard({ @@ -159,6 +161,7 @@ export const PopupMenuList = React.forwardRef< submenuContext, subpageContext, enabled: shouldHandleKeyboard, + disabled: popupMenuDisabled, enableTypeToSearch: true, onKeyDown, closeAll, diff --git a/packages/react/src/internal/popup-menu/components/providers.tsx b/packages/react/src/internal/popup-menu/components/providers.tsx index 4162da99..c5668e68 100644 --- a/packages/react/src/internal/popup-menu/components/providers.tsx +++ b/packages/react/src/internal/popup-menu/components/providers.tsx @@ -32,6 +32,8 @@ export interface PopupMenuProvidersProps { focusOwnerStore: FocusOwnerStore /** The OpenChain store instance */ openChainStore: OpenChainStore + /** Whether this menu tree currently ignores user interaction */ + disabled: boolean /** Nesting depth: 0 = root menu */ depth: number /** Close the entire menu tree */ @@ -89,6 +91,7 @@ export function PopupMenuProviders(props: PopupMenuProvidersProps) { store, focusOwnerStore, openChainStore, + disabled, depth, closeAll, registerSurface, @@ -105,6 +108,7 @@ export function PopupMenuProviders(props: PopupMenuProvidersProps) { const popupMenuContextValue: PopupMenuContextValue = React.useMemo( () => ({ store, + disabled, depth, closeAll, registerSurface, @@ -116,6 +120,7 @@ export function PopupMenuProviders(props: PopupMenuProvidersProps) { }), [ store, + disabled, depth, closeAll, registerSurface, diff --git a/packages/react/src/internal/popup-menu/components/radio-group/radio-group.tsx b/packages/react/src/internal/popup-menu/components/radio-group/radio-group.tsx index af3d9354..9f0a13d6 100644 --- a/packages/react/src/internal/popup-menu/components/radio-group/radio-group.tsx +++ b/packages/react/src/internal/popup-menu/components/radio-group/radio-group.tsx @@ -12,6 +12,7 @@ import { getSlotAttribute, useMaybeComponentName, } from '../../contexts/component-name-context.js' +import { usePopupMenuContext } from '../../contexts/popup-menu-context.js' import type { RadioValueChangeEventDetails, RadioValueChangeReason, @@ -86,7 +87,7 @@ export const PopupMenuRadioGroup = React.forwardRef( value: valueProp, defaultValue, onValueChange, - disabled = false, + disabled: disabledProp = false, forceMount = false, render, className, @@ -96,8 +97,11 @@ export const PopupMenuRadioGroup = React.forwardRef( } = props const { store } = useSurfaceContext() + const popupMenuContext = usePopupMenuContext() const groupId = React.useId() + const disabled = disabledProp || popupMenuContext.disabled + // Controlled/uncontrolled state management const [internalValue, setInternalValue] = React.useState< string | undefined diff --git a/packages/react/src/internal/popup-menu/components/radio-item/radio-item.tsx b/packages/react/src/internal/popup-menu/components/radio-item/radio-item.tsx index 9e9ceeab..b1826b66 100644 --- a/packages/react/src/internal/popup-menu/components/radio-item/radio-item.tsx +++ b/packages/react/src/internal/popup-menu/components/radio-item/radio-item.tsx @@ -135,7 +135,7 @@ export const PopupMenuRadioItem = React.forwardRef(function PopupMenuRadioItem( const radioGroupContext = useRadioGroupContext() // Combine disabled from props and RadioGroup - const disabled = disabledProp || radioGroupContext.disabled + const localDisabled = disabledProp || radioGroupContext.disabled // Check if this item is selected const checked = radioGroupContext.value === value @@ -143,7 +143,7 @@ export const PopupMenuRadioItem = React.forwardRef(function PopupMenuRadioItem( const item = usePopupMenuItem({ id, keywords, - disabled, + disabled: localDisabled, forceMount, shortcut, forceOrder, @@ -152,6 +152,8 @@ export const PopupMenuRadioItem = React.forwardRef(function PopupMenuRadioItem( children, }) + const disabled = item.disabled + // Register the select handler that sets the radio value // Note: closeOnClick is handled by usePopupMenuItem's onAfterSelect React.useEffect(() => { diff --git a/packages/react/src/internal/popup-menu/components/submenu-root/submenu-root.tsx b/packages/react/src/internal/popup-menu/components/submenu-root/submenu-root.tsx index 1585f9b5..751062f3 100644 --- a/packages/react/src/internal/popup-menu/components/submenu-root/submenu-root.tsx +++ b/packages/react/src/internal/popup-menu/components/submenu-root/submenu-root.tsx @@ -16,9 +16,13 @@ import { useMaybePopupMenuContext, } from '../../contexts/popup-menu-context.js' import { SubmenuContext } from '../../contexts/submenu-context.js' +import type { PopupMenuRootActions } from '../../hooks/use-popup-menu-root.js' export interface PopupMenuSubmenuRootProps - extends Omit { + extends Omit< + PopoverRootProps, + 'open' | 'onOpenChange' | 'defaultOpen' | 'actionsRef' + > { /** * Whether the submenu is open. * Use for controlled mode. @@ -78,6 +82,21 @@ export interface PopupMenuSubmenuRootProps */ onOpenChangeComplete?: (open: boolean) => void + /** + * Whether this submenu should ignore user interaction. + * Also inherits disabled state from its parent menu. + * @default false + */ + disabled?: boolean + + /** + * A ref to imperative actions. + * - `close`: closes the submenu imperatively. + * - `unmount`: unmounts the popup imperatively (when keep-mounted mode is enabled). + * - `setDisabled`: enables/disables the submenu imperatively. + */ + actionsRef?: React.RefObject + children: React.ReactNode } @@ -97,6 +116,8 @@ export function PopupMenuSubmenuRoot(props: PopupMenuSubmenuRootProps) { items: itemsProp, onHighlightChange, onOpenChangeComplete: onOpenChangeCompleteProp, + disabled: disabledProp = false, + actionsRef, children, ...rest } = props @@ -121,6 +142,26 @@ export function PopupMenuSubmenuRoot(props: PopupMenuSubmenuRootProps) { // Create the store instance for this submenu const store = ListboxStore.useStore(undefined, { open: defaultOpen }) + const [imperativeDisabled, setImperativeDisabled] = React.useState(false) + const disabled = + (parentPopupMenuContext?.disabled ?? false) || + disabledProp || + imperativeDisabled + + const setDisabled = React.useCallback((nextDisabled: boolean) => { + setImperativeDisabled(nextDisabled) + }, []) + + const setOpen = React.useCallback( + (newOpen: boolean) => { + if (newOpen && disabled) { + return + } + store.setOpen(newOpen) + }, + [store, disabled], + ) + // Sync controlled open prop to store store.useControlledProp('open', openProp, defaultOpen) @@ -150,9 +191,9 @@ export function PopupMenuSubmenuRoot(props: PopupMenuSubmenuRootProps) { return } - store.setOpen(newOpen) + setOpen(newOpen) }, - [store, onOpenChange], + [setOpen, onOpenChange], ) // Handle animation complete - clear search and hide input if clearSearchOnClose is 'after-exit' @@ -200,29 +241,45 @@ export function PopupMenuSubmenuRoot(props: PopupMenuSubmenuRootProps) { React.useEffect(() => { if (!parentOpen) { - store.setOpen(false) + setOpen(false) } - }, [parentOpen, store]) + }, [parentOpen, setOpen]) // Register this submenu with the root for closeAll tracking const depth = parentDepth + 1 React.useEffect(() => { if (!parentRegisterSurface) return - return parentRegisterSurface(depth, (newOpen) => store.setOpen(newOpen)) - }, [parentRegisterSurface, depth, store]) + return parentRegisterSurface(depth, (newOpen) => setOpen(newOpen)) + }, [parentRegisterSurface, depth, setOpen]) + + const popoverActionsRef = React.useRef(null) + + React.useImperativeHandle( + actionsRef, + () => ({ + close: () => { + popoverActionsRef.current?.close() + }, + unmount: () => { + popoverActionsRef.current?.unmount() + }, + setDisabled, + }), + [setDisabled], + ) // Submenu context value const submenuContextValue = React.useMemo( () => ({ open, - setOpen: (newOpen: boolean) => store.setOpen(newOpen), + setOpen, triggerRef, contentRef, parentSurfaceId, childSurfaceId, closeRootOnEsc, }), - [open, store, parentSurfaceId, childSurfaceId, closeRootOnEsc], + [open, setOpen, parentSurfaceId, childSurfaceId, closeRootOnEsc], ) // Fallback registerSurface for edge cases (submenu without parent root) @@ -263,25 +320,30 @@ export function PopupMenuSubmenuRoot(props: PopupMenuSubmenuRootProps) { const popupMenuContextValue = React.useMemo( () => ({ store, + disabled, depth, - closeAll: parentCloseAll ?? (() => store.setOpen(false)), + closeAll: parentCloseAll ?? (() => setOpen(false)), registerSurface: parentRegisterSurface ?? fallbackRegisterSurface, virtualization, virtualAnchor: parentPopupMenuContext?.virtualAnchor, menuType: parentPopupMenuContext?.menuType ?? ('dropdown' as const), closeOnOutsidePress: parentPopupMenuContext?.closeOnOutsidePress ?? 'pointerdown', + getQualifiedRowId: parentPopupMenuContext?.getQualifiedRowId, }), [ store, + disabled, depth, parentCloseAll, parentRegisterSurface, + setOpen, fallbackRegisterSurface, virtualization, parentPopupMenuContext?.virtualAnchor, parentPopupMenuContext?.menuType, parentPopupMenuContext?.closeOnOutsidePress, + parentPopupMenuContext?.getQualifiedRowId, ], ) @@ -294,6 +356,7 @@ export function PopupMenuSubmenuRoot(props: PopupMenuSubmenuRootProps) { open={open} onOpenChange={handlePopoverOpenChange} onOpenChangeComplete={handleOpenChangeComplete} + actionsRef={actionsRef ? popoverActionsRef : undefined} > {children} @@ -306,5 +369,5 @@ export function PopupMenuSubmenuRoot(props: PopupMenuSubmenuRootProps) { export namespace PopupMenuSubmenuRoot { export interface Props extends PopupMenuSubmenuRootProps {} export type ChangeEventDetails = Popover.Root.ChangeEventDetails - export type Actions = Popover.Root.Actions + export type Actions = PopupMenuRootActions } diff --git a/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx b/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx index 89c2206e..49e315d1 100644 --- a/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx +++ b/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx @@ -143,7 +143,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< id: idProp, value, keywords, - disabled = false, + disabled: disabledProp = false, forceMount = false, openOnHighlight = true, delay: delayProp, @@ -222,7 +222,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< id: idProp, value, keywords, - disabled, + disabled: disabledProp, forceMount, forceOrder, forceScore, @@ -231,6 +231,8 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< children, }) + const disabled = item.disabled + // Register submenu open callback with parent store // When submenu is opened via keyboard (ArrowRight/Ctrl+L), transfer focus ownership React.useEffect(() => { diff --git a/packages/react/src/internal/popup-menu/components/subpage-back-item/subpage-back-item.tsx b/packages/react/src/internal/popup-menu/components/subpage-back-item/subpage-back-item.tsx index d78625ab..7a58c688 100644 --- a/packages/react/src/internal/popup-menu/components/subpage-back-item/subpage-back-item.tsx +++ b/packages/react/src/internal/popup-menu/components/subpage-back-item/subpage-back-item.tsx @@ -63,7 +63,7 @@ export const PopupMenuSubpageBackItem = React.forwardRef< id, value, keywords, - disabled = false, + disabled: disabledProp = false, forceMount = false, onSelect, render, @@ -92,13 +92,15 @@ export const PopupMenuSubpageBackItem = React.forwardRef< id, value, keywords, - disabled, + disabled: disabledProp, forceMount, closeOnClick: false, onSelect: handleBack, children, }) + const disabled = item.disabled + const state: PopupMenuSubpageBackItem.State = React.useMemo( () => ({ subpageBackItem: true, diff --git a/packages/react/src/internal/popup-menu/components/subpage-back/subpage-back.tsx b/packages/react/src/internal/popup-menu/components/subpage-back/subpage-back.tsx index ff95a728..66a9dcbe 100644 --- a/packages/react/src/internal/popup-menu/components/subpage-back/subpage-back.tsx +++ b/packages/react/src/internal/popup-menu/components/subpage-back/subpage-back.tsx @@ -8,6 +8,7 @@ import { useMaybeComponentName, } from '../../contexts/component-name-context.js' import { useFocusOwner } from '../../contexts/focus-owner-context.js' +import { usePopupMenuContext } from '../../contexts/popup-menu-context.js' import { useSubpageContext } from '../../contexts/subpage-context.js' import { useSubpageStack } from '../../contexts/subpage-stack-context.js' import { PopupMenuSubpageBackDataAttributes } from './subpage-back.data-attrs.js' @@ -44,7 +45,7 @@ export const PopupMenuSubpageBack = React.forwardRef< PopupMenuSubpageBack.Props >(function PopupMenuSubpageBack(props, forwardedRef) { const { - disabled = false, + disabled: disabledProp = false, render, className, style, @@ -56,6 +57,9 @@ export const PopupMenuSubpageBack = React.forwardRef< const subpageContext = useSubpageContext() const subpageStack = useSubpageStack() const focusOwnerStore = useFocusOwner() + const popupMenuContext = usePopupMenuContext() + + const disabled = disabledProp || popupMenuContext.disabled const handleClick = React.useCallback( (event: React.MouseEvent) => { diff --git a/packages/react/src/internal/popup-menu/components/subpage-trigger/subpage-trigger.tsx b/packages/react/src/internal/popup-menu/components/subpage-trigger/subpage-trigger.tsx index e5b7c3ea..c00b7354 100644 --- a/packages/react/src/internal/popup-menu/components/subpage-trigger/subpage-trigger.tsx +++ b/packages/react/src/internal/popup-menu/components/subpage-trigger/subpage-trigger.tsx @@ -81,7 +81,7 @@ export const PopupMenuSubpageTrigger = React.forwardRef< id: idProp, value, keywords, - disabled = false, + disabled: disabledProp = false, forceMount = false, forceOrder, forceScore, @@ -118,7 +118,7 @@ export const PopupMenuSubpageTrigger = React.forwardRef< id: idProp, value, keywords, - disabled, + disabled: disabledProp, forceMount, forceOrder, forceScore, @@ -128,6 +128,8 @@ export const PopupMenuSubpageTrigger = React.forwardRef< children, }) + const disabled = item.disabled + const isTargetOpen = activePageId === targetPageId const targetSurfaceId = getSurfaceId(targetPageId) const isPopupFocused = focusOwnerStore.useState( diff --git a/packages/react/src/internal/popup-menu/components/subpage/subpage.tsx b/packages/react/src/internal/popup-menu/components/subpage/subpage.tsx index 44ef956c..274b89a6 100644 --- a/packages/react/src/internal/popup-menu/components/subpage/subpage.tsx +++ b/packages/react/src/internal/popup-menu/components/subpage/subpage.tsx @@ -134,6 +134,7 @@ export function PopupMenuSubpage(props: PopupMenuSubpageProps) { virtualization: parentPopupMenuContext.virtualization, virtualAnchor: parentPopupMenuContext.virtualAnchor, menuType: parentPopupMenuContext.menuType, + disabled: parentPopupMenuContext.disabled, closeOnOutsidePress: parentPopupMenuContext.closeOnOutsidePress, getQualifiedRowId: parentPopupMenuContext.getQualifiedRowId, }), diff --git a/packages/react/src/internal/popup-menu/contexts/popup-menu-context.ts b/packages/react/src/internal/popup-menu/contexts/popup-menu-context.ts index 4dfd5f8b..c3ff04eb 100644 --- a/packages/react/src/internal/popup-menu/contexts/popup-menu-context.ts +++ b/packages/react/src/internal/popup-menu/contexts/popup-menu-context.ts @@ -51,6 +51,8 @@ export interface VirtualizationConfig { export interface PopupMenuContextValue { /** The Listbox store instance */ store: ListboxStore + /** Whether this menu tree currently ignores user interaction. */ + disabled: boolean /** Nesting depth: 0 = root menu, 1+ = submenu */ depth: number /** Close the entire menu tree (deepest submenu to root, sequentially) */ diff --git a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-item.ts b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-item.ts index 35292e6c..ed0200b1 100644 --- a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-item.ts +++ b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-item.ts @@ -12,6 +12,7 @@ import { useListboxContext, useListboxItem, } from '../../listbox/index.js' +import { useMaybePopupMenuContext } from '../contexts/popup-menu-context.js' import { useAimGuard } from './use-aim-guard.js' export interface UsePopupMenuItemParams @@ -23,7 +24,10 @@ export interface UsePopupMenuItemParams closeOnClick?: boolean } -export type UsePopupMenuItemReturn = UseListboxItemReturn +export interface UsePopupMenuItemReturn extends UseListboxItemReturn { + /** Whether this item is effectively disabled (item disabled OR menu disabled). */ + disabled: boolean +} /** * Hook that provides all shared logic for navigatable/highlightable popup menu items. @@ -35,9 +39,13 @@ export type UsePopupMenuItemReturn = UseListboxItemReturn export function usePopupMenuItem( params: UsePopupMenuItemParams, ): UsePopupMenuItemReturn { - const { closeOnClick = true, ...rest } = params + const { closeOnClick = true, disabled = false, ...rest } = params const { aimGuardActiveRef, guardedDepthRef } = useAimGuard() const { closeAll } = useListboxContext() + const popupMenuContext = useMaybePopupMenuContext() + + const menuDisabled = popupMenuContext?.disabled ?? false + const effectiveDisabled = disabled || menuDisabled // Create aim guard refs object for the listbox hook const aimGuard = React.useMemo( @@ -58,10 +66,19 @@ export function usePopupMenuItem( [closeOnClick, closeAll], ) - return useListboxItem({ + const item = useListboxItem({ ...rest, + disabled: effectiveDisabled, aimGuard, closeOnClick, onAfterSelect: handleAfterSelect, }) + + return React.useMemo( + () => ({ + ...item, + disabled: effectiveDisabled, + }), + [item, effectiveDisabled], + ) } diff --git a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-keyboard.ts b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-keyboard.ts index 2c26e3fc..b0863d9e 100644 --- a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-keyboard.ts +++ b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-keyboard.ts @@ -26,6 +26,8 @@ export interface UsePopupMenuKeyboardParams { subpageContext?: SubpageContextValue | null /** Whether keyboard handling is enabled */ enabled: boolean + /** Whether the menu currently ignores user interaction. */ + disabled?: boolean /** * Whether to enable type-to-search behavior. * When true, printable characters will activate the input and set pending search. @@ -70,6 +72,7 @@ export function usePopupMenuKeyboard( submenuContext, subpageContext = null, enabled, + disabled = false, enableTypeToSearch = false, onKeyDown, closeAll, @@ -108,7 +111,7 @@ export function usePopupMenuKeyboard( return useListboxKeyboard({ store, surfaceId, - enabled, + enabled: enabled && !disabled, onKeyDown, onSelect: handleSelect, closeAll, diff --git a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts index 212fa7d1..4c3e8968 100644 --- a/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts +++ b/packages/react/src/internal/popup-menu/hooks/use-popup-menu-root.ts @@ -66,6 +66,29 @@ export interface UsePopupMenuRootParams { * @default 'pointerdown' */ closeOnOutsidePress?: 'click' | 'pointerdown' + + /** + * Whether the menu should ignore user interaction. + * Can be controlled declaratively via this prop, or imperatively via actionsRef.setDisabled(). + * @default false + */ + disabled?: boolean + + /** + * Initial disabled state for imperative usage. + * Ignored when `disabled` prop is true. + * @default false + */ + defaultDisabled?: boolean +} + +export interface PopupMenuRootActions { + /** Close the menu tree imperatively. */ + close: () => void + /** Unmount the popup imperatively (when keep-mounted mode is enabled). */ + unmount: () => void + /** Enable/disable the menu tree imperatively. */ + setDisabled: (disabled: boolean) => void } export interface UsePopupMenuRootReturn { @@ -90,6 +113,10 @@ export interface UsePopupMenuRootReturn { reason?: PopupMenuOpenChangeReason, event?: Event, ) => void + /** Whether the menu currently ignores user interaction. */ + disabled: boolean + /** Update imperative disabled state. */ + setDisabled: (disabled: boolean) => void } // ============================================================================ @@ -116,8 +143,18 @@ export function usePopupMenuRoot( items: itemsProp, onHighlightChange, closeOnOutsidePress = 'pointerdown', + disabled: disabledProp = false, + defaultDisabled = false, } = params + const [imperativeDisabled, setImperativeDisabled] = + React.useState(defaultDisabled) + const disabled = disabledProp || imperativeDisabled + + const setDisabled = React.useCallback((nextDisabled: boolean) => { + setImperativeDisabled(nextDisabled) + }, []) + // Track outside pointer events to distinguish outside-press from focus-out // When a pointerdown happens outside the menu, we store it so that if a // focus-out close happens immediately after, we can treat it as outside-press @@ -235,7 +272,7 @@ export function usePopupMenuRoot( // - 'pointerdown': Close immediately when pointer is pressed outside // - 'click': Store event and convert focus-out to outside-press reason React.useEffect(() => { - if (!isOpen) { + if (!isOpen || disabled) { outsidePointerEventRef.current = null return } @@ -265,7 +302,7 @@ export function usePopupMenuRoot( document.removeEventListener('pointerdown', handlePointerDown, true) outsidePointerEventRef.current = null } - }, [isOpen, closeOnOutsidePress]) + }, [isOpen, closeOnOutsidePress, disabled]) // Handle open state change const handleOpenChange = React.useCallback( @@ -274,6 +311,13 @@ export function usePopupMenuRoot( reason: PopupMenuOpenChangeReason = REASONS.none, event?: Event, ) => { + const isImperativeAction = reason === REASONS.imperativeAction + + // Ignore user-driven open/close interactions while disabled. + if (disabled && !isImperativeAction) { + return + } + store.setOpen(newOpen, reason, event) // Clear focus ownership and open chain when menu closes if (!newOpen) { @@ -281,7 +325,7 @@ export function usePopupMenuRoot( openChainStore.clear() } }, - [store, focusOwnerStore, openChainStore], + [store, focusOwnerStore, openChainStore, disabled], ) // Memoize virtualization config @@ -302,5 +346,7 @@ export function usePopupMenuRoot( closeAll, virtualization, handleOpenChange, + disabled, + setDisabled, } } diff --git a/packages/react/src/internal/popup-menu/index.ts b/packages/react/src/internal/popup-menu/index.ts index 019f170b..d7f99393 100644 --- a/packages/react/src/internal/popup-menu/index.ts +++ b/packages/react/src/internal/popup-menu/index.ts @@ -90,6 +90,7 @@ export { useMouseTrail } from './utils/use-mouse-trail.js' // ============================================================================ export type { + PopupMenuRootActions, UsePopupMenuRootParams, UsePopupMenuRootReturn, } from './hooks/use-popup-menu-root.js' diff --git a/packages/react/src/select/root/root.tsx b/packages/react/src/select/root/root.tsx index 42b472f6..57402d67 100644 --- a/packages/react/src/select/root/root.tsx +++ b/packages/react/src/select/root/root.tsx @@ -5,6 +5,7 @@ import * as React from 'react' import type { VirtualItem } from '../../internal/listbox/index.js' import { PopupMenuProviders, + type PopupMenuRootActions, type UsePopupMenuRootParams, usePopupMenuRoot, } from '../../internal/popup-menu/index.js' @@ -34,7 +35,10 @@ type SelectValue< export interface SelectRootProps< Value = unknown, Multiple extends boolean | undefined = false, -> extends Omit { +> extends Omit< + PopoverRootProps, + 'open' | 'onOpenChange' | 'defaultOpen' | 'actionsRef' + > { // ===== Open State ===== /** * Whether the select is open. @@ -57,6 +61,14 @@ export interface SelectRootProps< */ onOpenChangeComplete?: (open: boolean) => void + /** + * A ref to imperative actions. + * - `close`: closes the menu imperatively. + * - `unmount`: unmounts the popup imperatively (when keep-mounted mode is enabled). + * - `setDisabled`: enables/disables the menu imperatively. + */ + actionsRef?: React.RefObject + /** * Whether the select is initially open. * Use for uncontrolled mode. @@ -247,6 +259,7 @@ export function SelectRoot< open: openProp, onOpenChange, onOpenChangeComplete, + actionsRef, defaultOpen = false, // Single selection value: valueProp, @@ -265,7 +278,7 @@ export function SelectRoot< name, form, required, - disabled = false, + disabled: disabledProp = false, placeholder = 'Select...', items, // Behavior @@ -320,6 +333,8 @@ export function SelectRoot< closeAll, virtualization, handleOpenChange, + disabled: menuDisabled, + setDisabled, } = usePopupMenuRoot({ // Cast to generic type - component handles type safety via narrowed types onOpenChange: @@ -329,8 +344,25 @@ export function SelectRoot< items: virtualItems, onHighlightChange: onHighlightChange as unknown as UsePopupMenuRootParams['onHighlightChange'], + disabled: disabledProp, }) + const popoverActionsRef = React.useRef(null) + + React.useImperativeHandle( + actionsRef, + () => ({ + close: () => { + popoverActionsRef.current?.close() + }, + unmount: () => { + popoverActionsRef.current?.unmount() + }, + setDisabled, + }), + [setDisabled], + ) + // Sync controlled open prop to store store.useControlledProp('open', openProp, defaultOpen) @@ -399,7 +431,7 @@ export function SelectRoot< name, form, required, - disabled, + disabled: menuDisabled, placeholder, items, itemTextRegistry: itemTextRegistryRef.current, @@ -425,7 +457,7 @@ export function SelectRoot< name, form, required, - disabled, + menuDisabled, placeholder, items, registerItemText, @@ -518,6 +550,7 @@ export function SelectRoot< store={store} focusOwnerStore={focusOwnerStore} openChainStore={openChainStore} + disabled={menuDisabled} depth={0} closeAll={closeAll} registerSurface={registerSurface} @@ -532,6 +565,7 @@ export function SelectRoot< onOpenChange={handlePopoverOpenChange} onOpenChangeComplete={handleOpenChangeComplete} modal={modal} + actionsRef={actionsRef ? popoverActionsRef : undefined} > {children} @@ -547,5 +581,5 @@ export namespace SelectRoot { > extends SelectRootProps {} export type OpenChangeEventDetails = SelectOpenChangeEventDetails export type HighlightChangeEventDetails = SelectHighlightChangeEventDetails - export type Actions = Popover.Root.Actions + export type Actions = PopupMenuRootActions } diff --git a/packages/react/src/select/trigger/trigger.tsx b/packages/react/src/select/trigger/trigger.tsx index 2b98377e..0d3c658a 100644 --- a/packages/react/src/select/trigger/trigger.tsx +++ b/packages/react/src/select/trigger/trigger.tsx @@ -121,7 +121,9 @@ export const SelectTrigger = React.forwardRef< >(function SelectTrigger(props, forwardedRef) { const { disabled: disabledProp, ...rest } = props const selectContext = useSelectContext() - const disabled = disabledProp ?? selectContext.disabled + const popupMenuContext = usePopupMenuContext() + const disabled = + (disabledProp ?? selectContext.disabled) || popupMenuContext.disabled return (