diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 4d6b38be6..6013794a7 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react' +import { act, render, waitFor } from '@testing-library/react' import React, { Fragment, createElement, useEffect, useState } from 'react' import { ComboboxMode, @@ -48,6 +48,7 @@ import { ComboboxInput, ComboboxOption, ComboboxOptions, + type ComboboxHandle, } from './combobox' let NOOP = () => {} @@ -5910,3 +5911,67 @@ describe('transitions', () => { }) ) }) + +describe('Imperative API', () => { + it( + 'should be possible to close the Combobox via ref.close()', + suppressConsoleLogs(async () => { + let closeHandler = jest.fn() + let comboboxRef: React.RefObject = { current: null } + + render( + + + Toggle + + Alice + Bob + + + ) + + // Open the combobox + await click(getComboboxButton()) + + // Verify it is open + assertComboboxList({ state: ComboboxState.Visible }) + + // Close via ref + expect(closeHandler).toHaveBeenCalledTimes(0) + act(() => comboboxRef.current?.close()) + expect(closeHandler).toHaveBeenCalledTimes(1) + + // Verify it is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should call onClose when calling close() on an already closed Combobox', + suppressConsoleLogs(async () => { + let closeHandler = jest.fn() + let comboboxRef: React.RefObject = { current: null } + + render( + + + Toggle + + Alice + + + ) + + // Verify it is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to close via ref - this calls onClose even if already closed + // (matching existing behavior in the machine) + act(() => comboboxRef.current?.close()) + expect(closeHandler).toHaveBeenCalledTimes(1) + + // Should still be closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index eb8b52fb1..e444fadd9 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -8,6 +8,7 @@ import React, { createContext, useCallback, useContext, + useImperativeHandle, useMemo, useRef, useState, @@ -95,6 +96,10 @@ import { useComboboxMachineContext, } from './combobox-machine-glue' +export type ComboboxHandle = { + close: () => void +} + let ComboboxDataContext = createContext<{ value: unknown defaultValue: unknown @@ -319,6 +324,8 @@ function ComboboxFn ({ close: () => machine.actions.closeCombobox() }), [machine]) + let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) type TActualValue = true extends typeof multiple ? EnsureArray[number] : TValue @@ -431,7 +438,7 @@ function ComboboxFn { if (defaultValue === undefined) return @@ -1617,7 +1624,7 @@ export interface _internal_ComponentCombobox extends HasDisplayName { TMultiple extends boolean | undefined = false, TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG, >( - props: ComboboxProps & RefProp + props: ComboboxProps & { ref?: Ref } ): React.JSX.Element }