Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/react/src/combobox/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 47 additions & 8 deletions packages/react/src/combobox/root/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -37,7 +39,10 @@ type ComboboxValueType<
export interface ComboboxRootProps<
Value = unknown,
Multiple extends boolean | undefined = false,
> extends Omit<PopoverRootProps, 'open' | 'onOpenChange' | 'defaultOpen'> {
> extends Omit<
PopoverRootProps,
'open' | 'onOpenChange' | 'defaultOpen' | 'actionsRef'
> {
// ===== Open State =====
/**
* Whether the combobox is open.
Expand All @@ -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<ComboboxRoot.Actions | null>

// ===== Single Selection =====
/**
* Current selected value (single-select mode).
Expand Down Expand Up @@ -296,6 +309,7 @@ export function ComboboxRoot<
// Open state
open: openProp,
onOpenChange,
actionsRef,
defaultOpen = false,
// Single selection
value: valueProp,
Expand All @@ -318,7 +332,7 @@ export function ComboboxRoot<
name,
form,
required,
disabled = false,
disabled: disabledProp = false,
placeholder = 'Search...',
items,
// Behavior
Expand Down Expand Up @@ -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:
Expand All @@ -391,8 +407,25 @@ export function ComboboxRoot<
items: virtualItems,
onHighlightChange:
onHighlightChange as unknown as UsePopupMenuRootParams['onHighlightChange'],
disabled: disabledProp,
})

const popoverActionsRef = React.useRef<Popover.Root.Actions | null>(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)

Expand All @@ -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
Expand All @@ -422,7 +459,7 @@ export function ComboboxRoot<
event,
)
},
[baseHandleOpenChange],
[baseHandleOpenChange, menuDisabled],
)

// Handle animation complete - clear search and hide input if clearSearchOnClose is 'after-exit'
Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -606,7 +643,7 @@ export function ComboboxRoot<
name,
form,
required,
disabled,
disabled: menuDisabled,
placeholder,
items,
itemTextRegistry: itemTextRegistryRef.current,
Expand Down Expand Up @@ -640,7 +677,7 @@ export function ComboboxRoot<
name,
form,
required,
disabled,
menuDisabled,
placeholder,
items,
registerItemText,
Expand Down Expand Up @@ -712,6 +749,7 @@ export function ComboboxRoot<
store={store}
focusOwnerStore={focusOwnerStore}
openChainStore={openChainStore}
disabled={menuDisabled}
depth={0}
closeAll={closeAll}
registerSurface={registerSurface}
Expand All @@ -726,6 +764,7 @@ export function ComboboxRoot<
onOpenChange={handlePopoverOpenChange}
onOpenChangeComplete={handleOpenChangeComplete}
modal={modal}
actionsRef={actionsRef ? popoverActionsRef : undefined}
>
{children}
</Popover.Root>
Expand All @@ -741,5 +780,5 @@ export namespace ComboboxRoot {
> extends ComboboxRootProps<Value, Multiple> {}
export type OpenChangeEventDetails = ComboboxOpenChangeEventDetails
export type HighlightChangeEventDetails = ComboboxHighlightChangeEventDetails
export type Actions = Popover.Root.Actions
export type Actions = PopupMenuRootActions
}
193 changes: 192 additions & 1 deletion packages/react/src/context-menu/root/root.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

// ============================================================================
Expand Down Expand Up @@ -128,6 +128,104 @@ function ControlledContextMenu({
)
}

function ContextMenuWithImperativeActions() {
const actionsRef = React.useRef<ContextMenu.Root.Actions | null>(null)

return (
<div>
<button
type="button"
data-testid="disable-menu"
onClick={() => actionsRef.current?.setDisabled(true)}
>
Disable Menu
</button>
<button
type="button"
data-testid="enable-menu"
onClick={() => actionsRef.current?.setDisabled(false)}
>
Enable Menu
</button>
<button
type="button"
data-testid="close-menu"
onClick={() => actionsRef.current?.close()}
>
Close Menu
</button>

<ContextMenu.Root actionsRef={actionsRef}>
<ContextMenu.Trigger data-testid="trigger">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Positioner>
<ContextMenu.Popup>
<ContextMenu.Surface data-testid="surface">
<ContextMenu.List>
<ContextMenu.Item data-testid="item-1">
Item 1
</ContextMenu.Item>
</ContextMenu.List>
</ContextMenu.Surface>
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
)
}

interface ContextMenuDisableHandle {
setDisabled: (disabled: boolean) => void
}

const ContextMenuWithInputAndImperativeActions = React.forwardRef<
ContextMenuDisableHandle,
{
dataInput?: boolean
}
>(function ContextMenuWithInputAndImperativeActions(
{ dataInput = false },
forwardedRef,
) {
const actionsRef = React.useRef<ContextMenu.Root.Actions | null>(null)

React.useImperativeHandle(
forwardedRef,
() => ({
setDisabled: (disabled) => actionsRef.current?.setDisabled(disabled),
}),
[],
)

return (
<ContextMenu.Root actionsRef={actionsRef}>
<ContextMenu.Trigger data-testid="trigger">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Positioner>
<ContextMenu.Popup>
<ContextMenu.Surface data-testid="surface">
{dataInput ? (
<ContextMenu.DataInput data-testid="menu-data-input" />
) : (
<ContextMenu.Input data-testid="menu-input" />
)}

<ContextMenu.List>
<ContextMenu.Item data-testid="item-1">Item 1</ContextMenu.Item>
</ContextMenu.List>
</ContextMenu.Surface>
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
)
})

// ============================================================================
// Helper Functions
// ============================================================================
Expand Down Expand Up @@ -747,6 +845,99 @@ describe('<ContextMenu.Root />', () => {
})
})

describe('imperative actions', () => {
it('blocks opening when disabled imperatively and reopens when re-enabled', async () => {
const user = userEvent.setup()
render(<ContextMenuWithImperativeActions />)

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(<ContextMenuWithImperativeActions />)

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<ContextMenuDisableHandle>()

render(<ContextMenuWithInputAndImperativeActions ref={actions} />)

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<ContextMenuDisableHandle>()

render(
<ContextMenuWithInputAndImperativeActions ref={actions} dataInput />,
)

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(<BasicContextMenu defaultOpen />)
Expand Down
Loading
Loading