diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index ce8a3bfc6..57d566f76 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for `role="alertdialog"` to `` component ([#2709](https://github.com/tailwindlabs/headlessui/pull/2709)) - Ensure blurring the `Combobox.Input` component closes the `Combobox` ([#2712](https://github.com/tailwindlabs/headlessui/pull/2712)) +### Added + +- Add `immediate` prop to `` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686)) + ## [1.7.17] - 2023-08-17 ### Fixed diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index bac14cf8c..30b638bab 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -4698,6 +4698,176 @@ describe('Mouse interactions', () => { }) ) + it( + 'should be possible to open the combobox by focusing the input with immediate mode enabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + }) + ) + + it( + 'should not be possible to open the combobox by focusing the input with immediate mode disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Verify it is invisible + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should not be possible to open the combobox by focusing the input with immediate mode enabled when button is disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Verify it is invisible + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to close a combobox on click with immediate mode enabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + + // Click to close + await click(getComboboxButton()) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should be possible to close a focused combobox on click with immediate mode enabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox by focusing input + await focus(getComboboxInput()) + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + + // Click to close + await click(getComboboxButton()) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertActiveElement(getComboboxInput()) + }) + ) + it( 'should be possible to open the combobox on click', suppressConsoleLogs(async () => { @@ -5358,6 +5528,35 @@ describe('Mouse interactions', () => { }) ) + it( + 'should be possible to click a combobox option, which closes the combobox with immediate mode enabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + // Open combobox by focusing input + await focus(getComboboxInput()) + assertActiveElement(getComboboxInput()) + + assertComboboxList({ state: ComboboxState.Visible }) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + it( 'should be possible to click a disabled combobox option, which is a no-op', suppressConsoleLogs(async () => { diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index ed6fec8bf..88f5b1c91 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -29,6 +29,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useTreeWalker } from '../../hooks/use-tree-walker' +import { history } from '../../utils/active-element-history' import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' @@ -68,6 +69,7 @@ enum ValueMode { enum ActivationTrigger { Pointer, + Focus, Other, } @@ -99,6 +101,8 @@ enum ActionTypes { UnregisterOption, RegisterLabel, + + SetActivationTrigger, } function adjustOrderedState( @@ -142,6 +146,7 @@ type Actions = | { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef } | { type: ActionTypes.RegisterLabel; id: string | null } | { type: ActionTypes.UnregisterOption; id: string } + | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } let reducers: { [P in ActionTypes]: ( @@ -253,6 +258,12 @@ let reducers: { labelId: action.id, } }, + [ActionTypes.SetActivationTrigger]: (state, action) => { + return { + ...state, + activationTrigger: action.trigger, + } + }, } let ComboboxActionsContext = createContext<{ @@ -264,6 +275,7 @@ let ComboboxActionsContext = createContext<{ goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void selectOption(id: string): void selectActiveOption(): void + setActivationTrigger(trigger: ActivationTrigger): void onChange(value: unknown): void } | null>(null) ComboboxActionsContext.displayName = 'ComboboxActionsContext' @@ -287,6 +299,7 @@ let ComboboxDataContext = createContext< mode: ValueMode activeOptionIndex: number | null nullable: boolean + immediate: boolean compare(a: unknown, z: unknown): boolean isSelected(value: unknown): boolean __demoMode: boolean @@ -384,6 +397,7 @@ export type ComboboxProps< __demoMode?: boolean form?: string name?: string + immediate?: boolean } function ComboboxFn( @@ -418,6 +432,7 @@ function ComboboxFn( @@ -468,6 +483,7 @@ function ComboboxFn( () => ({ ...state, + immediate, optionsPropsRef, labelRef, inputRef, @@ -621,6 +637,10 @@ function ComboboxFn { + dispatch({ type: ActionTypes.SetActivationTrigger, trigger }) + }) + let actions = useMemo<_Actions>( () => ({ onChange, @@ -629,6 +649,7 @@ function ComboboxFn { + let relatedTarget = + (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) isTyping.current = false // Focus is moved into the list, we don't want to close yet. - if (data.optionsRef.current?.contains(event.relatedTarget)) { + if (data.optionsRef.current?.contains(relatedTarget)) { return } - if (data.buttonRef.current?.contains(event.relatedTarget)) { + if (data.buttonRef.current?.contains(relatedTarget)) { return } @@ -1045,8 +1070,9 @@ function InputFn< clear() } - // We do have a value, so let's select the active option - else { + // We do have a value, so let's select the active option, unless we were just going through + // the form and we opened it due to the focus event. + else if (data.activationTrigger !== ActivationTrigger.Focus) { actions.selectActiveOption() } } @@ -1054,6 +1080,26 @@ function InputFn< return actions.closeCombobox() }) + let handleFocus = useEvent((event: ReactFocusEvent) => { + let relatedTarget = + (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) + if (data.buttonRef.current?.contains(relatedTarget)) return + if (data.optionsRef.current?.contains(relatedTarget)) return + if (data.disabled) return + + if (!data.immediate) return + if (data.comboboxState === ComboboxState.Open) return + + actions.openCombobox() + + // We need to make sure that tabbing through a form doesn't result in incorrectly setting the + // value of the combobox. We will set the activation trigger to `Focus`, and we will ignore + // selecting the active option when the user tabs away. + d.nextFrame(() => { + actions.setActivationTrigger(ActivationTrigger.Focus) + }) + }) + // TODO: Verify this. The spec says that, for the input/combobox, the label is the labelling element when present // Otherwise it's the ID of the non-label element let labelledby = useComputed(() => { @@ -1088,6 +1134,7 @@ function InputFn< onCompositionEnd: handleCompositionEnd, onKeyDown: handleKeyDown, onChange: handleChange, + onFocus: handleFocus, onBlur: handleBlur, } @@ -1172,7 +1219,7 @@ function ButtonFn( } }) - let handleClick = useEvent((event: ReactMouseEvent) => { + let handleClick = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (data.comboboxState === ComboboxState.Open) { actions.closeCombobox() @@ -1429,9 +1476,6 @@ function OptionFn< let handleClick = useEvent((event: { preventDefault: Function }) => { if (disabled) return event.preventDefault() select() - if (data.mode === ValueMode.Single) { - actions.closeCombobox() - } // We want to make sure that we don't accidentally trigger the virtual keyboard. // @@ -1446,7 +1490,11 @@ function OptionFn< // But right now this is still an experimental feature: // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/virtualKeyboard if (!isMobile()) { - requestAnimationFrame(() => data.inputRef.current?.focus()) + requestAnimationFrame(() => data.inputRef.current?.focus({ preventScroll: true })) + } + + if (data.mode === ValueMode.Single) { + requestAnimationFrame(() => actions.closeCombobox()) } }) diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index 88a2362fd..7e0ad8edf 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -23,8 +23,8 @@ import { useEventListener } from '../../hooks/use-event-listener' import { microTask } from '../../utils/micro-task' import { useWatch } from '../../hooks/use-watch' import { useDisposables } from '../../hooks/use-disposables' -import { onDocumentReady } from '../../utils/document-ready' import { useOnUnmount } from '../../hooks/use-on-unmount' +import { history } from '../../utils/active-element-history' type Containers = // Lazy resolved containers @@ -212,29 +212,6 @@ export let FocusTrap = Object.assign(FocusTrapRoot, { // --- -let history: HTMLElement[] = [] -onDocumentReady(() => { - function handle(e: Event) { - if (!(e.target instanceof HTMLElement)) return - if (e.target === document.body) return - if (history[0] === e.target) return - - history.unshift(e.target) - - // Filter out DOM Nodes that don't exist anymore - history = history.filter((x) => x != null && x.isConnected) - history.splice(10) // Only keep the 10 most recent items - } - - window.addEventListener('click', handle, { capture: true }) - window.addEventListener('mousedown', handle, { capture: true }) - window.addEventListener('focus', handle, { capture: true }) - - document.body.addEventListener('click', handle, { capture: true }) - document.body.addEventListener('mousedown', handle, { capture: true }) - document.body.addEventListener('focus', handle, { capture: true }) -}) - function useRestoreElement(enabled: boolean = true) { let localHistory = useRef(history.slice()) diff --git a/packages/@headlessui-react/src/utils/active-element-history.ts b/packages/@headlessui-react/src/utils/active-element-history.ts new file mode 100644 index 000000000..d75dbb399 --- /dev/null +++ b/packages/@headlessui-react/src/utils/active-element-history.ts @@ -0,0 +1,24 @@ +import { onDocumentReady } from './document-ready' + +export let history: HTMLElement[] = [] +onDocumentReady(() => { + function handle(e: Event) { + if (!(e.target instanceof HTMLElement)) return + if (e.target === document.body) return + if (history[0] === e.target) return + + history.unshift(e.target) + + // Filter out DOM Nodes that don't exist anymore + history = history.filter((x) => x != null && x.isConnected) + history.splice(10) // Only keep the 10 most recent items + } + + window.addEventListener('click', handle, { capture: true }) + window.addEventListener('mousedown', handle, { capture: true }) + window.addEventListener('focus', handle, { capture: true }) + + document.body.addEventListener('click', handle, { capture: true }) + document.body.addEventListener('mousedown', handle, { capture: true }) + document.body.addEventListener('focus', handle, { capture: true }) +}) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 41e97e1b6..adae643cb 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow `