diff --git a/CHANGELOG.md b/CHANGELOG.md index 44cccf4..46dedd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve controlled Tabs behaviour ([#1050](https://github.com/tailwindlabs/headlessui/pull/1050)) - Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051)) +### Added + +- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047)) + ## [Unreleased - @headlessui/vue] ### Fixed @@ -20,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure correct order when conditionally rendering `MenuItem`, `ListboxOption` and `RadioGroupOption` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045)) - Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051)) +### Added + +- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047)) + ## [@headlessui/react@v1.4.3] - 2022-01-14 ### Fixes @@ -88,7 +96,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) - Expose `close` function from the render prop for `Disclosure`, `Disclosure.Panel`, `Popover` and `Popover.Panel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697)) - ## [@headlessui/vue@v1.4.0] - 2021-07-29 ### Added diff --git a/package.json b/package.json index b4996f4..7f6b020 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ ], "scripts": { "react": "yarn workspace @headlessui/react", + "react-playground": "yarn workspace playground-react dev", + "playground-react": "yarn workspace playground-react dev", "vue": "yarn workspace @headlessui/vue", "shared": "yarn workspace @headlessui/shared", "build": "yarn workspaces run build", diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index f9b2721..a638b2d 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -26,6 +26,7 @@ "prepublishOnly": "npm run build", "test": "../../scripts/test.sh", "build": "../../scripts/build.sh", + "watch": "../../scripts/watch.sh", "lint": "../../scripts/lint.sh" }, "peerDependencies": { diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx new file mode 100644 index 0000000..60c6375 --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -0,0 +1,4917 @@ +import React, { createElement, useState, useEffect } from 'react' +import { render } from '@testing-library/react' + +import { Combobox } from './combobox' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { + click, + focus, + mouseMove, + mouseLeave, + press, + shift, + type, + word, + Keys, + MouseButton, +} from '../../test-utils/interactions' +import { + assertActiveElement, + assertActiveComboboxOption, + assertComboboxList, + assertComboboxButton, + assertComboboxButtonLinkedWithCombobox, + assertComboboxButtonLinkedWithComboboxLabel, + assertComboboxOption, + assertComboboxLabel, + assertComboboxLabelLinkedWithCombobox, + assertNoActiveComboboxOption, + assertNoSelectedComboboxOption, + getComboboxInput, + getComboboxButton, + getComboboxButtons, + getComboboxInputs, + getComboboxOptions, + getComboboxLabel, + ComboboxState, + getByText, + getComboboxes, +} from '../../test-utils/accessibility-assertions' +import { Transition } from '../transitions/transition' + +let NOOP = () => {} + +jest.mock('../../hooks/use-id') + +beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) +}) + +afterAll(() => jest.restoreAllMocks()) + +describe('safeguards', () => { + it.each([ + ['Combobox.Button', Combobox.Button], + ['Combobox.Label', Combobox.Label], + ['Combobox.Options', Combobox.Options], + ['Combobox.Option', Combobox.Option], + ])( + 'should error when we are using a <%s /> without a parent ', + suppressConsoleLogs((name, Component) => { + // @ts-expect-error This is fine + expect(() => render(createElement(Component))).toThrowError( + `<${name} /> is missing a parent component.` + ) + }) + ) + + it( + 'should be possible to render a Combobox without crashing', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) +}) + +describe('Rendering', () => { + describe('Combobox', () => { + it( + 'should be possible to render a Combobox using a render prop', + suppressConsoleLogs(async () => { + render( + + {({ open }) => ( + <> + + Trigger + {open && ( + + Option A + Option B + Option C + + )} + + )} + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to disable a Combobox', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await press(Keys.Enter, getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + }) + + describe('Combobox.Input', () => { + it( + 'selecting an option puts the value into Combobox.Input when displayValue is not provided', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + + + Trigger + + Option A + Option B + Option C + + + ) + } + + render() + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('b') + }) + ) + + it( + 'selecting an option puts the display value into Combobox.Input when displayValue is provided', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + + str?.toUpperCase() ?? ''} + /> + Trigger + + Option A + Option B + Option C + + + ) + } + + render() + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('B') + }) + ) + }) + + describe('Combobox.Label', () => { + it( + 'should be possible to render a Combobox.Label using a render prop', + suppressConsoleLogs(async () => { + render( + + {JSON.stringify} + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxLabelLinkedWithCombobox() + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + it( + 'should be possible to render a Combobox.Label using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + render( + + {JSON.stringify} + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + }) + + describe('Combobox.Button', () => { + it( + 'should be possible to render a Combobox.Button using a render prop', + suppressConsoleLogs(async () => { + render( + + + {JSON.stringify} + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a Combobox.Button using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + render( + + + + {JSON.stringify} + + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together', + suppressConsoleLogs(async () => { + render( + + Label + + Trigger + + Option A + Option B + Option C + + + ) + + // TODO: Needed to make it similar to vue test implementation? + // await new Promise(requestAnimationFrame) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + describe('`type` attribute', () => { + it('should set the `type` to "button" by default', async () => { + render( + + + Trigger + + ) + + expect(getComboboxButton()).toHaveAttribute('type', 'button') + }) + + it('should not set the `type` to "button" if it already contains a `type`', async () => { + render( + + + Trigger + + ) + + expect(getComboboxButton()).toHaveAttribute('type', 'submit') + }) + + it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => { + let CustomButton = React.forwardRef((props, ref) => ( + + + ) + + // Click the combobox button + await click(getComboboxButton()) + + // Ensure the combobox is open + assertComboboxList({ state: ComboboxState.Visible }) + + // Click the span inside the button + await click(getByText('Next')) + + // Ensure the combobox is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Ensure the outside button is focused + assertActiveElement(document.getElementById('btn')) + + // Ensure that the focus button only got focus once (first click) + expect(focusFn).toHaveBeenCalledTimes(1) + }) + ) + + it( + 'should be possible to hover an option and make it active', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should make a combobox option active when you move the mouse over it', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is already active', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseMove(options[1]) + + // Nothing should be changed + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + await mouseMove(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to hover an option that is disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + + // We should not have an active option now + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave an option and make it inactive', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + await mouseLeave(options[0]) + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + + await mouseLeave(options[2]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave a disabled option and be a no-op', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + assertNoActiveComboboxOption() + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to click a combobox option, which closes the combobox', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + function Example() { + let [value, setValue] = useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + + Trigger + + alice + bob + charlie + + + ) + } + + render() + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('bob') + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[1]) + }) + ) + + it( + 'should be possible to click a disabled combobox option, which is a no-op', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + function Example() { + let [value, setValue] = useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + + Trigger + + alice + + bob + + charlie + + + ) + } + + render() + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + expect(handleChange).toHaveBeenCalledTimes(0) + + // Close the combobox + await click(getComboboxButton()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is non existing + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible focus a combobox option, so that it becomes active', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + + + Trigger + + alice + bob + charlie + + + ) + } + + render() + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // Verify that nothing is active yet + assertNoActiveComboboxOption() + + // We should be able to focus the first option + await focus(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should not be possible to focus a combobox option which is disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should not be able to focus the first option + await focus(options[1]) + assertNoActiveComboboxOption() + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx new file mode 100644 index 0000000..d0f327b --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -0,0 +1,924 @@ +import React, { + Fragment, + createContext, + createRef, + useCallback, + useContext, + useMemo, + useReducer, + useRef, + + // Types + Dispatch, + ElementType, + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + MutableRefObject, + Ref, + ContextType, +} from 'react' + +import { useDisposables } from '../../hooks/use-disposables' +import { useId } from '../../hooks/use-id' +import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' +import { useComputed } from '../../hooks/use-computed' +import { useSyncRefs } from '../../hooks/use-sync-refs' +import { Props } from '../../types' +import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render' +import { match } from '../../utils/match' +import { disposables } from '../../utils/disposables' +import { Keys } from '../keyboard' +import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { isDisabledReactIssue7711 } from '../../utils/bugs' +import { isFocusableElement, FocusableMode } from '../../utils/focus-management' +import { useWindowEvent } from '../../hooks/use-window-event' +import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' +import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { useLatestValue } from '../../hooks/use-latest-value' + +enum ComboboxStates { + Open, + Closed, +} + +type ComboboxOptionDataRef = MutableRefObject<{ + textValue?: string + disabled: boolean + value: unknown +}> + +interface StateDefinition { + comboboxState: ComboboxStates + + orientation: 'horizontal' | 'vertical' + + propsRef: MutableRefObject<{ + value: unknown + onChange(value: unknown): void + }> + inputPropsRef: MutableRefObject<{ + displayValue?(item: unknown): string + }> + labelRef: MutableRefObject + inputRef: MutableRefObject + buttonRef: MutableRefObject + optionsRef: MutableRefObject + + disabled: boolean + options: { id: string; dataRef: ComboboxOptionDataRef }[] + activeOptionIndex: number | null +} + +enum ActionTypes { + OpenCombobox, + CloseCombobox, + + SetDisabled, + SetOrientation, + + GoToOption, + + RegisterOption, + UnregisterOption, +} + +type Actions = + | { type: ActionTypes.CloseCombobox } + | { type: ActionTypes.OpenCombobox } + | { type: ActionTypes.SetDisabled; disabled: boolean } + | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] } + | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string } + | { type: ActionTypes.GoToOption; focus: Exclude } + | { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef } + | { type: ActionTypes.UnregisterOption; id: string } + +let reducers: { + [P in ActionTypes]: ( + state: StateDefinition, + action: Extract + ) => StateDefinition +} = { + [ActionTypes.CloseCombobox](state) { + if (state.disabled) return state + if (state.comboboxState === ComboboxStates.Closed) return state + return { ...state, activeOptionIndex: null, comboboxState: ComboboxStates.Closed } + }, + [ActionTypes.OpenCombobox](state) { + if (state.disabled) return state + if (state.comboboxState === ComboboxStates.Open) return state + return { ...state, comboboxState: ComboboxStates.Open } + }, + [ActionTypes.SetDisabled](state, action) { + if (state.disabled === action.disabled) return state + return { ...state, disabled: action.disabled } + }, + [ActionTypes.SetOrientation](state, action) { + if (state.orientation === action.orientation) return state + return { ...state, orientation: action.orientation } + }, + [ActionTypes.GoToOption](state, action) { + if (state.disabled) return state + if (state.comboboxState === ComboboxStates.Closed) return state + + let activeOptionIndex = calculateActiveIndex(action, { + resolveItems: () => state.options, + resolveActiveIndex: () => state.activeOptionIndex, + resolveId: item => item.id, + resolveDisabled: item => item.dataRef.current.disabled, + }) + + if (state.activeOptionIndex === activeOptionIndex) return state + return { ...state, activeOptionIndex } + }, + [ActionTypes.RegisterOption]: (state, action) => { + let currentActiveOption = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null + + let orderMap = Array.from( + state.optionsRef.current?.querySelectorAll('[id^="headlessui-combobox-option-"]')! + ).reduce( + (lookup, element, index) => Object.assign(lookup, { [element.id]: index }), + {} + ) as Record + + let options = [...state.options, { id: action.id, dataRef: action.dataRef }].sort( + (a, z) => orderMap[a.id] - orderMap[z.id] + ) + + return { + ...state, + options, + activeOptionIndex: (() => { + if (currentActiveOption === null) return null + + // If we inserted an option before the current active option then the + // active option index would be wrong. To fix this, we will re-lookup + // the correct index. + return options.indexOf(currentActiveOption) + })(), + } + }, + [ActionTypes.UnregisterOption]: (state, action) => { + let nextOptions = state.options.slice() + let currentActiveOption = + state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null + + let idx = nextOptions.findIndex(a => a.id === action.id) + + if (idx !== -1) nextOptions.splice(idx, 1) + + return { + ...state, + options: nextOptions, + activeOptionIndex: (() => { + if (idx === state.activeOptionIndex) return null + if (currentActiveOption === null) return null + + // If we removed the option before the actual active index, then it would be out of sync. To + // fix this, we will find the correct (new) index position. + return nextOptions.indexOf(currentActiveOption) + })(), + } + }, +} + +let ComboboxContext = createContext<[StateDefinition, Dispatch] | null>(null) +ComboboxContext.displayName = 'ComboboxContext' + +function useComboboxContext(component: string) { + let context = useContext(ComboboxContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent <${Combobox.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext) + throw err + } + return context +} + +let ComboboxActions = createContext<{ + selectOption(id: string): void + selectActiveOption(): void +} | null>(null) +ComboboxActions.displayName = 'ComboboxActions' + +function useComboboxActions() { + let context = useContext(ComboboxActions) + if (context === null) { + let err = new Error(`ComboboxActions is missing a parent <${Combobox.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxActions) + throw err + } + return context +} + +function stateReducer(state: StateDefinition, action: Actions) { + return match(action.type, reducers, state, action) +} + +// --- + +let DEFAULT_COMBOBOX_TAG = Fragment +interface ComboboxRenderPropArg { + open: boolean + disabled: boolean + activeIndex: number | null + activeOption: T | null +} + +export function Combobox( + props: Props< + TTag, + ComboboxRenderPropArg, + 'value' | 'onChange' | 'disabled' | 'horizontal' + > & { + value: TType + onChange(value: TType): void + disabled?: boolean + horizontal?: boolean + } +) { + let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props + const orientation = horizontal ? 'horizontal' : 'vertical' + + let reducerBag = useReducer(stateReducer, { + comboboxState: ComboboxStates.Closed, + propsRef: { + current: { + value, + onChange, + }, + }, + inputPropsRef: { + current: { + displayValue: undefined, + }, + }, + labelRef: createRef(), + inputRef: createRef(), + buttonRef: createRef(), + optionsRef: createRef(), + disabled, + orientation, + options: [], + activeOptionIndex: null, + } as StateDefinition) + let [ + { + comboboxState, + options, + activeOptionIndex, + propsRef, + inputPropsRef, + optionsRef, + inputRef, + buttonRef, + }, + dispatch, + ] = reducerBag + + useIsoMorphicEffect(() => { + propsRef.current.value = value + }, [value, propsRef]) + useIsoMorphicEffect(() => { + propsRef.current.onChange = onChange + }, [onChange, propsRef]) + + useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled]) + useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [ + orientation, + ]) + + // Handle outside click + useWindowEvent('mousedown', event => { + let target = event.target as HTMLElement + + if (comboboxState !== ComboboxStates.Open) return + + if (buttonRef.current?.contains(target)) return + if (inputRef.current?.contains(target)) return + if (optionsRef.current?.contains(target)) return + + dispatch({ type: ActionTypes.CloseCombobox }) + + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + inputRef.current?.focus() + } + }) + + let slot = useMemo>( + () => ({ + open: comboboxState === ComboboxStates.Open, + disabled, + activeIndex: activeOptionIndex, + activeOption: + activeOptionIndex === null + ? null + : (options[activeOptionIndex].dataRef.current.value as TType), + }), + [comboboxState, disabled, options, activeOptionIndex] + ) + + let syncInputValue = useCallback(() => { + if (!inputRef.current) return + if (value === undefined) return + let displayValue = inputPropsRef.current.displayValue + + if (typeof displayValue === 'function') { + inputRef.current.value = displayValue(value) + } else if (typeof value === 'string') { + inputRef.current.value = value + } + }, [value, inputRef, inputPropsRef]) + + let selectOption = useCallback( + (id: string) => { + let option = options.find(item => item.id === id) + if (!option) return + + let { dataRef } = option + propsRef.current.onChange(dataRef.current.value) + syncInputValue() + }, + [options, propsRef, inputRef] + ) + + let selectActiveOption = useCallback(() => { + if (activeOptionIndex !== null) { + let { dataRef } = options[activeOptionIndex] + propsRef.current.onChange(dataRef.current.value) + syncInputValue() + } + }, [activeOptionIndex, options, propsRef, inputRef]) + + let actionsBag = useMemo>( + () => ({ selectOption, selectActiveOption }), + [selectOption, selectActiveOption] + ) + + useIsoMorphicEffect(() => { + if (comboboxState !== ComboboxStates.Closed) { + return + } + syncInputValue() + }, [syncInputValue, comboboxState]) + + // Ensure that we update the inputRef if the value changes + useIsoMorphicEffect(syncInputValue, [syncInputValue]) + + return ( + + + + {render({ + props: passThroughProps, + slot, + defaultTag: DEFAULT_COMBOBOX_TAG, + name: 'Combobox', + })} + + + + ) +} + +// --- + +let DEFAULT_INPUT_TAG = 'input' as const +interface InputRenderPropArg { + open: boolean + disabled: boolean +} +type InputPropsWeControl = + | 'id' + | 'role' + | 'type' + | 'aria-labelledby' + | 'aria-expanded' + | 'aria-activedescendant' + | 'onKeyDown' + | 'onChange' + | 'displayValue' + +let Input = forwardRefWithAs(function Input< + TTag extends ElementType = typeof DEFAULT_INPUT_TAG, + // TODO: One day we will be able to infer this type from the generic in Combobox itself. + // But today is not that day.. + TType = Parameters[0]['value'] +>( + props: Props & { + displayValue?(item: TType): string + onChange(event: React.ChangeEvent): void + }, + ref: Ref +) { + let { value, onChange, displayValue, ...passThroughProps } = props + let [state, dispatch] = useComboboxContext([Combobox.name, Input.name].join('.')) + let actions = useComboboxActions() + + let inputRef = useSyncRefs(state.inputRef, ref) + let inputPropsRef = state.inputPropsRef + + let id = `headlessui-combobox-input-${useId()}` + let d = useDisposables() + + let onChangeRef = useLatestValue(onChange) + + useIsoMorphicEffect(() => { + inputPropsRef.current.displayValue = displayValue + }, [displayValue, inputPropsRef]) + + let handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + + actions.selectActiveOption() + dispatch({ type: ActionTypes.CloseCombobox }) + break + + case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): + event.preventDefault() + event.stopPropagation() + return match(state.comboboxState, { + [ComboboxStates.Open]: () => { + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next }) + }, + [ComboboxStates.Closed]: () => { + dispatch({ type: ActionTypes.OpenCombobox }) + // TODO: We can't do this outside next frame because the options aren't rendered yet + // But doing this in next frame results in a flicker because the dom mutations are async here + // Basically: + // Sync -> no option list yet + // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element + + // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + } + }) + }, + }) + + case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + return match(state.comboboxState, { + [ComboboxStates.Open]: () => { + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous }) + }, + [ComboboxStates.Closed]: () => { + dispatch({ type: ActionTypes.OpenCombobox }) + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + } + }) + }, + }) + + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.CloseCombobox }) + + case Keys.Tab: + actions.selectActiveOption() + dispatch({ type: ActionTypes.CloseCombobox }) + break + } + }, + [d, dispatch, state, actions] + ) + + let handleChange = useCallback( + (event: React.ChangeEvent) => { + dispatch({ type: ActionTypes.OpenCombobox }) + onChangeRef.current(event) + }, + [dispatch, onChangeRef] + ) + + // TODO: Verify this. The spec says that, for the input/combobox, the lebel is the labelling element when present + // Otherwise it's the ID of the non-label element + let labelledby = useComputed(() => { + if (!state.labelRef.current) return undefined + return [state.labelRef.current.id].join(' ') + }, [state.labelRef.current]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), + [state] + ) + + let propsWeControl = { + ref: inputRef, + id, + role: 'combobox', + type: 'text', + 'aria-controls': state.optionsRef.current?.id, + 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open, + 'aria-activedescendant': + state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, + 'aria-labelledby': labelledby, + disabled: state.disabled, + onKeyDown: handleKeyDown, + onChange: handleChange, + } + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_INPUT_TAG, + name: 'Combobox.Input', + }) +}) + +// --- + +let DEFAULT_BUTTON_TAG = 'button' as const +interface ButtonRenderPropArg { + open: boolean + disabled: boolean +} +type ButtonPropsWeControl = + | 'id' + | 'type' + | 'tabIndex' + | 'aria-haspopup' + | 'aria-controls' + | 'aria-expanded' + | 'aria-labelledby' + | 'disabled' + | 'onClick' + | 'onKeyDown' + +let Button = forwardRefWithAs(function Button( + props: Props, + ref: Ref +) { + let [state, dispatch] = useComboboxContext([Combobox.name, Button.name].join('.')) + let actions = useComboboxActions() + let buttonRef = useSyncRefs(state.buttonRef, ref) + + let id = `headlessui-combobox-button-${useId()}` + let d = useDisposables() + + let handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): + event.preventDefault() + event.stopPropagation() + if (state.comboboxState === ComboboxStates.Closed) { + dispatch({ type: ActionTypes.OpenCombobox }) + // TODO: We can't do this outside next frame because the options aren't rendered yet + // But doing this in next frame results in a flicker because the dom mutations are async here + // Basically: + // Sync -> no option list yet + // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element + + // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + } + }) + } + return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + + case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + if (state.comboboxState === ComboboxStates.Closed) { + dispatch({ type: ActionTypes.OpenCombobox }) + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + } + }) + } + return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.CloseCombobox }) + return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + } + }, + [d, dispatch, state, actions] + ) + + let handleClick = useCallback( + (event: ReactMouseEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + if (state.comboboxState === ComboboxStates.Open) { + dispatch({ type: ActionTypes.CloseCombobox }) + } else { + event.preventDefault() + dispatch({ type: ActionTypes.OpenCombobox }) + } + + d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + }, + [dispatch, d, state] + ) + + let labelledby = useComputed(() => { + if (!state.labelRef.current) return undefined + return [state.labelRef.current.id, id].join(' ') + }, [state.labelRef.current, id]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), + [state] + ) + let passthroughProps = props + let propsWeControl = { + ref: buttonRef, + id, + type: useResolveButtonType(props, state.buttonRef), + tabIndex: -1, + 'aria-haspopup': true, + 'aria-controls': state.optionsRef.current?.id, + 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open, + 'aria-labelledby': labelledby, + disabled: state.disabled, + onClick: handleClick, + onKeyDown: handleKeyDown, + } + + return render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_BUTTON_TAG, + name: 'Combobox.Button', + }) +}) + +// --- + +let DEFAULT_LABEL_TAG = 'label' as const +interface LabelRenderPropArg { + open: boolean + disabled: boolean +} +type LabelPropsWeControl = 'id' | 'ref' | 'onClick' + +function Label( + props: Props +) { + let [state] = useComboboxContext([Combobox.name, Label.name].join('.')) + let id = `headlessui-combobox-label-${useId()}` + + let handleClick = useCallback(() => state.inputRef.current?.focus({ preventScroll: true }), [ + state.inputRef, + ]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), + [state] + ) + let propsWeControl = { ref: state.labelRef, id, onClick: handleClick } + return render({ + props: { ...props, ...propsWeControl }, + slot, + defaultTag: DEFAULT_LABEL_TAG, + name: 'Combobox.Label', + }) +} + +// --- + +let DEFAULT_OPTIONS_TAG = 'ul' as const +interface OptionsRenderPropArg { + open: boolean +} +type OptionsPropsWeControl = + | 'aria-activedescendant' + | 'aria-labelledby' + | 'aria-orientation' + | 'id' + | 'onKeyDown' + | 'role' + | 'tabIndex' + +let OptionsRenderFeatures = Features.RenderStrategy | Features.Static + +let Options = forwardRefWithAs(function Options< + TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG +>( + props: Props & + PropsForFeatures, + ref: Ref +) { + let [state, dispatch] = useComboboxContext([Combobox.name, Options.name].join('.')) + let optionsRef = useSyncRefs(state.optionsRef, ref) + + let id = `headlessui-combobox-options-${useId()}` + + let usesOpenClosedState = useOpenClosed() + let visible = (() => { + if (usesOpenClosedState !== null) { + return usesOpenClosedState === State.Open + } + + return state.comboboxState === ComboboxStates.Open + })() + + let labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [ + state.labelRef.current, + state.buttonRef.current, + ]) + + let handleLeave = useCallback(() => { + if (state.comboboxState !== ComboboxStates.Open) return + if (state.activeOptionIndex === null) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + }, [state, dispatch]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open }), + [state] + ) + let propsWeControl = { + 'aria-activedescendant': + state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, + 'aria-labelledby': labelledby, + 'aria-orientation': state.orientation, + role: 'listbox', + id, + ref: optionsRef, + onPointerLeave: handleLeave, + onMouseLeave: handleLeave, + } + let passthroughProps = props + + return render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_OPTIONS_TAG, + features: OptionsRenderFeatures, + visible, + name: 'Combobox.Options', + }) +}) + +// --- + +let DEFAULT_OPTION_TAG = 'li' as const +interface OptionRenderPropArg { + active: boolean + selected: boolean + disabled: boolean +} +type ComboboxOptionPropsWeControl = + | 'id' + | 'role' + | 'tabIndex' + | 'aria-disabled' + | 'aria-selected' + | 'onPointerLeave' + | 'onMouseLeave' + | 'onPointerMove' + | 'onMouseMove' + +function Option< + TTag extends ElementType = typeof DEFAULT_OPTION_TAG, + // TODO: One day we will be able to infer this type from the generic in Combobox itself. + // But today is not that day.. + TType = Parameters[0]['value'] +>( + props: Props & { + disabled?: boolean + value: TType + } +) { + let { disabled = false, value, ...passthroughProps } = props + let [state, dispatch] = useComboboxContext([Combobox.name, Option.name].join('.')) + let actions = useComboboxActions() + let id = `headlessui-combobox-option-${useId()}` + let active = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false + let selected = state.propsRef.current.value === value + let bag = useRef({ disabled, value }) + + useIsoMorphicEffect(() => { + bag.current.disabled = disabled + }, [bag, disabled]) + useIsoMorphicEffect(() => { + bag.current.value = value + }, [bag, value]) + useIsoMorphicEffect(() => { + bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase() + }, [bag, id]) + + let select = useCallback(() => actions.selectOption(id), [actions, id]) + + useIsoMorphicEffect(() => { + dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag }) + return () => dispatch({ type: ActionTypes.UnregisterOption, id }) + }, [bag, id]) + + useIsoMorphicEffect(() => { + if (state.comboboxState !== ComboboxStates.Open) return + if (!selected) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [state.comboboxState]) + + useIsoMorphicEffect(() => { + if (state.comboboxState !== ComboboxStates.Open) return + if (!active) return + let d = disposables() + d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + return d.dispose + }, [id, active, state.comboboxState]) + + let handleClick = useCallback( + (event: { preventDefault: Function }) => { + if (disabled) return event.preventDefault() + select() + dispatch({ type: ActionTypes.CloseCombobox }) + disposables().nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + }, + [dispatch, state.inputRef, disabled, select] + ) + + let handleFocus = useCallback(() => { + if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [disabled, id, dispatch]) + + let handleMove = useCallback(() => { + if (disabled) return + if (active) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [disabled, active, id, dispatch]) + + let handleLeave = useCallback(() => { + if (disabled) return + if (!active) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + }, [disabled, active, dispatch]) + + let slot = useMemo(() => ({ active, selected, disabled }), [ + active, + selected, + disabled, + ]) + + let propsWeControl = { + id, + role: 'option', + tabIndex: disabled === true ? undefined : -1, + 'aria-disabled': disabled === true ? true : undefined, + 'aria-selected': selected === true ? true : undefined, + disabled: undefined, // Never forward the `disabled` prop + onClick: handleClick, + onFocus: handleFocus, + onPointerMove: handleMove, + onMouseMove: handleMove, + onPointerLeave: handleLeave, + onMouseLeave: handleLeave, + } + + return render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_OPTION_TAG, + name: 'Combobox.Option', + }) +} + +// --- + +Combobox.Input = Input +Combobox.Button = Button +Combobox.Label = Label +Combobox.Options = Options +Combobox.Option = Option diff --git a/packages/@headlessui-react/src/hooks/use-computed.ts b/packages/@headlessui-react/src/hooks/use-computed.ts index 980ef9b..3d41644 100644 --- a/packages/@headlessui-react/src/hooks/use-computed.ts +++ b/packages/@headlessui-react/src/hooks/use-computed.ts @@ -1,12 +1,10 @@ -import { useState, useRef } from 'react' +import { useState } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' +import { useLatestValue } from './use-latest-value' export function useComputed(cb: () => T, dependencies: React.DependencyList) { let [value, setValue] = useState(cb) - let cbRef = useRef(cb) - useIsoMorphicEffect(() => { - cbRef.current = cb - }, [cb]) + let cbRef = useLatestValue(cb) useIsoMorphicEffect(() => setValue(cbRef.current), [cbRef, setValue, ...dependencies]) return value } diff --git a/packages/@headlessui-react/src/hooks/use-latest-value.ts b/packages/@headlessui-react/src/hooks/use-latest-value.ts new file mode 100644 index 0000000..6795e7e --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-latest-value.ts @@ -0,0 +1,11 @@ +import { useRef, useEffect } from 'react' + +export function useLatestValue(value: T) { + let cache = useRef(value) + + useEffect(() => { + cache.current = value + }, [value]) + + return cache +} diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts index 145b355..1058a5e 100644 --- a/packages/@headlessui-react/src/index.test.ts +++ b/packages/@headlessui-react/src/index.test.ts @@ -6,6 +6,7 @@ import * as HeadlessUI from './index' */ it('should expose the correct components', () => { expect(Object.keys(HeadlessUI)).toEqual([ + 'Combobox', 'Dialog', 'Disclosure', 'FocusTrap', diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts index 2ef29db..2ba6c32 100644 --- a/packages/@headlessui-react/src/index.ts +++ b/packages/@headlessui-react/src/index.ts @@ -1,3 +1,4 @@ +export * from './components/combobox/combobox' export * from './components/dialog/dialog' export * from './components/disclosure/disclosure' export * from './components/focus-trap/focus-trap' diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index 1fb40a1..f478642 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -91,7 +91,7 @@ export function assertMenuButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertMenuButton) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton) throw err } } @@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id')) expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) throw err } } @@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge // Ensure link between menu & menu item is correct expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) throw err } } @@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) { // Ensure we don't have an active menu expect(menu).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem) throw err } } @@ -183,7 +183,7 @@ export function assertMenu( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenu) throw err } } @@ -214,7 +214,393 @@ export function assertMenuItem( } } } catch (err) { - Error.captureStackTrace(err, assertMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem) + throw err + } +} + +// --- + +export function getComboboxLabel(): HTMLElement | null { + return document.querySelector('label,[id^="headlessui-combobox-label"]') +} + +export function getComboboxButton(): HTMLElement | null { + return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]') +} + +export function getComboboxButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getComboboxInput(): HTMLInputElement | null { + return document.querySelector('[role="combobox"]') +} + +export function getCombobox(): HTMLElement | null { + return document.querySelector('[role="listbox"]') +} + +export function getComboboxInputs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="combobox"]')) +} + +export function getComboboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="listbox"]')) +} + +export function getComboboxOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="option"]')) +} + +// --- + +export enum ComboboxState { + /** The combobox is visible to the user. */ + Visible, + + /** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, +} + +export function assertCombobox( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + combobox = getComboboxInput() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertHidden(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertVisible(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(combobox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxList( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + listbox = getCombobox() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertHidden(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertVisible(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(listbox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxButton( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + }, + button = getComboboxButton() +) { + try { + if (button === null) return expect(button).not.toBe(null) + + // Ensure menu button have these properties + expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute('aria-haspopup') + + switch (options.state) { + case ComboboxState.Visible: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break + + case ComboboxState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case ComboboxState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + default: + assertNever(options.state) + } + + if (options.textContent) { + expect(button).toHaveTextContent(options.textContent) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton) + throw err + } +} + +export function assertComboboxLabel( + options: { + attributes?: Record + tag?: string + textContent?: string + }, + label = getComboboxLabel() +) { + try { + if (label === null) return expect(label).not.toBe(null) + + // Ensure menu button have these properties + expect(label).toHaveAttribute('id') + + if (options.textContent) { + expect(label).toHaveTextContent(options.textContent) + } + + if (options.tag) { + expect(label.tagName.toLowerCase()).toBe(options.tag) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel) + throw err + } +} + +export function assertComboboxButtonLinkedWithCombobox( + button = getComboboxButton(), + combobox = getCombobox() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure link between button & combobox is correct + expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id')) + expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox) + throw err + } +} + +export function assertComboboxLabelLinkedWithCombobox( + label = getComboboxLabel(), + combobox = getComboboxInput() +) { + try { + if (label === null) return expect(label).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox) + throw err + } +} + +export function assertComboboxButtonLinkedWithComboboxLabel( + button = getComboboxButton(), + label = getComboboxLabel() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (label === null) return expect(label).not.toBe(null) + + // Ensure link between button & label is correct + expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) + } catch (err) { + if (err instanceof Error) + Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel) + throw err + } +} + +export function assertActiveComboboxOption( + item: HTMLElement | null, + combobox = getComboboxInput() +) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + if (item === null) return expect(item).not.toBe(null) + + // Ensure link between combobox & combobox item is correct + expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption) + throw err + } +} + +export function assertNoActiveComboboxOption(combobox = getComboboxInput()) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure we don't have an active combobox + expect(combobox).not.toHaveAttribute('aria-activedescendant') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption) + throw err + } +} + +export function assertNoSelectedComboboxOption(items = getComboboxOptions()) { + try { + for (let item of items) expect(item).not.toHaveAttribute('aria-selected') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption) + throw err + } +} + +export function assertComboboxOption( + item: HTMLElement | null, + options?: { + tag?: string + attributes?: Record + selected?: boolean + } +) { + try { + if (item === null) return expect(item).not.toBe(null) + + // Check that some attributes exists, doesn't really matter what the values are at this point in + // time, we just require them. + expect(item).toHaveAttribute('id') + + // Check that we have the correct values for certain attributes + expect(item).toHaveAttribute('role', 'option') + if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1') + + // Ensure combobox button has the following attributes + if (!options) return + + for (let attributeName in options.attributes) { + expect(item).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + + if (options.tag) { + expect(item.tagName.toLowerCase()).toBe(options.tag) + } + + if (options.selected != null) { + switch (options.selected) { + case true: + return expect(item).toHaveAttribute('aria-selected', 'true') + + case false: + return expect(item).not.toHaveAttribute('aria-selected') + + default: + assertNever(options.selected) + } + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption) throw err } } @@ -311,7 +697,7 @@ export function assertListbox( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListbox) throw err } } @@ -368,7 +754,7 @@ export function assertListboxButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxButton) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton) throw err } } @@ -400,7 +786,7 @@ export function assertListboxLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel) throw err } } @@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox( expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id')) expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) throw err } } @@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox( expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) throw err } } @@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel( // Ensure link between button & label is correct expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) + if (err instanceof Error) + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) throw err } } @@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge // Ensure link between listbox & listbox item is correct expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption) throw err } } @@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) { // Ensure we don't have an active listbox expect(listbox).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption) throw err } } @@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) { try { for (let item of items) expect(item).not.toHaveAttribute('aria-selected') } catch (err) { - Error.captureStackTrace(err, assertNoSelectedListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption) throw err } } @@ -530,7 +917,7 @@ export function assertListboxOption( } } } catch (err) { - Error.captureStackTrace(err, assertListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption) throw err } } @@ -597,7 +984,7 @@ export function assertSwitch( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertSwitch) + if (err instanceof Error) Error.captureStackTrace(err, assertSwitch) throw err } } @@ -678,7 +1065,7 @@ export function assertDisclosureButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertDisclosureButton) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton) throw err } } @@ -725,7 +1112,7 @@ export function assertDisclosurePanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDisclosurePanel) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel) throw err } } @@ -810,7 +1197,7 @@ export function assertPopoverButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertPopoverButton) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton) throw err } } @@ -857,7 +1244,7 @@ export function assertPopoverPanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertPopoverPanel) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel) throw err } } @@ -984,7 +1371,7 @@ export function assertDialog( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialog) + if (err instanceof Error) Error.captureStackTrace(err, assertDialog) throw err } } @@ -1040,7 +1427,7 @@ export function assertDialogTitle( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogTitle) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle) throw err } } @@ -1096,7 +1483,7 @@ export function assertDialogDescription( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogDescription) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription) throw err } } @@ -1143,7 +1530,7 @@ export function assertDialogOverlay( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogOverlay) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay) throw err } } @@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertRadioGroupLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel) throw err } } @@ -1267,7 +1654,7 @@ export function assertTabs( } } } catch (err) { - Error.captureStackTrace(err, assertTabs) + if (err instanceof Error) Error.captureStackTrace(err, assertTabs) throw err } } @@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) { expect(document.activeElement?.outerHTML).toBe(element.outerHTML) } } catch (err) { - Error.captureStackTrace(err, assertActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement) throw err } } @@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) { if (element === null) return expect(element).not.toBe(null) expect(element.contains(document.activeElement)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertContainsActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement) throw err } } @@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) { expect(element).toHaveAttribute('hidden') expect(element).toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertHidden) + if (err instanceof Error) Error.captureStackTrace(err, assertHidden) throw err } } @@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) { expect(element).not.toHaveAttribute('hidden') expect(element).not.toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertVisible) + if (err instanceof Error) Error.captureStackTrace(err, assertVisible) throw err } } @@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertFocusable) throw err } } @@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false) } catch (err) { - Error.captureStackTrace(err, assertNotFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable) throw err } } diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index d78f9b6..b65ece9 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -1,4 +1,7 @@ import { fireEvent } from '@testing-library/react' +import { disposables } from '../utils/disposables' + +let d = disposables() function nextFrame(cb: Function): void { setImmediate(() => @@ -33,7 +36,19 @@ export function shift(event: Partial) { } export function word(input: string): Partial[] { - return input.split('').map(key => ({ key })) + let result = input.split('').map(key => ({ key })) + + d.enqueue(() => { + let element = document.activeElement + + if (element instanceof HTMLInputElement) { + fireEvent.change(element, { + target: Object.assign({}, element, { value: input }), + }) + } + }) + + return result } let Default = Symbol() @@ -76,6 +91,9 @@ let order: Record< function keypress(element, event) { return fireEvent.keyPress(element, event) }, + function input(element, event) { + return fireEvent.input(element, event) + }, function keyup(element, event) { return fireEvent.keyUp(element, event) }, @@ -159,9 +177,11 @@ export async function type(events: Partial[], element = document. // We don't want to actually wait in our tests, so let's advance jest.runAllTimers() + await d.workQueue() + await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, type) + if (err instanceof Error) Error.captureStackTrace(err, type) throw err } finally { jest.useRealTimers() @@ -224,7 +244,7 @@ export async function click( await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, click) + if (err instanceof Error) Error.captureStackTrace(err, click) throw err } } @@ -237,7 +257,7 @@ export async function focus(element: Document | Element | Window | Node | null) await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, focus) + if (err instanceof Error) Error.captureStackTrace(err, focus) throw err } } @@ -251,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseEnter) + if (err instanceof Error) Error.captureStackTrace(err, mouseEnter) throw err } } @@ -265,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseMove) + if (err instanceof Error) Error.captureStackTrace(err, mouseMove) throw err } } @@ -281,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseLeave) + if (err instanceof Error) Error.captureStackTrace(err, mouseLeave) throw err } } diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts index 7c9a388..4c0f89c 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -1,7 +1,12 @@ export function disposables() { let disposables: Function[] = [] + let queue: Function[] = [] let api = { + enqueue(fn: Function) { + queue.push(fn) + }, + requestAnimationFrame(...args: Parameters) { let raf = requestAnimationFrame(...args) api.add(() => cancelAnimationFrame(raf)) @@ -27,6 +32,12 @@ export function disposables() { dispose() } }, + + async workQueue() { + for (let handle of queue.splice(0)) { + await handle() + } + }, } return api diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx new file mode 100644 index 0000000..fbe2047 --- /dev/null +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx @@ -0,0 +1,5334 @@ +import { + DefineComponent, + defineComponent, + nextTick, + ref, + watch, + h, + reactive, + computed, + PropType, +} from 'vue' +import { render } from '../../test-utils/vue-testing-library' +import { + Combobox, + ComboboxInput, + ComboboxLabel, + ComboboxButton, + ComboboxOptions, + ComboboxOption, +} from './combobox' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { + click, + focus, + mouseMove, + mouseLeave, + press, + shift, + type, + word, + Keys, + MouseButton, +} from '../../test-utils/interactions' +import { + assertActiveElement, + assertActiveComboboxOption, + assertComboboxList, + assertComboboxButton, + assertComboboxButtonLinkedWithCombobox, + assertComboboxButtonLinkedWithComboboxLabel, + assertComboboxOption, + assertComboboxLabel, + assertComboboxLabelLinkedWithCombobox, + assertNoActiveComboboxOption, + assertNoSelectedComboboxOption, + getComboboxInput, + getComboboxButton, + getComboboxButtons, + getComboboxInputs, + getComboboxOptions, + getComboboxLabel, + ComboboxState, + getByText, + getComboboxes, +} from '../../test-utils/accessibility-assertions' +import { html } from '../../test-utils/html' +import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' + +jest.mock('../../hooks/use-id') + +beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) +}) + +afterAll(() => jest.restoreAllMocks()) + +function nextFrame() { + return new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve() + }) + }) + }) +} + +function getDefaultComponents() { + return { + Combobox, + ComboboxInput, + ComboboxLabel, + ComboboxButton, + ComboboxOptions, + ComboboxOption, + } +} + +function renderTemplate(input: string | Partial) { + let defaultComponents = getDefaultComponents() + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents })) + } + + return render( + defineComponent( + (Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as unknown) as DefineComponent + ) + ) +} + +describe('safeguards', () => { + it.each([ + ['ComboboxButton', ComboboxButton], + ['ComboboxLabel', ComboboxLabel], + ['ComboboxOptions', ComboboxOptions], + ['ComboboxOption', ComboboxOption], + ])( + 'should error when we are using a <%s /> without a parent ', + suppressConsoleLogs((name, Component) => { + expect(() => render(Component)).toThrowError( + `<${name} /> is missing a parent component.` + ) + }) + ) + + it( + 'should be possible to render a Combobox without crashing', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) +}) + +describe('Rendering', () => { + describe('Combobox', () => { + it( + 'should be possible to render a Combobox using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to disable a Combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await press(Keys.Enter, getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + }) + + describe('Combobox.Input', () => { + it( + 'selecting an option puts the value into Combobox.Input when displayValue is not provided', + suppressConsoleLogs(async () => { + let Example = defineComponent({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // TODO: Rendering Example directly reveals a vue bug — I think it's been fixed for a while but I can't find the commit + renderTemplate(Example) + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('b') + }) + ) + + it( + 'selecting an option puts the display value into Combobox.Input when displayValue is provided', + suppressConsoleLogs(async () => { + let Example = defineComponent({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + renderTemplate(Example) + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('B') + }) + ) + }) + + describe('ComboboxLabel', () => { + it( + 'should be possible to render a ComboboxLabel using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + {{JSON.stringify(data)}} + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxLabelLinkedWithCombobox() + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + it( + 'should be possible to render a ComboboxLabel using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + {{JSON.stringify(data)}} + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + }) + + describe('ComboboxButton', () => { + it( + 'should be possible to render a ComboboxButton using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + {{JSON.stringify(data)}} + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a ComboboxButton using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + {{JSON.stringify(data)}} + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a ComboboxButton and a ComboboxLabel and see them linked together', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Label + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + await new Promise(requestAnimationFrame) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + describe('`type` attribute', () => { + it('should set the `type` to "button" by default', async () => { + renderTemplate({ + template: html` + + + Trigger + + `, + setup: () => ({ value: ref(null) }), + }) + + expect(getComboboxButton()).toHaveAttribute('type', 'button') + }) + + it('should not set the `type` to "button" if it already contains a `type`', async () => { + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + expect(getComboboxButton()).toHaveAttribute('type', 'submit') + }) + + it( + 'should set the `type` to "button" when using the `as` prop which resolves to a "button"', + suppressConsoleLogs(async () => { + let CustomButton = defineComponent({ + setup: props => () => h('button', { ...props }), + }) + + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ + value: ref(null), + CustomButton, + }), + }) + + await new Promise(requestAnimationFrame) + + expect(getComboboxButton()).toHaveAttribute('type', 'button') + }) + ) + + it('should not set the type if the "as" prop is not a "button"', async () => { + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + expect(getComboboxButton()).not.toHaveAttribute('type') + }) + + it( + 'should not set the `type` to "button" when using the `as` prop which resolves to a "div"', + suppressConsoleLogs(async () => { + let CustomButton = defineComponent({ + setup: props => () => h('div', props), + }) + + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ + value: ref(null), + CustomButton, + }), + }) + + await new Promise(requestAnimationFrame) + + expect(getComboboxButton()).not.toHaveAttribute('type') + }) + ) + }) + }) + + describe('ComboboxOptions', () => { + it( + 'should be possible to render ComboboxOptions using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + {{JSON.stringify(data)}} + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + + assertComboboxList({ + state: ComboboxState.Visible, + textContent: JSON.stringify({ open: true }), + }) + + assertActiveElement(getComboboxInput()) + }) + ) + + it('should be possible to always render the ComboboxOptions if we provide it a `static` prop', () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Let's verify that the combobox is already there + expect(getComboboxInput()).not.toBe(null) + }) + + it('should be possible to use a different render strategy for the ComboboxOptions', async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + await new Promise(nextTick) + + assertComboboxList({ state: ComboboxState.InvisibleHidden }) + + // Let's open the combobox, to see if it is not hidden anymore + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + }) + }) + + describe('ComboboxOption', () => { + it( + 'should be possible to render a ComboboxOption using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + {{JSON.stringify(data)}} + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.Visible, + textContent: JSON.stringify({ active: false, selected: false, disabled: false }), + }) + }) + ) + }) + + it('should guarantee the order of DOM nodes when performing actions', async () => { + let props = reactive({ hide: false }) + + renderTemplate({ + template: html` + + + Trigger + + Option 1 + Option 2 + Option 3 + + + `, + setup() { + return { + value: ref(null), + get hide() { + return props.hide + }, + } + }, + }) + + // Open the combobox + await click(getByText('Trigger')) + + props.hide = true + await nextFrame() + + props.hide = false + await nextFrame() + + assertComboboxList({ state: ComboboxState.Visible }) + + let options = getComboboxOptions() + + // Focus the first item + await press(Keys.ArrowDown) + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + + await press(Keys.ArrowDown) + + // Verify that the second combobox option is active + assertActiveComboboxOption(options[1]) + + await press(Keys.ArrowDown) + + // Verify that the third combobox option is active + assertActiveComboboxOption(options[2]) + }) +}) + +describe('Rendering composition', () => { + it( + 'should be possible to swap the Combobox option with a button for example', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify options are buttons now + getComboboxOptions().forEach(option => assertComboboxOption(option, { tag: 'button' })) + }) + ) +}) + +describe('Composition', () => { + let OpenClosedWrite = defineComponent({ + props: { open: { type: Boolean } }, + setup(props, { slots }) { + useOpenClosedProvider(ref(props.open ? State.Open : State.Closed)) + return () => slots.default?.() + }, + }) + + let OpenClosedRead = defineComponent({ + emits: ['read'], + setup(_, { slots, emit }) { + let state = useOpenClosed() + watch([state], ([value]) => emit('read', value)) + return () => slots.default?.() + }, + }) + + it( + 'should always open the ComboboxOptions because of a wrapping OpenClosed component', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { OpenClosedWrite }, + template: html` + + + Trigger + + + {{JSON.stringify(data)}} + + + + `, + }) + + await new Promise(nextTick) + + // Verify the combobox is visible + assertComboboxList({ state: ComboboxState.Visible }) + + // Let's try and open the combobox + await click(getComboboxButton()) + + // Verify the combobox is still visible + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should always close the ComboboxOptions because of a wrapping OpenClosed component', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { OpenClosedWrite }, + template: html` + + + Trigger + + + {{JSON.stringify(data)}} + + + + `, + }) + + await new Promise(nextTick) + + // Verify the combobox is hidden + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Let's try and open the combobox + await click(getComboboxButton()) + + // Verify the combobox is still hidden + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to read the OpenClosed state', + suppressConsoleLogs(async () => { + let readFn = jest.fn() + renderTemplate({ + components: { OpenClosedRead }, + template: html` + + + Trigger + + + Option A + + + + `, + setup() { + return { value: ref(null), readFn } + }, + }) + + await new Promise(nextTick) + + // Verify the combobox is hidden + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Let's toggle the combobox 3 times + await click(getComboboxButton()) + await click(getComboboxButton()) + await click(getComboboxButton()) + + // Verify the combobox is visible + assertComboboxList({ state: ComboboxState.Visible }) + + expect(readFn).toHaveBeenCalledTimes(3) + expect(readFn).toHaveBeenNthCalledWith(1, State.Open) + expect(readFn).toHaveBeenNthCalledWith(2, State.Closed) + expect(readFn).toHaveBeenNthCalledWith(3, State.Open) + }) + ) +}) + +describe('Keyboard interactions', () => { + describe('Button', () => { + describe('`Enter` key', () => { + it( + 'should be possible to open the Combobox with Enter', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(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, { selected: false })) + + assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with Enter when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.Enter) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with Enter, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + await new Promise(nextTick) + + assertComboboxButton({ + state: ComboboxState.InvisibleHidden, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleHidden }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + let options = getComboboxOptions() + + // Hover over Option A + await mouseMove(options[0]) + + // Verify that Option A is active + assertActiveComboboxOption(options[0]) + + // Verify that Option B is still selected + assertComboboxOption(options[1], { selected: true }) + + // Close/Hide the combobox + await press(Keys.Escape) + + // Re-open the combobox + await click(getComboboxButton()) + + // Verify we have combobox options + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + {{ option.name }} + + + `, + setup: () => { + let options = [ + { id: 'a', name: 'Option A' }, + { id: 'b', name: 'Option B' }, + { id: 'c', name: 'Option C' }, + ] + let value = ref(options[1]) + + return { value, options } + }, + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Space` key', () => { + it( + 'should be possible to open the combobox with Space', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + // Verify we moved focus to the input field + assertActiveElement(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)) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with Space when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.Space) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with Space, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + // 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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should have no active combobox option upon Space key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open combobox with Escape', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Re-focus the button + getComboboxButton()?.focus() + assertActiveElement(getComboboxButton()) + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the combobox with ArrowDown', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // 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)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowDown when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowDown, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // 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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`ArrowRight` key', () => { + it( + 'should be possible to open the combobox with ArrowRight', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowRight when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowRight) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowRight, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + 'should be possible to open the combobox with ArrowUp and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // 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)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowUp, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // 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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`ArrowLeft` key', () => { + it( + 'should be possible to open the combobox with ArrowLeft and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowLeft and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowLeft) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowLeft, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowLeft to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) + }) + + describe('Input', () => { + describe('`Enter` key', () => { + it( + 'should be possible to close the combobox with Enter and choose the active combobox option', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup() { + let value = ref(null) + watch([value], () => handleChange(value.value)) + return { value } + }, + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + + // Activate the first combobox option + let options = getComboboxOptions() + await mouseMove(options[0]) + + // Choose option, and close combobox + await press(Keys.Enter) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[0]) + }) + ) + }) + + describe('`Tab` key', () => { + it( + 'pressing Tab should select the active item and move to the next DOM node', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + + Trigger + + Option A + Option B + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Select the 2nd option + await press(Keys.ArrowDown) + await press(Keys.ArrowDown) + + // Tab to the next DOM node + await press(Keys.Tab) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#after-combobox')) + }) + ) + + it( + 'pressing Shift+Tab should select the active item and move to the previous DOM node', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + + Trigger + + Option A + Option B + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Select the 2nd option + await press(Keys.ArrowDown) + await press(Keys.ArrowDown) + + // Tab to the next DOM node + await press(shift(Keys.Tab)) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#before-combobox')) + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open combobox with Escape', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the combobox with ArrowDown', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // 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)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowDown when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowDown, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // 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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[0]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + + // We should NOT be able to go down again (because last option). Current implementation won't go around. + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the combobox options and skip the first disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // Open combobox + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + }) + + describe('`ArrowRight` key', () => { + it( + 'should be possible to open the combobox with ArrowRight', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowRight when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowRight) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowRight, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowRight to navigate the combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[0]) + + // We should be able to go down again + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[2]) + + // We should NOT be able to go down again (because last option). Current implementation won't go around. + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowRight to navigate the combobox options and skip the first disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use ArrowRight to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // Open combobox + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[2]) + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + 'should be possible to open the combobox with ArrowUp and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // 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)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowUp, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // 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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should not be possible to navigate up or down if there is only a single non-disabled option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // Going up or down should select the single available option + await press(Keys.ArrowUp) + + // We should not be able to go up (because those are disabled) + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[2]) + + // We should not be able to go down (because this is the last option) + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // 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)) + assertActiveComboboxOption(options[2]) + + // We should be able to go down once + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + + // We should NOT be able to go up again (because first option). Current implementation won't go around. + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`ArrowLeft` key', () => { + it( + 'should be possible to open the combobox with ArrowLeft and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowLeft and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowLeft) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowLeft, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowLeft to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`End` key', () => { + it( + 'should be possible to use the End key to go to the last combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should have no option selected + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await press(Keys.End) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the End key to go to the last non disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should have no option selected + assertNoActiveComboboxOption() + + // We should be able to go to the last non-disabled option + await press(Keys.End) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the End key to go to the first combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should have no active combobox option upon End key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageDown` key', () => { + it( + 'should be possible to use the PageDown key to go to the last combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first option + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await press(Keys.PageDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the last non disabled Combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + + let options = getComboboxOptions() + + // We should have nothing active + assertNoActiveComboboxOption() + + // We should be able to go to the last non-disabled option + await press(Keys.PageDown) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the first combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should have no active combobox option upon PageDown key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Home` key', () => { + it( + 'should be possible to use the Home key to go to the first combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.Home) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the Home key to go to the first non disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the Home key to go to the last combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[3]) + }) + ) + + it( + 'should have no active combobox option upon Home key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageUp` key', () => { + it( + 'should be possible to use the PageUp key to go to the first combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.PageUp) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the first non disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the last combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[3]) + }) + ) + + it( + 'should have no active combobox option upon PageUp key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Any` key aka search', () => { + let Example = defineComponent({ + components: getDefaultComponents(), + + template: html` + + + Trigger + + + {{ person.name }} + + + + `, + + props: { + people: { + type: Array as PropType<{ value: string; name: string; disabled: boolean }[]>, + required: true, + }, + }, + + setup(props) { + let value = ref(null) + let query = ref('') + let filteredPeople = computed(() => { + return query.value === '' + ? props.people + : props.people.filter(person => + person.name.toLowerCase().includes(query.value.toLowerCase()) + ) + }) + + return { + value, + query, + filteredPeople, + setQuery: (event: Event & { target: HTMLInputElement }) => { + query.value = event.target.value + }, + } + }, + }) + + it( + 'should be possible to type a full word that has a perfect match', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to type a partial of a word', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bo')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('ali')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('char')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to type words with spaces', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob t')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob the builder') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice j')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice jones') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie b')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie bit me') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should not be possible to search and activate a disabled option', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + // We should not be able to go to the disabled option + await type(word('bo')) + await press(Keys.Home) + + assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() + }) + ) + + it( + 'should maintain activeIndex and activeOption when filtering', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + await press(Keys.ArrowDown) + await press(Keys.ArrowDown) + + // Person B should be active + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + + // Filter more, remove `person a` + await type(word('person b')) + options = getComboboxOptions() + expect(options[0]).toHaveTextContent('person b') + assertActiveComboboxOption(options[0]) + + // Filter less, insert `person a` before `person b` + await type(word('person')) + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + }) + ) + }) + }) +}) + +describe('Mouse interactions', () => { + it( + 'should focus the ComboboxButton when we click the ComboboxLabel', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Label + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Ensure the button is not focused yet + assertActiveElement(document.body) + + // Focus the label + await click(getComboboxLabel()) + + // Ensure that the actual button is focused instead + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should not focus the ComboboxInput when we right click the ComboboxLabel', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Label + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Ensure the button is not focused yet + assertActiveElement(document.body) + + // Focus the label + await click(getComboboxLabel(), MouseButton.Right) + + // Ensure that the body is still active + assertActiveElement(document.body) + }) + ) + + it( + 'should be possible to open the combobox on click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // 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 on right click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to open the combobox + await click(getComboboxButton(), MouseButton.Right) + + // Verify it is still closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should not be possible to open the combobox on click when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to open the combobox + await click(getComboboxButton()) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox on click, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // 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, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to close a combobox on click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // 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 }) + }) + ) + + it( + 'should be a no-op when we click outside of a closed combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Verify that the window is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Click something that is not related to the combobox + await click(document.body) + + // Should still be closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to click outside of the combobox which should close the combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + // Click something that is not related to the combobox + await click(document.body) + + // Should be closed now + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should be possible to click outside of the combobox on another combobox button which should close the current combobox and open the new combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` +
+ + + Trigger + + alice + bob + charlie + + + + + + Trigger + + alice + bob + charlie + + +
+ `, + setup: () => ({ value: ref(null) }), + }) + + let [button1, button2] = getComboboxButtons() + + // Click the first combobox button + await click(button1) + expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible + + // Verify that the first input is focused + assertActiveElement(getComboboxInputs()[0]) + + // Click the second combobox button + await click(button2) + + expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible + + // Verify that the first input is focused + assertActiveElement(getComboboxInputs()[1]) + }) + ) + + it( + 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + // Click the combobox button again + await click(getComboboxButton()) + + // Should be closed now + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should be possible to click outside of the combobox, on an element which is within a focusable element, which closes the combobox', + suppressConsoleLogs(async () => { + let focusFn = jest.fn() + renderTemplate({ + template: html` +
+ + + Trigger + + alice + bob + charlie + + + + +
+ `, + setup: () => ({ value: ref('test'), focusFn }), + }) + + // Click the combobox button + await click(getComboboxButton()) + + // Ensure the combobox is open + assertComboboxList({ state: ComboboxState.Visible }) + + // Click the span inside the button + await click(getByText('Next')) + + // Ensure the combobox is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Ensure the outside button is focused + assertActiveElement(document.getElementById('btn')) + + // Ensure that the focus button only got focus once (first click) + expect(focusFn).toHaveBeenCalledTimes(1) + }) + ) + + it( + 'should be possible to hover an option and make it active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should make a combobox option active when you move the mouse over it', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is already active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseMove(options[1]) + + // Nothing should be changed + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + await mouseMove(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to hover an option that is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + + // We should not have an active option now + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave an option and make it inactive', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + await mouseLeave(options[0]) + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + + await mouseLeave(options[2]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave a disabled option and be a no-op', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + assertNoActiveComboboxOption() + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to click a combobox option, which closes the combobox', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup() { + let value = ref(null) + watch([value], () => handleChange(value.value)) + return { value } + }, + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('bob') + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[1]) + }) + ) + + it( + 'should be possible to click a disabled combobox option, which is a no-op', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup() { + let value = ref(null) + watch([value], () => handleChange(value.value)) + return { value } + }, + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + expect(handleChange).toHaveBeenCalledTimes(0) + + // Close the combobox + await click(getComboboxButton()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is non existing + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible focus a combobox option, so that it becomes active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // Verify that nothing is active yet + assertNoActiveComboboxOption() + + // We should be able to focus the first option + await focus(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should not be possible to focus a combobox option which is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should not be able to focus the first option + await focus(options[1]) + assertNoActiveComboboxOption() + }) + ) +}) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts new file mode 100644 index 0000000..784ee1c --- /dev/null +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -0,0 +1,686 @@ +import { + defineComponent, + ref, + provide, + inject, + onMounted, + onUnmounted, + computed, + nextTick, + InjectionKey, + Ref, + ComputedRef, + watchEffect, + toRaw, + watch, + PropType, +} from 'vue' + +import { Features, render, omit } from '../../utils/render' +import { useId } from '../../hooks/use-id' +import { Keys } from '../../keyboard' +import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index' +import { dom } from '../../utils/dom' +import { useWindowEvent } from '../../hooks/use-window-event' +import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed' +import { match } from '../../utils/match' +import { useResolveButtonType } from '../../hooks/use-resolve-button-type' + +enum ComboboxStates { + Open, + Closed, +} + +type ComboboxOptionDataRef = Ref<{ disabled: boolean; value: unknown }> +type StateDefinition = { + // State + ComboboxState: Ref + value: ComputedRef + orientation: Ref<'vertical' | 'horizontal'> + + labelRef: Ref + inputRef: Ref + buttonRef: Ref + optionsRef: Ref + inputPropsRef: Ref<{ displayValue?: (item: unknown) => string }> + + disabled: Ref + options: Ref<{ id: string; dataRef: ComboboxOptionDataRef }[]> + activeOptionIndex: Ref + + // State mutators + closeCombobox(): void + openCombobox(): void + goToOption(focus: Focus, id?: string): void + selectOption(id: string): void + selectActiveOption(): void + registerOption(id: string, dataRef: ComboboxOptionDataRef): void + unregisterOption(id: string): void + select(value: unknown): void +} + +let ComboboxContext = Symbol('ComboboxContext') as InjectionKey + +function useComboboxContext(component: string) { + let context = inject(ComboboxContext, null) + + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext) + throw err + } + + return context +} + +// --- + +export let Combobox = defineComponent({ + name: 'Combobox', + emits: { 'update:modelValue': (_value: any) => true }, + props: { + as: { type: [Object, String], default: 'template' }, + disabled: { type: [Boolean], default: false }, + horizontal: { type: [Boolean], default: false }, + modelValue: { type: [Object, String, Number, Boolean] }, + }, + setup(props, { slots, attrs, emit }) { + let ComboboxState = ref(ComboboxStates.Closed) + let labelRef = ref(null) + let inputRef = ref(null) as StateDefinition['inputRef'] + let buttonRef = ref(null) as StateDefinition['buttonRef'] + let optionsRef = ref( + null + ) as StateDefinition['optionsRef'] + let options = ref([]) + let activeOptionIndex = ref(null) + + let value = computed(() => props.modelValue) + + let api = { + ComboboxState, + value, + orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')), + inputRef, + labelRef, + buttonRef, + optionsRef, + disabled: computed(() => props.disabled), + options, + activeOptionIndex, + inputPropsRef: ref<{ displayValue?: (item: unknown) => string }>({ displayValue: undefined }), + closeCombobox() { + if (props.disabled) return + if (ComboboxState.value === ComboboxStates.Closed) return + ComboboxState.value = ComboboxStates.Closed + activeOptionIndex.value = null + }, + openCombobox() { + if (props.disabled) return + if (ComboboxState.value === ComboboxStates.Open) return + ComboboxState.value = ComboboxStates.Open + }, + goToOption(focus: Focus, id?: string) { + if (props.disabled) return + if (ComboboxState.value === ComboboxStates.Closed) return + + let nextActiveOptionIndex = calculateActiveIndex( + focus === Focus.Specific + ? { focus: Focus.Specific, id: id! } + : { focus: focus as Exclude }, + { + resolveItems: () => options.value, + resolveActiveIndex: () => activeOptionIndex.value, + resolveId: option => option.id, + resolveDisabled: option => option.dataRef.disabled, + } + ) + + if (activeOptionIndex.value === nextActiveOptionIndex) return + activeOptionIndex.value = nextActiveOptionIndex + }, + syncInputValue() { + let value = api.value.value + if (!dom(api.inputRef)) return + if (value === undefined) return + let displayValue = api.inputPropsRef.value.displayValue + + if (typeof displayValue === 'function') { + api.inputRef!.value!.value = displayValue(value) + } else if (typeof value === 'string') { + api.inputRef!.value!.value = value + } + }, + selectOption(id: string) { + let option = options.value.find(item => item.id === id) + if (!option) return + + let { dataRef } = option + emit('update:modelValue', dataRef.value) + api.syncInputValue() + }, + selectActiveOption() { + if (activeOptionIndex.value === null) return + + let { dataRef } = options.value[activeOptionIndex.value] + emit('update:modelValue', dataRef.value) + api.syncInputValue() + }, + registerOption(id: string, dataRef: ComboboxOptionDataRef) { + let currentActiveOption = + activeOptionIndex.value !== null ? options.value[activeOptionIndex.value] : null + let orderMap = Array.from( + optionsRef.value?.querySelectorAll('[id^="headlessui-combobox-option-"]') ?? [] + ).reduce( + (lookup, element, index) => Object.assign(lookup, { [element.id]: index }), + {} + ) as Record + + // @ts-expect-error The expected type comes from property 'dataRef' which is declared here on type '{ id: string; dataRef: { textValue: string; disabled: boolean; }; }' + options.value = [...options.value, { id, dataRef }].sort( + (a, z) => orderMap[a.id] - orderMap[z.id] + ) + + // If we inserted an option before the current active option then the + // active option index would be wrong. To fix this, we will re-lookup + // the correct index. + activeOptionIndex.value = (() => { + if (currentActiveOption === null) return null + return options.value.indexOf(currentActiveOption) + })() + }, + unregisterOption(id: string) { + let nextOptions = options.value.slice() + let currentActiveOption = + activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null + let idx = nextOptions.findIndex(a => a.id === id) + if (idx !== -1) nextOptions.splice(idx, 1) + options.value = nextOptions + activeOptionIndex.value = (() => { + if (idx === activeOptionIndex.value) return null + if (currentActiveOption === null) return null + + // If we removed the option before the actual active index, then it would be out of sync. To + // fix this, we will find the correct (new) index position. + return nextOptions.indexOf(currentActiveOption) + })() + }, + } + + useWindowEvent('mousedown', event => { + let target = event.target as HTMLElement + let active = document.activeElement + + if (ComboboxState.value !== ComboboxStates.Open) return + + if (dom(inputRef)?.contains(target)) return + if (dom(buttonRef)?.contains(target)) return + if (dom(optionsRef)?.contains(target)) return + + api.closeCombobox() + + if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element + if (!event.defaultPrevented) dom(inputRef)?.focus({ preventScroll: true }) + }) + + watchEffect(() => { + api.syncInputValue() + }) + + // @ts-expect-error Types of property 'dataRef' are incompatible. + provide(ComboboxContext, api) + useOpenClosedProvider( + computed(() => + match(ComboboxState.value, { + [ComboboxStates.Open]: State.Open, + [ComboboxStates.Closed]: State.Closed, + }) + ) + ) + + return () => { + let slot = { open: ComboboxState.value === ComboboxStates.Open, disabled: props.disabled } + return render({ + props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']), + slot, + slots, + attrs, + name: 'Combobox', + }) + } + }, +}) + +// --- + +export let ComboboxLabel = defineComponent({ + name: 'ComboboxLabel', + props: { as: { type: [Object, String], default: 'label' } }, + render() { + let api = useComboboxContext('ComboboxLabel') + + let slot = { + open: api.ComboboxState.value === ComboboxStates.Open, + disabled: api.disabled.value, + } + let propsWeControl = { id: this.id, ref: 'el', onClick: this.handleClick } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + name: 'ComboboxLabel', + }) + }, + setup() { + let api = useComboboxContext('ComboboxLabel') + let id = `headlessui-combobox-label-${useId()}` + + return { + id, + el: api.labelRef, + handleClick() { + dom(api.inputRef)?.focus({ preventScroll: true }) + }, + } + }, +}) + +// --- + +export let ComboboxButton = defineComponent({ + name: 'ComboboxButton', + props: { + as: { type: [Object, String], default: 'button' }, + }, + render() { + let api = useComboboxContext('ComboboxButton') + + let slot = { + open: api.ComboboxState.value === ComboboxStates.Open, + disabled: api.disabled.value, + } + let propsWeControl = { + ref: 'el', + id: this.id, + type: this.type, + tabindex: '-1', + 'aria-haspopup': true, + 'aria-controls': dom(api.optionsRef)?.id, + 'aria-expanded': api.disabled.value + ? undefined + : api.ComboboxState.value === ComboboxStates.Open, + 'aria-labelledby': api.labelRef.value + ? [dom(api.labelRef)?.id, this.id].join(' ') + : undefined, + disabled: api.disabled.value === true ? true : undefined, + onKeydown: this.handleKeydown, + onClick: this.handleClick, + } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + name: 'ComboboxButton', + }) + }, + setup(props, { attrs }) { + let api = useComboboxContext('ComboboxButton') + let id = `headlessui-combobox-button-${useId()}` + + function handleClick(event: MouseEvent) { + if (api.disabled.value) return + if (api.ComboboxState.value === ComboboxStates.Open) { + api.closeCombobox() + } else { + event.preventDefault() + api.openCombobox() + } + + nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true })) + } + + function handleKeydown(event: KeyboardEvent) { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case match(api.orientation.value, { + vertical: Keys.ArrowDown, + horizontal: Keys.ArrowRight, + }): + event.preventDefault() + event.stopPropagation() + if (api.ComboboxState.value === ComboboxStates.Closed) { + api.openCombobox() + // TODO: We can't do this outside next frame because the options aren't rendered yet + // But doing this in next frame results in a flicker because the dom mutations are async here + // Basically: + // Sync -> no option list yet + // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element + + // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.First) + } + }) + } + nextTick(() => api.inputRef.value?.focus({ preventScroll: true })) + return + + case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + if (api.ComboboxState.value === ComboboxStates.Closed) { + api.openCombobox() + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.Last) + } + }) + } + nextTick(() => api.inputRef.value?.focus({ preventScroll: true })) + return + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + api.closeCombobox() + nextTick(() => api.inputRef.value?.focus({ preventScroll: true })) + return + } + } + + return { + api, + id, + el: api.buttonRef, + type: useResolveButtonType( + computed(() => ({ as: props.as, type: attrs.type })), + api.buttonRef + ), + handleClick, + handleKeydown, + } + }, +}) + +// --- + +export let ComboboxInput = defineComponent({ + name: 'ComboboxInput', + props: { + as: { type: [Object, String], default: 'input' }, + static: { type: Boolean, default: false }, + unmount: { type: Boolean, default: true }, + displayValue: { type: Function as PropType<(item: unknown) => string> }, + }, + emits: { + change: (_value: Event & { target: HTMLInputElement }) => true, + }, + render() { + let api = useComboboxContext('ComboboxInput') + + let slot = { open: api.ComboboxState.value === ComboboxStates.Open } + let propsWeControl = { + 'aria-activedescendant': + api.activeOptionIndex.value === null + ? undefined + : api.options.value[api.activeOptionIndex.value]?.id, + 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id, + 'aria-orientation': api.orientation.value, + id: this.id, + onKeydown: this.handleKeyDown, + onChange: this.handleChange, + role: 'combobox', + tabIndex: 0, + ref: 'el', + } + let passThroughProps = this.$props + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + features: Features.RenderStrategy | Features.Static, + name: 'ComboboxInput', + }) + }, + setup(props, { emit }) { + let api = useComboboxContext('ComboboxInput') + let id = `headlessui-combobox-input-${useId()}` + api.inputPropsRef = computed(() => props) + + function handleKeyDown(event: KeyboardEvent) { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + + api.selectActiveOption() + api.closeCombobox() + break + + case match(api.orientation.value, { + vertical: Keys.ArrowDown, + horizontal: Keys.ArrowRight, + }): + event.preventDefault() + event.stopPropagation() + return match(api.ComboboxState.value, { + [ComboboxStates.Open]: () => api.goToOption(Focus.Next), + [ComboboxStates.Closed]: () => { + api.openCombobox() + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.First) + } + }) + }, + }) + + case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + return match(api.ComboboxState.value, { + [ComboboxStates.Open]: () => api.goToOption(Focus.Previous), + [ComboboxStates.Closed]: () => { + api.openCombobox() + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.Last) + } + }) + }, + }) + + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + event.stopPropagation() + return api.goToOption(Focus.First) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + return api.goToOption(Focus.Last) + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + api.closeCombobox() + break + + case Keys.Tab: + api.selectActiveOption() + api.closeCombobox() + break + } + } + + function handleChange(event: Event & { target: HTMLInputElement }) { + api.openCombobox() + emit('change', event) + } + + return { id, el: api.inputRef, handleKeyDown, handleChange } + }, +}) + +// --- + +export let ComboboxOptions = defineComponent({ + name: 'ComboboxOptions', + props: { + as: { type: [Object, String], default: 'ul' }, + static: { type: Boolean, default: false }, + unmount: { type: Boolean, default: true }, + }, + render() { + let api = useComboboxContext('ComboboxOptions') + + let slot = { open: api.ComboboxState.value === ComboboxStates.Open } + let propsWeControl = { + 'aria-activedescendant': + api.activeOptionIndex.value === null + ? undefined + : api.options.value[api.activeOptionIndex.value]?.id, + 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id, + 'aria-orientation': api.orientation.value, + id: this.id, + ref: 'el', + role: 'listbox', + } + let passThroughProps = this.$props + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + features: Features.RenderStrategy | Features.Static, + visible: this.visible, + name: 'ComboboxOptions', + }) + }, + setup() { + let api = useComboboxContext('ComboboxOptions') + let id = `headlessui-combobox-options-${useId()}` + + let usesOpenClosedState = useOpenClosed() + let visible = computed(() => { + if (usesOpenClosedState !== null) { + return usesOpenClosedState.value === State.Open + } + + return api.ComboboxState.value === ComboboxStates.Open + }) + + return { id, el: api.optionsRef, visible } + }, +}) + +export let ComboboxOption = defineComponent({ + name: 'ComboboxOption', + props: { + as: { type: [Object, String], default: 'li' }, + value: { type: [Object, String, Number, Boolean] }, + disabled: { type: Boolean, default: false }, + }, + setup(props, { slots, attrs }) { + let api = useComboboxContext('ComboboxOption') + let id = `headlessui-combobox-option-${useId()}` + + let active = computed(() => { + return api.activeOptionIndex.value !== null + ? api.options.value[api.activeOptionIndex.value].id === id + : false + }) + + let selected = computed(() => toRaw(api.value.value) === toRaw(props.value)) + + let dataRef = computed(() => ({ + disabled: props.disabled, + value: props.value, + })) + + onMounted(() => api.registerOption(id, dataRef)) + onUnmounted(() => api.unregisterOption(id)) + + onMounted(() => { + watch( + [api.ComboboxState, selected], + () => { + if (api.ComboboxState.value !== ComboboxStates.Open) return + if (!selected.value) return + api.goToOption(Focus.Specific, id) + }, + { immediate: true } + ) + }) + + watchEffect(() => { + if (api.ComboboxState.value !== ComboboxStates.Open) return + if (!active.value) return + nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + }) + + function handleClick(event: MouseEvent) { + if (props.disabled) return event.preventDefault() + api.selectOption(id) + api.closeCombobox() + nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true })) + } + + function handleFocus() { + if (props.disabled) return api.goToOption(Focus.Nothing) + api.goToOption(Focus.Specific, id) + } + + function handleMove() { + if (props.disabled) return + if (active.value) return + api.goToOption(Focus.Specific, id) + } + + function handleLeave() { + if (props.disabled) return + if (!active.value) return + api.goToOption(Focus.Nothing) + } + + return () => { + let { disabled } = props + let slot = { active: active.value, selected: selected.value, disabled } + let propsWeControl = { + id, + role: 'option', + tabIndex: disabled === true ? undefined : -1, + 'aria-disabled': disabled === true ? true : undefined, + 'aria-selected': selected.value === true ? selected.value : undefined, + disabled: undefined, // Never forward the `disabled` prop + onClick: handleClick, + onFocus: handleFocus, + onPointermove: handleMove, + onMousemove: handleMove, + onPointerleave: handleLeave, + onMouseleave: handleLeave, + } + + return render({ + props: { ...props, ...propsWeControl }, + slot, + attrs, + slots, + name: 'ComboboxOption', + }) + } + }, +}) diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index 97ddbee..d8d8fac 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -6,6 +6,14 @@ import * as HeadlessUI from './index' */ it('should expose the correct components', () => { expect(Object.keys(HeadlessUI)).toEqual([ + // Combobox + 'Combobox', + 'ComboboxLabel', + 'ComboboxButton', + 'ComboboxInput', + 'ComboboxOptions', + 'ComboboxOption', + // Dialog 'Dialog', 'DialogOverlay', diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts index 2ef29db..2ba6c32 100644 --- a/packages/@headlessui-vue/src/index.ts +++ b/packages/@headlessui-vue/src/index.ts @@ -1,3 +1,4 @@ +export * from './components/combobox/combobox' export * from './components/dialog/dialog' export * from './components/disclosure/disclosure' export * from './components/focus-trap/focus-trap' diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index 1fb40a1..f478642 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -91,7 +91,7 @@ export function assertMenuButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertMenuButton) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton) throw err } } @@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id')) expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) throw err } } @@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge // Ensure link between menu & menu item is correct expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) throw err } } @@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) { // Ensure we don't have an active menu expect(menu).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem) throw err } } @@ -183,7 +183,7 @@ export function assertMenu( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenu) throw err } } @@ -214,7 +214,393 @@ export function assertMenuItem( } } } catch (err) { - Error.captureStackTrace(err, assertMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem) + throw err + } +} + +// --- + +export function getComboboxLabel(): HTMLElement | null { + return document.querySelector('label,[id^="headlessui-combobox-label"]') +} + +export function getComboboxButton(): HTMLElement | null { + return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]') +} + +export function getComboboxButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getComboboxInput(): HTMLInputElement | null { + return document.querySelector('[role="combobox"]') +} + +export function getCombobox(): HTMLElement | null { + return document.querySelector('[role="listbox"]') +} + +export function getComboboxInputs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="combobox"]')) +} + +export function getComboboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="listbox"]')) +} + +export function getComboboxOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="option"]')) +} + +// --- + +export enum ComboboxState { + /** The combobox is visible to the user. */ + Visible, + + /** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, +} + +export function assertCombobox( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + combobox = getComboboxInput() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertHidden(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertVisible(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(combobox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxList( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + listbox = getCombobox() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertHidden(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertVisible(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(listbox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxButton( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + }, + button = getComboboxButton() +) { + try { + if (button === null) return expect(button).not.toBe(null) + + // Ensure menu button have these properties + expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute('aria-haspopup') + + switch (options.state) { + case ComboboxState.Visible: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break + + case ComboboxState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case ComboboxState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + default: + assertNever(options.state) + } + + if (options.textContent) { + expect(button).toHaveTextContent(options.textContent) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton) + throw err + } +} + +export function assertComboboxLabel( + options: { + attributes?: Record + tag?: string + textContent?: string + }, + label = getComboboxLabel() +) { + try { + if (label === null) return expect(label).not.toBe(null) + + // Ensure menu button have these properties + expect(label).toHaveAttribute('id') + + if (options.textContent) { + expect(label).toHaveTextContent(options.textContent) + } + + if (options.tag) { + expect(label.tagName.toLowerCase()).toBe(options.tag) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel) + throw err + } +} + +export function assertComboboxButtonLinkedWithCombobox( + button = getComboboxButton(), + combobox = getCombobox() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure link between button & combobox is correct + expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id')) + expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox) + throw err + } +} + +export function assertComboboxLabelLinkedWithCombobox( + label = getComboboxLabel(), + combobox = getComboboxInput() +) { + try { + if (label === null) return expect(label).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox) + throw err + } +} + +export function assertComboboxButtonLinkedWithComboboxLabel( + button = getComboboxButton(), + label = getComboboxLabel() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (label === null) return expect(label).not.toBe(null) + + // Ensure link between button & label is correct + expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) + } catch (err) { + if (err instanceof Error) + Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel) + throw err + } +} + +export function assertActiveComboboxOption( + item: HTMLElement | null, + combobox = getComboboxInput() +) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + if (item === null) return expect(item).not.toBe(null) + + // Ensure link between combobox & combobox item is correct + expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption) + throw err + } +} + +export function assertNoActiveComboboxOption(combobox = getComboboxInput()) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure we don't have an active combobox + expect(combobox).not.toHaveAttribute('aria-activedescendant') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption) + throw err + } +} + +export function assertNoSelectedComboboxOption(items = getComboboxOptions()) { + try { + for (let item of items) expect(item).not.toHaveAttribute('aria-selected') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption) + throw err + } +} + +export function assertComboboxOption( + item: HTMLElement | null, + options?: { + tag?: string + attributes?: Record + selected?: boolean + } +) { + try { + if (item === null) return expect(item).not.toBe(null) + + // Check that some attributes exists, doesn't really matter what the values are at this point in + // time, we just require them. + expect(item).toHaveAttribute('id') + + // Check that we have the correct values for certain attributes + expect(item).toHaveAttribute('role', 'option') + if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1') + + // Ensure combobox button has the following attributes + if (!options) return + + for (let attributeName in options.attributes) { + expect(item).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + + if (options.tag) { + expect(item.tagName.toLowerCase()).toBe(options.tag) + } + + if (options.selected != null) { + switch (options.selected) { + case true: + return expect(item).toHaveAttribute('aria-selected', 'true') + + case false: + return expect(item).not.toHaveAttribute('aria-selected') + + default: + assertNever(options.selected) + } + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption) throw err } } @@ -311,7 +697,7 @@ export function assertListbox( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListbox) throw err } } @@ -368,7 +754,7 @@ export function assertListboxButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxButton) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton) throw err } } @@ -400,7 +786,7 @@ export function assertListboxLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel) throw err } } @@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox( expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id')) expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) throw err } } @@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox( expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) throw err } } @@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel( // Ensure link between button & label is correct expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) + if (err instanceof Error) + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) throw err } } @@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge // Ensure link between listbox & listbox item is correct expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption) throw err } } @@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) { // Ensure we don't have an active listbox expect(listbox).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption) throw err } } @@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) { try { for (let item of items) expect(item).not.toHaveAttribute('aria-selected') } catch (err) { - Error.captureStackTrace(err, assertNoSelectedListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption) throw err } } @@ -530,7 +917,7 @@ export function assertListboxOption( } } } catch (err) { - Error.captureStackTrace(err, assertListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption) throw err } } @@ -597,7 +984,7 @@ export function assertSwitch( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertSwitch) + if (err instanceof Error) Error.captureStackTrace(err, assertSwitch) throw err } } @@ -678,7 +1065,7 @@ export function assertDisclosureButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertDisclosureButton) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton) throw err } } @@ -725,7 +1112,7 @@ export function assertDisclosurePanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDisclosurePanel) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel) throw err } } @@ -810,7 +1197,7 @@ export function assertPopoverButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertPopoverButton) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton) throw err } } @@ -857,7 +1244,7 @@ export function assertPopoverPanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertPopoverPanel) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel) throw err } } @@ -984,7 +1371,7 @@ export function assertDialog( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialog) + if (err instanceof Error) Error.captureStackTrace(err, assertDialog) throw err } } @@ -1040,7 +1427,7 @@ export function assertDialogTitle( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogTitle) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle) throw err } } @@ -1096,7 +1483,7 @@ export function assertDialogDescription( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogDescription) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription) throw err } } @@ -1143,7 +1530,7 @@ export function assertDialogOverlay( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogOverlay) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay) throw err } } @@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertRadioGroupLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel) throw err } } @@ -1267,7 +1654,7 @@ export function assertTabs( } } } catch (err) { - Error.captureStackTrace(err, assertTabs) + if (err instanceof Error) Error.captureStackTrace(err, assertTabs) throw err } } @@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) { expect(document.activeElement?.outerHTML).toBe(element.outerHTML) } } catch (err) { - Error.captureStackTrace(err, assertActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement) throw err } } @@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) { if (element === null) return expect(element).not.toBe(null) expect(element.contains(document.activeElement)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertContainsActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement) throw err } } @@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) { expect(element).toHaveAttribute('hidden') expect(element).toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertHidden) + if (err instanceof Error) Error.captureStackTrace(err, assertHidden) throw err } } @@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) { expect(element).not.toHaveAttribute('hidden') expect(element).not.toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertVisible) + if (err instanceof Error) Error.captureStackTrace(err, assertVisible) throw err } } @@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertFocusable) throw err } } @@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false) } catch (err) { - Error.captureStackTrace(err, assertNotFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable) throw err } } diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index dd483d0..8e93518 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -1,4 +1,7 @@ import { fireEvent } from '@testing-library/dom' +import { disposables } from '../utils/disposables' + +let d = disposables() function nextFrame(cb: Function): void { setImmediate(() => @@ -33,7 +36,19 @@ export function shift(event: Partial) { } export function word(input: string): Partial[] { - return input.split('').map(key => ({ key })) + let result = input.split('').map(key => ({ key })) + + d.enqueue(() => { + let element = document.activeElement + + if (element instanceof HTMLInputElement) { + fireEvent.change(element, { + target: Object.assign({}, element, { value: input }), + }) + } + }) + + return result } let Default = Symbol() @@ -76,6 +91,9 @@ let order: Record< function keypress(element, event) { return fireEvent.keyPress(element, event) }, + function input(element, event) { + return fireEvent.input(element, event) + }, function keyup(element, event) { return fireEvent.keyUp(element, event) }, @@ -159,9 +177,11 @@ export async function type(events: Partial[], element = document. // We don't want to actually wait in our tests, so let's advance jest.runAllTimers() + await d.workQueue() + await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, type) + if (err instanceof Error) Error.captureStackTrace(err, type) throw err } finally { jest.useRealTimers() @@ -178,7 +198,7 @@ export enum MouseButton { } export async function click( - element: Document | Element | Window | null, + element: Document | Element | Window | Node | null, button = MouseButton.Left ) { try { @@ -224,12 +244,12 @@ export async function click( await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, click) + if (err instanceof Error) Error.captureStackTrace(err, click) throw err } } -export async function focus(element: Document | Element | Window | null) { +export async function focus(element: Document | Element | Window | Node | null) { try { if (element === null) return expect(element).not.toBe(null) @@ -237,11 +257,10 @@ export async function focus(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, focus) + if (err instanceof Error) Error.captureStackTrace(err, focus) throw err } } - export async function mouseEnter(element: Document | Element | Window | null) { try { if (element === null) return expect(element).not.toBe(null) @@ -252,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseEnter) + if (err instanceof Error) Error.captureStackTrace(err, mouseEnter) throw err } } @@ -266,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseMove) + if (err instanceof Error) Error.captureStackTrace(err, mouseMove) throw err } } @@ -282,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseLeave) + if (err instanceof Error) Error.captureStackTrace(err, mouseLeave) throw err } } diff --git a/packages/@headlessui-vue/src/utils/disposables.ts b/packages/@headlessui-vue/src/utils/disposables.ts index 7c9a388..4c0f89c 100644 --- a/packages/@headlessui-vue/src/utils/disposables.ts +++ b/packages/@headlessui-vue/src/utils/disposables.ts @@ -1,7 +1,12 @@ export function disposables() { let disposables: Function[] = [] + let queue: Function[] = [] let api = { + enqueue(fn: Function) { + queue.push(fn) + }, + requestAnimationFrame(...args: Parameters) { let raf = requestAnimationFrame(...args) api.add(() => cancelAnimationFrame(raf)) @@ -27,6 +32,12 @@ export function disposables() { dispose() } }, + + async workQueue() { + for (let handle of queue.splice(0)) { + await handle() + } + }, } return api diff --git a/packages/playground-react/next-env.d.ts b/packages/playground-react/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/packages/playground-react/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/playground-react/next.config.js b/packages/playground-react/next.config.js new file mode 100644 index 0000000..5b8efdf --- /dev/null +++ b/packages/playground-react/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + devIndicators: { + autoPrerender: false, + }, +} diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json new file mode 100644 index 0000000..40c4325 --- /dev/null +++ b/packages/playground-react/package.json @@ -0,0 +1,23 @@ +{ + "name": "playground-react", + "version": "1.0.0", + "main": "next.config.js", + "scripts": { + "prebuild": "yarn workspace @headlessui/react build", + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "keywords": [], + "author": "Robin Malfait", + "license": "ISC", + "description": "", + "dependencies": { + "@headlessui/react": "*", + "@popperjs/core": "^2.6.0", + "framer-motion": "^6.0.0", + "next": "^12.0.8", + "react": "16.14.0", + "react-dom": "16.14.0" + } +} diff --git a/packages/playground-react/pages/_app.tsx b/packages/playground-react/pages/_app.tsx new file mode 100644 index 0000000..671e2ee --- /dev/null +++ b/packages/playground-react/pages/_app.tsx @@ -0,0 +1,239 @@ +import React, { useState, useEffect } from 'react' +import Link from 'next/link' +import Head from 'next/head' + +function disposables() { + let disposables: Function[] = [] + + let api = { + requestAnimationFrame(...args: Parameters) { + let raf = requestAnimationFrame(...args) + api.add(() => cancelAnimationFrame(raf)) + }, + + nextFrame(...args: Parameters) { + api.requestAnimationFrame(() => { + api.requestAnimationFrame(...args) + }) + }, + + setTimeout(...args: Parameters) { + let timer = setTimeout(...args) + api.add(() => clearTimeout(timer)) + }, + + add(cb: () => void) { + disposables.push(cb) + }, + + dispose() { + for (let dispose of disposables.splice(0)) { + dispose() + } + }, + } + + return api +} + +export function useDisposables() { + // Using useState instead of useRef so that we can use the initializer function. + let [d] = useState(disposables) + useEffect(() => () => d.dispose(), [d]) + return d +} + +function NextLink(props: React.ComponentProps<'a'>) { + let { href, children, ...rest } = props + return ( + + {children} + + ) +} + +enum KeyDisplayMac { + ArrowUp = '↑', + ArrowDown = '↓', + ArrowLeft = '←', + ArrowRight = '→', + Home = '↖', + End = '↘', + Alt = '⌥', + CapsLock = '⇪', + Meta = '⌘', + Shift = '⇧', + Control = '⌃', + Backspace = '⌫', + Delete = '⌦', + Enter = '↵', + Escape = '⎋', + Tab = '↹', + PageUp = '⇞', + PageDown = '⇟', + ' ' = '␣', +} + +enum KeyDisplayWindows { + ArrowUp = '↑', + ArrowDown = '↓', + ArrowLeft = '←', + ArrowRight = '→', + Meta = 'Win', + Control = 'Ctrl', + Backspace = '⌫', + Delete = 'Del', + Escape = 'Esc', + PageUp = 'PgUp', + PageDown = 'PgDn', + ' ' = '␣', +} + +function tap(value: T, cb: (value: T) => void) { + cb(value) + return value +} + +function useKeyDisplay() { + let [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) return {} + let isMac = navigator.userAgent.indexOf('Mac OS X') !== -1 + return isMac ? KeyDisplayMac : KeyDisplayWindows +} + +function KeyCaster() { + let [keys, setKeys] = useState([]) + let d = useDisposables() + let KeyDisplay = useKeyDisplay() + + useEffect(() => { + function handler(event: KeyboardEvent) { + setKeys(current => [ + event.shiftKey && event.key !== 'Shift' + ? KeyDisplay[`Shift${event.key}`] ?? event.key + : KeyDisplay[event.key] ?? event.key, + ...current, + ]) + d.setTimeout(() => setKeys(current => tap(current.slice(), clone => clone.pop())), 2000) + } + + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + }, [d, KeyDisplay]) + + if (keys.length <= 0) return null + + return ( +
+ {keys + .slice() + .reverse() + .join(' ')} +
+ ) +} + +function MyApp({ Component, pageProps }) { + return ( + <> + + + + + + + + + + + +
+
+ + + +
+ + + +
+ +
+
+ + ) +} + +function Logo({ className }) { + return ( + + + + + + + + + + + + + + + + + + ) +} + +export default MyApp diff --git a/packages/playground-react/pages/_error.tsx b/packages/playground-react/pages/_error.tsx new file mode 100644 index 0000000..21c1a8a --- /dev/null +++ b/packages/playground-react/pages/_error.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import ErrorPage from 'next/error' +import Head from 'next/head' +import Link from 'next/link' + +import { ExamplesType, resolveAllExamples } from '../utils/resolve-all-examples' + +function NextLink(props: React.ComponentProps<'a'>) { + let { href, children, ...rest } = props + return ( + + {children} + + ) +} + +export async function getStaticProps() { + return { + props: { + examples: await resolveAllExamples('pages'), + }, + } +} + +export default function Page(props: { examples: false | ExamplesType[] }) { + if (props.examples === false) { + return + } + + return ( + <> + + Examples + + +
+
+

Examples

+ +
+
+ + ) +} + +export function Examples(props: { examples: ExamplesType[] }) { + return ( +
    + {props.examples.map(example => ( +
  • + {example.children ? ( +

    {example.name}

    + ) : ( + + {example.name} + + )} + {example.children && } +
  • + ))} +
+ ) +} diff --git a/packages/playground-react/pages/combobox/combobox-with-pure-tailwind.tsx b/packages/playground-react/pages/combobox/combobox-with-pure-tailwind.tsx new file mode 100644 index 0000000..a1e6e87 --- /dev/null +++ b/packages/playground-react/pages/combobox/combobox-with-pure-tailwind.tsx @@ -0,0 +1,136 @@ +import React, { useState, useEffect } from 'react' +import { Combobox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let everybody = [ + 'Wade Cooper', + 'Arlene Mccoy', + 'Devon Webb', + 'Tom Cook', + 'Tanya Fox', + 'Hellen Schmidt', + 'Caroline Schultz', + 'Mason Heaney', + 'Claudie Smitham', + 'Emil Schaefer', +] + +function useDebounce(value: T, delay: number) { + let [debouncedValue, setDebouncedValue] = useState(value) + useEffect(() => { + let timer = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + return debouncedValue +} +export default function Home() { + let [query, setQuery] = useState('') + let [activePerson, setActivePerson] = useState(everybody[2]) + + // Mimic delayed response from an API + let actualQuery = useDebounce(query, 0 /* Change to higher value like 100 for testing purposes */) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(everybody[Math.floor(Math.random() * everybody.length)]) + }, []) + + let people = + actualQuery === '' + ? everybody + : everybody.filter(person => person.toLowerCase().includes(actualQuery.toLowerCase())) + + return ( +
+
+
Selected person: {activePerson}
+
+ { + setActivePerson(value) + }} + as="div" + > + + Assigned to + + +
+ + setQuery(e.target.value)} + className="border-none outline-none px-3 py-1" + /> + + + + + + + + + +
+ + {people.map(name => ( + { + return classNames( + 'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {name} + + {selected && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/combobox/command-palette-with-groups.tsx b/packages/playground-react/pages/combobox/command-palette-with-groups.tsx new file mode 100644 index 0000000..ee432ca --- /dev/null +++ b/packages/playground-react/pages/combobox/command-palette-with-groups.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect, Fragment } from 'react' +import { Combobox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let everybody = [ + { id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' }, + { id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' }, + { id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' }, + { id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' }, + { id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' }, + { + id: 6, + img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg', + name: 'James McDonald', + }, + { id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' }, + { id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' }, +] + +export default function Home() { + let [query, setQuery] = useState('') + let [activePerson, setActivePerson] = useState(everybody[2]) + + function setPerson(person) { + setActivePerson(person) + setQuery(person.name ?? '') + } + + // Choose a random person on mount + useEffect(() => { + setPerson(everybody[Math.floor(Math.random() * everybody.length)]) + }, []) + + let people = + query === '' + ? everybody + : everybody.filter(person => person.name.toLowerCase().includes(query.toLowerCase())) + + let groups = people.reduce((groups, person) => { + let lastNameLetter = person.name.split(' ')[1][0] + + groups.set(lastNameLetter, [...(groups.get(lastNameLetter) || []), person]) + + return groups + }, new Map()) + + return ( +
+
+
+ setPerson(person)} + className="bg-white w-full shadow-sm border border-black/5 bg-clip-padding rounded overflow-hidden" + > + {({ activeOption }) => { + return ( +
+ setQuery(e.target.value)} + className="border-none outline-none px-3 py-1 bg-none rounded-none w-full" + placeholder="Search users…" + displayValue={item => item?.name} + /> +
+ + {Array.from(groups.entries()) + .sort(([letterA], [letterZ]) => letterA.localeCompare(letterZ)) + .map(([letter, people]) => ( + +
{letter}
+ {people.map(person => ( + { + return classNames( + 'flex relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none space-x-4', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + + {person.name} + + {active && ( + + + + + + )} + + )} + + ))} +
+ ))} +
+ + {people.length === 0 ? ( +
No person selected
+ ) : activeOption === null ? null : ( +
+
+
+ +
{activeOption.name}
+
Obviously cool person
+
+
+
+ )} +
+
+ ) + }} +
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/combobox/command-palette.tsx b/packages/playground-react/pages/combobox/command-palette.tsx new file mode 100644 index 0000000..f68ba92 --- /dev/null +++ b/packages/playground-react/pages/combobox/command-palette.tsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from 'react' +import { Combobox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let everybody = [ + { id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' }, + { id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' }, + { id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' }, + { id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' }, + { id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' }, + { + id: 6, + img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg', + name: 'James McDonald', + }, + { id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' }, + { id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' }, +] + +export default function Home() { + let [query, setQuery] = useState('') + let [activePerson, setActivePerson] = useState(everybody[2]) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(everybody[Math.floor(Math.random() * everybody.length)]) + }, []) + + let people = + query === '' + ? everybody + : everybody.filter(person => person.name.toLowerCase().includes(query.toLowerCase())) + + return ( +
+
+
+ setActivePerson(person)} + className="bg-white w-full shadow-sm border border-black/5 bg-clip-padding rounded overflow-hidden" + > + {({ activeOption, open }) => { + return ( +
+ setQuery(e.target.value)} + className="border-none outline-none px-3 py-1 rounded-none w-full" + placeholder="Search users…" + displayValue={item => item?.name} + /> +
+ + {people.map(person => ( + { + return classNames( + 'flex relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none space-x-4', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + + {person.name} + + {active && ( + + + + + + )} + + )} + + ))} + + + {people.length === 0 ? ( +
No person selected
+ ) : activeOption === null ? null : ( +
+
+
+ +
{activeOption.name}
+
Obviously cool person
+
+
+
+ )} +
+
+ ) + }} +
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/dialog/dialog.tsx b/packages/playground-react/pages/dialog/dialog.tsx new file mode 100644 index 0000000..7187c97 --- /dev/null +++ b/packages/playground-react/pages/dialog/dialog.tsx @@ -0,0 +1,238 @@ +import React, { useState, Fragment } from 'react' +import { Dialog, Menu, Portal, Transition } from '@headlessui/react' +import { usePopper } from '../../utils/hooks/use-popper' +import { classNames } from '../../utils/class-names' + +function resolveClass({ active, disabled }) { + return classNames( + 'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left', + active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', + disabled && 'cursor-not-allowed opacity-50' + ) +} + +function Nested({ onClose, level = 0 }) { + let [showChild, setShowChild] = useState(false) + + return ( + <> + + +
+

Level: {level}

+
+ + + +
+
+ {showChild && setShowChild(false)} level={level + 1} />} +
+ + ) +} + +export default function Home() { + let [isOpen, setIsOpen] = useState(false) + let [nested, setNested] = useState(false) + + let [trigger, container] = usePopper({ + placement: 'bottom-end', + strategy: 'fixed', + modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], + }) + + return ( + <> + + + + {nested && setNested(false)} />} + + console.log('done')}> + +
+
+ + + + + + {/* This element is to trick the browser into centering the modal contents. */} + +
+
+
+
+ {/* Heroicon name: exclamation */} + +
+
+ + Deactivate account + +
+

+ Are you sure you want to deactivate your account? All of your data will + be permanently removed. This action cannot be undone. +

+
+ + + + Choose a reason + + + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + Support + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + ) +} diff --git a/packages/playground-react/pages/disclosure/disclosure.tsx b/packages/playground-react/pages/disclosure/disclosure.tsx new file mode 100644 index 0000000..856beb2 --- /dev/null +++ b/packages/playground-react/pages/disclosure/disclosure.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Disclosure, Transition } from '@headlessui/react' + +export default function Home() { + return ( +
+
+ + Trigger + + + Content + + +
+
+ ) +} diff --git a/packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx b/packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx new file mode 100644 index 0000000..f7f632f --- /dev/null +++ b/packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react' +import { Listbox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let people = [ + 'Wade Cooper', + 'Arlene Mccoy', + 'Devon Webb', + 'Tom Cook', + 'Tanya Fox', + 'Hellen Schmidt', + 'Caroline Schultz', + 'Mason Heaney', + 'Claudie Smitham', + 'Emil Schaefer', +] + +export default function Home() { + let [active, setActivePerson] = useState(people[2]) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(people[Math.floor(Math.random() * people.length)]) + }, []) + + return ( +
+
+
+ { + console.log('value:', value) + setActivePerson(value) + }} + > + + Assigned to + + +
+ + + {active} + + + + + + + + +
+ + {people.map(name => ( + { + return classNames( + 'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {name} + + {selected && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/listbox/multiple-elements.tsx b/packages/playground-react/pages/listbox/multiple-elements.tsx new file mode 100644 index 0000000..b68eb75 --- /dev/null +++ b/packages/playground-react/pages/listbox/multiple-elements.tsx @@ -0,0 +1,134 @@ +import React, { useState, useEffect } from 'react' +import { Listbox } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +let people = [ + 'Wade Cooper', + 'Arlene Mccoy', + 'Devon Webb', + 'Tom Cook', + 'Tanya Fox', + 'Hellen Schmidt', + 'Caroline Schultz', + 'Mason Heaney', + 'Claudie Smitham', + 'Emil Schaefer', +] + +export default function Home() { + return ( +
+ + +
+ +
+ +
+
+ + +
+ ) +} + +function PeopleList() { + let [active, setActivePerson] = useState(people[2]) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(people[Math.floor(Math.random() * people.length)]) + }, []) + + return ( +
+
+ { + console.log('value:', value) + setActivePerson(value) + }} + > + + Assigned to + + +
+ + + {active} + + + + + + + + +
+ + {people.map(name => ( + { + return classNames( + 'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {name} + + {selected && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/menu/menu-with-framer-motion.tsx b/packages/playground-react/pages/menu/menu-with-framer-motion.tsx new file mode 100644 index 0000000..c146740 --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-framer-motion.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import Link from 'next/link' +import { Menu } from '@headlessui/react' +import { AnimatePresence, motion } from 'framer-motion' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + return ( +
+
+ + {({ open }) => ( + <> + + + Options + + + + + + + + {open && ( + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ Account settings + + Support + + + New feature (soon) + + License +
+ +
+ +
+
+ )} +
+ + )} +
+
+
+ ) +} + +function NextLink(props: React.ComponentProps<'a'>) { + let { href, children, ...rest } = props + return ( + + {children} + + ) +} + +function SignOutButton(props) { + return ( +
{ + e.preventDefault() + alert('SIGNED OUT') + }} + className="w-full" + > + +
+ ) +} + +function Item(props: React.ComponentProps) { + return ( + + classNames( + 'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700', + active && 'bg-gray-100 text-gray-900', + disabled && 'cursor-not-allowed opacity-50' + ) + } + {...props} + /> + ) +} diff --git a/packages/playground-react/pages/menu/menu-with-popper.tsx b/packages/playground-react/pages/menu/menu-with-popper.tsx new file mode 100644 index 0000000..cd38588 --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-popper.tsx @@ -0,0 +1,95 @@ +import React, { ReactNode, useState, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { Menu } from '@headlessui/react' + +import { usePopper } from '../../utils/hooks/use-popper' +import { classNames } from '../../utils/class-names' + +export default function Home() { + let [trigger, container] = usePopper({ + placement: 'bottom-end', + strategy: 'fixed', + modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], + }) + + function resolveClass({ active, disabled }) { + return classNames( + 'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700', + active && 'bg-gray-100 text-gray-900', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+
+ + + + Options + + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + {data => ( + + Support + + )} + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+
+
+ ) +} + +function Portal(props: { children: ReactNode }) { + let { children } = props + let [mounted, setMounted] = useState(false) + + useEffect(() => setMounted(true), []) + + if (!mounted) return null + return createPortal(children, document.body) +} diff --git a/packages/playground-react/pages/menu/menu-with-transition-and-popper.tsx b/packages/playground-react/pages/menu/menu-with-transition-and-popper.tsx new file mode 100644 index 0000000..c801431 --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-transition-and-popper.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { Menu, Transition } from '@headlessui/react' + +import { usePopper } from '../../utils/hooks/use-popper' +import { classNames } from '../../utils/class-names' + +export default function Home() { + let [trigger, container] = usePopper({ + placement: 'bottom-end', + strategy: 'fixed', + modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], + }) + + function resolveClass({ active, disabled }) { + return classNames( + 'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left', + active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+
+ + + + Options + + + + + + +
+ + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + {data => ( + + Support + + )} + + + New feature (soon) + + + License + +
+
+ + Sign out + +
+
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/menu/menu-with-transition.tsx b/packages/playground-react/pages/menu/menu-with-transition.tsx new file mode 100644 index 0000000..950d651 --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-transition.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Menu, Transition } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +export default function Home() { + function resolveClass({ active, disabled }) { + return classNames( + 'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left', + active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+
+ + + + Options + + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + Support + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/menu/menu.tsx b/packages/playground-react/pages/menu/menu.tsx new file mode 100644 index 0000000..acade92 --- /dev/null +++ b/packages/playground-react/pages/menu/menu.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { Menu } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + return ( +
+
+ + + + Options + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ Account settings + Support + + New feature (soon) + + License +
+
+ Sign out +
+
+
+
+
+ ) +} + +function CustomMenuItem(props: React.ComponentProps) { + return ( + + {({ active, disabled }) => ( + + {props.children} + ⌘K + + )} + + ) +} diff --git a/packages/playground-react/pages/menu/multiple-elements.tsx b/packages/playground-react/pages/menu/multiple-elements.tsx new file mode 100644 index 0000000..7110acf --- /dev/null +++ b/packages/playground-react/pages/menu/multiple-elements.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { Menu } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +export default function Home() { + return ( +
+ + +
+
+ +
+
+ + +
+ ) +} + +function Dropdown() { + function resolveClass({ active, disabled }) { + return classNames( + 'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700', + active && 'bg-gray-100 text-gray-900', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+ + + + Options + + + + + + + +
+

Signed in as

+

tom@example.com

+
+ +
+ + Account settings + + + {data => ( + + Support + + )} + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/popover/popover.tsx b/packages/playground-react/pages/popover/popover.tsx new file mode 100644 index 0000000..c11be5e --- /dev/null +++ b/packages/playground-react/pages/popover/popover.tsx @@ -0,0 +1,116 @@ +import React, { forwardRef, Fragment } from 'react' +import { Popover, Portal, Transition } from '@headlessui/react' +import { usePopper } from '../../utils/hooks/use-popper' + +let Button = forwardRef( + (props: React.ComponentProps<'button'>, ref: React.MutableRefObject) => { + return ( + + ) + } +) + +function Link(props: React.ComponentProps<'a'>) { + return ( + + {props.children} + + ) +} + +export default function Home() { + let options = { + placement: 'bottom-start' as const, + strategy: 'fixed' as const, + modifiers: [], + } + + let [reference1, popper1] = usePopper(options) + let [reference2, popper2] = usePopper(options) + + let links = ['First', 'Second', 'Third', 'Fourth'] + + return ( +
+ + + + + + + + + + Normal + + + {links.map((link, i) => ( + + Normal - {link} + + ))} + + + + + + + {links.map((link, i) => ( + Focus - {link} + ))} + + + + + + + + {links.map(link => ( + Portal - {link} + ))} + + + + + + + + + {links.map(link => ( + Focus in Portal - {link} + ))} + + + + + + +
+ ) +} diff --git a/packages/playground-react/pages/radio-group/radio-group.tsx b/packages/playground-react/pages/radio-group/radio-group.tsx new file mode 100644 index 0000000..e92c54f --- /dev/null +++ b/packages/playground-react/pages/radio-group/radio-group.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react' +import { RadioGroup } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +export default function Home() { + let access = [ + { + id: 'access-1', + name: 'Public access', + description: 'This project would be available to anyone who has the link', + }, + { + id: 'access-2', + name: 'Private to Project Members', + description: 'Only members of this project would be able to access', + }, + { + id: 'access-3', + name: 'Private to you', + description: 'You are the only one able to access this project', + }, + ] + let [active, setActive] = useState() + + return ( +
+ Link before + +
+ +

Privacy setting

+
+ +
+ {access.map(({ id, name, description }, i) => { + return ( + + classNames( + // Rounded corners + i === 0 && 'rounded-tl-md rounded-tr-md', + access.length - 1 === i && 'rounded-bl-md rounded-br-md', + + // Shared + 'relative border p-4 flex focus:outline-none', + active ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200' + ) + } + > + {({ active, checked }) => ( +
+
+ + {name} + + + {description} + +
+
+ {checked && ( + + + + )} +
+
+ )} +
+ ) + })} +
+
+
+ Link after +
+ ) +} diff --git a/packages/playground-react/pages/switch/switch-with-pure-tailwind.tsx b/packages/playground-react/pages/switch/switch-with-pure-tailwind.tsx new file mode 100644 index 0000000..65cb003 --- /dev/null +++ b/packages/playground-react/pages/switch/switch-with-pure-tailwind.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react' +import { Switch } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + let [state, setState] = useState(false) + + return ( +
+ + Enable notifications + + + classNames( + 'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + <> + + + )} + + +
+ ) +} diff --git a/packages/playground-react/pages/tabs/tabs-with-pure-tailwind.tsx b/packages/playground-react/pages/tabs/tabs-with-pure-tailwind.tsx new file mode 100644 index 0000000..beac2e2 --- /dev/null +++ b/packages/playground-react/pages/tabs/tabs-with-pure-tailwind.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { Tab, Switch } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + let tabs = [ + { name: 'My Account', content: 'Tab content for my account' }, + { name: 'Company', content: 'Tab content for company', disabled: true }, + { name: 'Team Members', content: 'Tab content for team members' }, + { name: 'Billing', content: 'Tab content for billing' }, + ] + + let [manual, setManual] = useState(false) + + return ( +
+ + Manual keyboard activation + + + classNames( + 'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + + )} + + + + + + {tabs.map((tab, tabIdx) => ( + + classNames( + selected ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700', + tabIdx === 0 ? 'rounded-l-lg' : '', + tabIdx === tabs.length - 1 ? 'rounded-r-lg' : '', + tab.disabled && 'opacity-50', + 'group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-sm font-medium text-center hover:bg-gray-50 focus:z-10' + ) + } + > + {({ selected }) => ( + <> + {tab.name} + {tab.disabled && (disabled)} + + ))} + + + + {tabs.map(tab => ( + + {tab.content} + + ))} + + +
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/dropdown.tsx b/packages/playground-react/pages/transitions/component-examples/dropdown.tsx new file mode 100644 index 0000000..e391543 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/dropdown.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import Head from 'next/head' +import { Transition } from '@headlessui/react' + +export default function Home() { + return ( + <> + + Transition Component - Playground + + +
+ +
+ + ) +} + +function Dropdown() { + let [isOpen, setIsOpen] = useState(false) + + return ( +
+
+ + + +
+ + + + +
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/modal.tsx b/packages/playground-react/pages/transitions/component-examples/modal.tsx new file mode 100644 index 0000000..cd03991 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/modal.tsx @@ -0,0 +1,168 @@ +import React, { useRef, useState } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(false) + function toggle() { + setIsOpen(v => !v) + } + + let [email, setEmail] = useState('') + let [events, setEvents] = useState([]) + let inputRef = useRef(null) + + function addEvent(name) { + setEvents(existing => [...existing, `${new Date().toJSON()} - ${name}`]) + } + + return ( +
+
+
+ + + +
+ +
    +

    Events:

    + {events.map((event, i) => ( +
  • + {event} +
  • + ))} +
+
+ + { + addEvent('Before enter') + }} + afterEnter={() => { + inputRef.current.focus() + addEvent('After enter') + }} + beforeLeave={() => { + addEvent('Before leave (before confirm)') + window.confirm('Are you sure?') + addEvent('Before leave (after confirm)') + }} + afterLeave={() => { + addEvent('After leave (before alert)') + window.alert('Consider it done!') + addEvent('After leave (after alert)') + setEmail('') + }} + > +
+ +
+
+
+
+ {/* This element is to trick the browser into centering the modal contents. */} + ​ + +
+
+
+ {/* Heroicon name: exclamation */} + + + +
+
+ +
+

+ Are you sure you want to deactivate your account? All of your data will be + permanently removed. This action cannot be undone. +

+
+
+
+ +
+ setEmail(event.target.value)} + id="email" + className="block w-full px-3 form-input sm:text-sm sm:leading-5" + placeholder="name@example.com" + /> +
+
+
+
+
+
+
+ + + + + + +
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/nested/hidden.tsx b/packages/playground-react/pages/transitions/component-examples/nested/hidden.tsx new file mode 100644 index 0000000..989f266 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/nested/hidden.tsx @@ -0,0 +1,60 @@ +import React, { useState, ReactNode } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + ) +} + +function Box({ children }: { children?: ReactNode }) { + return ( + +
+ This is a box + {children} +
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/nested/unmount.tsx b/packages/playground-react/pages/transitions/component-examples/nested/unmount.tsx new file mode 100644 index 0000000..cf74435 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/nested/unmount.tsx @@ -0,0 +1,60 @@ +import React, { useState, ReactNode } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + ) +} + +function Box({ children }: { children?: ReactNode }) { + return ( + +
+ This is a box + {children} +
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx b/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx new file mode 100644 index 0000000..29b4b23 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + Contents to show and hide + +
+
+ + ) +} diff --git a/packages/playground-react/pages/transitions/full-page-examples/full-page-transition.tsx b/packages/playground-react/pages/transitions/full-page-examples/full-page-transition.tsx new file mode 100644 index 0000000..49cc646 --- /dev/null +++ b/packages/playground-react/pages/transitions/full-page-examples/full-page-transition.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useRef, useState } from 'react' +import Head from 'next/head' +import { Transition } from '@headlessui/react' + +import { classNames } from '../../../utils/class-names' +import { match } from '../../../utils/match' + +export default function Shell() { + return ( + <> + + Transition Component - Full Page Transition + +
+
+ +
+
+ + ) +} + +function usePrevious(value: T) { + let ref = useRef(value) + useEffect(() => { + ref.current = value + }, [value]) + return ref.current +} + +enum Direction { + Forwards = ' -> ', + Backwards = ' <- ', +} + +let pages = ['Dashboard', 'Team', 'Projects', 'Calendar', 'Reports'] +let colors = [ + 'bg-gradient-to-r from-teal-400 to-blue-400', + 'bg-gradient-to-r from-blue-400 to-orange-400', + 'bg-gradient-to-r from-orange-400 to-purple-400', + 'bg-gradient-to-r from-purple-400 to-green-400', + 'bg-gradient-to-r from-green-400 to-teal-400', +] + +function FullPageTransition() { + let [activePage, setActivePage] = useState(0) + let previousPage = usePrevious(activePage) + + let direction = activePage > previousPage ? Direction.Forwards : Direction.Backwards + + let transitions = match(direction, { + [Direction.Forwards]: { + enter: 'transition transform ease-in-out duration-500', + enterFrom: 'translate-x-full', + enterTo: 'translate-x-0', + leave: 'transition transform ease-in-out duration-500', + leaveFrom: 'translate-x-0', + leaveTo: '-translate-x-full', + }, + [Direction.Backwards]: { + enter: 'transition transform ease-in-out duration-500', + enterFrom: '-translate-x-full', + enterTo: 'translate-x-0', + leave: 'transition transform ease-in-out duration-500', + leaveFrom: 'translate-x-0', + leaveTo: 'translate-x-full', + }, + }) + + return ( +
+
+ +
+
+

+ {pages[activePage]} +

+
+
+
+ +
+
+
+
+ {pages.map((page, i) => ( + + {page} page content + + ))} +
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx b/packages/playground-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx new file mode 100644 index 0000000..0cc33e0 --- /dev/null +++ b/packages/playground-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState } from 'react' +import Head from 'next/head' +import { Transition } from '@headlessui/react' + +export default function App() { + let [mobileOpen, setMobileOpen] = useState(false) + + useEffect(() => { + function handleEscape(event) { + if (!mobileOpen) return + + if (event.key === 'Escape') { + setMobileOpen(false) + } + } + + document.addEventListener('keyup', handleEscape) + return () => document.removeEventListener('keyup', handleEscape) + }, [mobileOpen]) + + return ( + <> + + Transition Component - Layout with sidebar + + +
+ {/* Off-canvas menu for mobile */} + + {/* Off-canvas menu overlay, show/hide based on off-canvas menu state. */} + + {() => ( +
+
setMobileOpen(false)} + className="absolute inset-0 opacity-75 bg-cool-gray-600" + /> +
+ )} + + + {/* Off-canvas menu, show/hide based on off-canvas menu state. */} + +
+ setMobileOpen(false)} + > + + + + +
+
+ Easywire logo +
+
+
+ {/* Dummy element to force sidebar to shrink to fit close icon */} +
+ + + {/* Static sidebar for desktop */} +
+
+ {/* Sidebar component, swap this element with another sidebar if you like */} +
+
+ Easywire logo +
+
+
+
+
+
+ + {/* Search bar */} +
+
+
+ +
+
+ + + +
+ +
+
+
+
+
+
+ {/* Replace with your content */} +
+ {/* /End replace */} +
+
+
+ + ) +} diff --git a/packages/playground-react/public/favicon.ico b/packages/playground-react/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/packages/playground-react/tsconfig.json b/packages/playground-react/tsconfig.json new file mode 100644 index 0000000..6db37c0 --- /dev/null +++ b/packages/playground-react/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/playground-react/utils/class-names.ts b/packages/playground-react/utils/class-names.ts new file mode 100644 index 0000000..159b03c --- /dev/null +++ b/packages/playground-react/utils/class-names.ts @@ -0,0 +1,3 @@ +export function classNames(...classes: (false | null | undefined | string)[]): string { + return classes.filter(Boolean).join(' ') +} diff --git a/packages/playground-react/utils/hooks/use-popper.ts b/packages/playground-react/utils/hooks/use-popper.ts new file mode 100644 index 0000000..4d10885 --- /dev/null +++ b/packages/playground-react/utils/hooks/use-popper.ts @@ -0,0 +1,37 @@ +import { RefCallback, useRef, useCallback, useMemo } from 'react' +import { createPopper, Options } from '@popperjs/core' + +/** + * Example implementation to use Popper: https://popper.js.org/ + */ +export function usePopper( + options?: Partial +): [RefCallback, RefCallback] { + let reference = useRef(null) + let popper = useRef(null) + + let cleanupCallback = useRef(() => {}) + + let instantiatePopper = useCallback(() => { + if (!reference.current) return + if (!popper.current) return + + if (cleanupCallback.current) cleanupCallback.current() + + cleanupCallback.current = createPopper(reference.current, popper.current, options).destroy + }, [reference, popper, cleanupCallback, options]) + + return useMemo( + () => [ + referenceDomNode => { + reference.current = referenceDomNode + instantiatePopper() + }, + popperDomNode => { + popper.current = popperDomNode + instantiatePopper() + }, + ], + [reference, popper, instantiatePopper] + ) +} diff --git a/packages/playground-react/utils/match.ts b/packages/playground-react/utils/match.ts new file mode 100644 index 0000000..80496d1 --- /dev/null +++ b/packages/playground-react/utils/match.ts @@ -0,0 +1,20 @@ +export function match( + value: TValue, + lookup: Record TReturnValue)>, + ...args: any[] +): TReturnValue { + if (value in lookup) { + let returnValue = lookup[value] + return typeof returnValue === 'function' ? returnValue(...args) : returnValue + } + + let error = new Error( + `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys( + lookup + ) + .map(key => `"${key}"`) + .join(', ')}.` + ) + if (Error.captureStackTrace) Error.captureStackTrace(error, match) + throw error +} diff --git a/packages/playground-react/utils/resolve-all-examples.ts b/packages/playground-react/utils/resolve-all-examples.ts new file mode 100644 index 0000000..328da90 --- /dev/null +++ b/packages/playground-react/utils/resolve-all-examples.ts @@ -0,0 +1,50 @@ +import fs from 'fs' +import path from 'path' +export type ExamplesType = { + name: string + path: string + children?: ExamplesType[] +} + +export async function resolveAllExamples(...paths: string[]) { + let base = path.resolve(process.cwd(), ...paths) + + if (!fs.existsSync(base)) { + return false + } + + let files = await fs.promises.readdir(base, { withFileTypes: true }) + let items: ExamplesType[] = [] + + for (let file of files) { + if (file.name === '.DS_Store') { + continue + } + + // Skip reserved filenames from Next. E.g.: _app.tsx, _error.tsx + if (file.name.startsWith('_')) { + continue + } + + let bucket: ExamplesType = { + name: file.name.replace(/-/g, ' ').replace(/\.tsx?/g, ''), + path: [...paths, file.name] + .join('/') + .replace(/^pages/, '') + .replace(/\.tsx?/g, '') + .replace(/\/+/g, '/'), + } + + if (file.isDirectory()) { + let children = await resolveAllExamples(...paths, file.name) + + if (children) { + bucket.children = children + } + } + + items.push(bucket) + } + + return items +} diff --git a/yarn.lock b/yarn.lock index c693813..0739cde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -195,6 +195,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== +"@babel/helper-plugin-utils@^7.14.5": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" + integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== + "@babel/helper-regex@^7.10.4": version "7.10.5" resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" @@ -244,7 +249,7 @@ dependencies: "@babel/types" "^7.11.0" -"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.16.7": +"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== @@ -443,6 +448,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-syntax-jsx@7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.14.5.tgz#000e2e25d8673cce49300517a3eda44c263e4201" + integrity sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -874,6 +886,14 @@ globals "^11.1.0" lodash "^4.17.19" +"@babel/types@7.15.0": + version "7.15.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd" + integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ== + dependencies: + "@babel/helper-validator-identifier" "^7.14.9" + to-fast-properties "^2.0.0" + "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.11.5" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" @@ -904,6 +924,18 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@emotion/is-prop-valid@^0.8.2": + version "0.8.8" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" + integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== + dependencies: + "@emotion/memoize" "0.7.4" + +"@emotion/memoize@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" + integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1099,6 +1131,76 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@next/env@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/env/-/env-12.0.8.tgz#a32ca0a97d464307f2e6ff106ce09b19aac108cf" + integrity sha512-Wa0gOeioB9PHap9wtZDZEhgOSE3/+qE/UALWjJHuNvH4J3oE+13EjVOiEsr1JcPCXUN8ESQE+phDKlo6qJ8P9g== + +"@next/react-refresh-utils@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/react-refresh-utils/-/react-refresh-utils-12.0.8.tgz#481760a95ef442abd091663db6582d4dc1b31f06" + integrity sha512-Bq4T/aOOFQUkCF9b8k9x+HpjOevu65ZPxsYJOpgEtBuJyvb+sZREtDDLKb/RtjUeLMrWrsGD0aLteyFFtiS8Og== + +"@next/swc-android-arm64@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.0.8.tgz#f8dc9663da367a75982730cac058339fb310d79a" + integrity sha512-BiXMcOZNnXSIXv+FQvbRgbMb+iYayLX/Sb2MwR0wja+eMs46BY1x/ssXDwUBADP1M8YtrGTlSPHZqUiCU94+Mg== + +"@next/swc-darwin-arm64@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.0.8.tgz#d6aced7d0a04815dd1324e7982accb3de6a643e8" + integrity sha512-6EGMmvcIwPpwt0/iqLbXDGx6oKHAXzbowyyVXK8cqmIvhoghRFjqfiNGBs+ar6wEBGt68zhwn/77vE3iQWoFJw== + +"@next/swc-darwin-x64@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.0.8.tgz#f4fe58d2ed852538410b15a0c80d78908050c716" + integrity sha512-todxgQOGP/ucz5UH2kKR3XGDdkWmWr0VZAAbzgTbiFm45Ol4ih602k2nNR3xSbza9IqNhxNuUVsMpBgeo19CFQ== + +"@next/swc-linux-arm-gnueabihf@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.0.8.tgz#2c02d824fb46e8c6094d7e758c5d7e965070f574" + integrity sha512-KULmdrfI+DJxBuhEyV47MQllB/WpC3P2xbwhHezxL/LkC2nkz5SbV4k432qpx2ebjIRf9SjdQ5Oz1FjD8Urayw== + +"@next/swc-linux-arm64-gnu@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.0.8.tgz#fc32caf3373b299558ede1d889e8555b9ba10ffb" + integrity sha512-1XO87wgIVPvt5fx5i8CqdhksRdcpqyzCOLW4KrE0f9pUCIT04EbsFiKdmsH9c73aqjNZMnCMXpbV+cn4hN8x1w== + +"@next/swc-linux-arm64-musl@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.0.8.tgz#c2d3d7bc2c34da81412b74bdd6e11d0615ae1886" + integrity sha512-NStRZEy/rkk2G18Yhc/Jzi1Q2Dv+zH176oO8479zlDQ5syRfc6AvRHVV4iNRc8Pai58If83r/nOJkwFgGwkKLw== + +"@next/swc-linux-x64-gnu@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.0.8.tgz#029d84f856801b818e5525ab1406f2446821d48c" + integrity sha512-rHxTGtTEDFsdT9/VjewzxE19S7W1NE+aZpm4TwbT1pSNGK9KQxQGcXjqoHMeB+VZCFknzNEoIU/vydbjZMlAuw== + +"@next/swc-linux-x64-musl@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.0.8.tgz#db572da90ab3bce0bc58595c6b8c2d32ec64a2d3" + integrity sha512-1F4kuFRQE10GSx7LMSvRmjMXFGpxT30g8rZzq9r/p/WKdErA4WB4uxaKEX0P8AINfuN63i4luKdR+LoacgBhYw== + +"@next/swc-win32-arm64-msvc@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.0.8.tgz#f33e2e56a96489935f87c6dd28f79a7b7ed3778f" + integrity sha512-QuRe49jqCV61TysGopC1P0HPqFAMZMWe1nbIQLyOkDLkULmZR8N2eYZq7fwqvZE5YwhMmJA/grwWFVBqSEh5Kg== + +"@next/swc-win32-ia32-msvc@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.0.8.tgz#0f6c7f3e50fc1a4752aed5c862f53c86ce77e3b8" + integrity sha512-0RV3/julybJr1IlPCowIWrJJZyAl+sOakJEM15y1NOOsbwTQ5eKZZXSi+7e23TN4wmy5HwNvn2dKzgOEVJ+jbA== + +"@next/swc-win32-x64-msvc@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.0.8.tgz#eae6d4c94dc8aae8ba177e2de02080339d0d4563" + integrity sha512-tTga6OFfO2JS+Yt5hdryng259c/tzNgSWkdiU2E+RBHiysAIOta57n4PJ8iPahOSqEqjaToPI76wM+o441GaNQ== + +"@popperjs/core@^2.6.0": + version "2.11.2" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.2.tgz#830beaec4b4091a9e9398ac50f865ddea52186b9" + integrity sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA== + "@rollup/plugin-babel@^5.1.0": version "5.2.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.1.tgz#20fc8f8864dc0eaa1c5578408459606808f72924" @@ -1936,6 +2038,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2048,6 +2155,11 @@ caniuse-lite@^1.0.30001131: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001191.tgz" integrity sha512-xJJqzyd+7GCJXkcoBiQ1GuxEiOBCLQ0aVW9HMekifZsAVGdj5eJ4mFB9fEhSHipq9IOk/QXFJUiIr9lZT+EsGw== +caniuse-lite@^1.0.30001283: + version "1.0.30001300" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz#11ab6c57d3eb6f964cba950401fd00a146786468" + integrity sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA== + capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" @@ -2211,6 +2323,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +colorette@^1.2.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" + integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== + colorette@^2.0.16: version "2.0.16" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da" @@ -2280,7 +2397,7 @@ contains-path@^0.1.0: resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= -convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== @@ -2603,6 +2720,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -3221,6 +3343,26 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +framer-motion@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.0.0.tgz#08e44c42b67c967774a197b3994f8475cd486c32" + integrity sha512-wVI+hVRkvQeWSvkxk8z5bTg+jBs9vfEZOist2s0e9tQzZvt+OBuAoAcvCvl+ADmFd4ncC2934vkwiJPZ8nSvMg== + dependencies: + framesync "6.0.1" + hey-listen "^1.0.8" + popmotion "11.0.3" + style-value-types "5.0.0" + tslib "^2.1.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + +framesync@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20" + integrity sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA== + dependencies: + tslib "^2.1.0" + fs-extra@8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" @@ -3440,6 +3582,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + hosted-git-info@^2.1.4: version "2.8.8" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" @@ -3551,7 +3698,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.4: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4383,6 +4530,15 @@ jest-watcher@^25.2.4, jest-watcher@^25.5.0: jest-util "^25.5.0" string-length "^3.1.0" +jest-worker@27.0.0-next.5: + version "27.0.0-next.5" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.0.0-next.5.tgz#5985ee29b12a4e191f4aae4bb73b97971d86ec28" + integrity sha512-mk0umAQ5lT+CaOJ+Qp01N6kz48sJG2kr2n1rX0koqKf6FIygQV0qLOdN9SCYID4IVeSigDOcPeGLozdMLYfb5g== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" + jest-worker@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5" @@ -4679,6 +4835,15 @@ load-json-file@^2.0.0: pify "^2.0.0" strip-bom "^3.0.0" +loader-utils@1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" + integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA== + dependencies: + big.js "^5.2.2" + emojis-list "^2.0.0" + json5 "^1.0.1" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -4943,6 +5108,11 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +nanoid@^3.1.23: + version "3.2.0" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" + integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -4965,6 +5135,35 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +next@^12.0.8: + version "12.0.8" + resolved "https://registry.yarnpkg.com/next/-/next-12.0.8.tgz#29138f7cdd045e4bbba466af45bf430e769634b4" + integrity sha512-g5c1Kuh1F8tSXJn2rVvzYBzqe9EXaR6+rY3/KrQ7y0D9FueRLfHI35wM0DRadDcPSc3+vncspfhYH3jnYE/KjA== + dependencies: + "@next/env" "12.0.8" + "@next/react-refresh-utils" "12.0.8" + caniuse-lite "^1.0.30001283" + jest-worker "27.0.0-next.5" + node-fetch "2.6.1" + postcss "8.2.15" + react-is "17.0.2" + react-refresh "0.8.3" + stream-browserify "3.0.0" + styled-jsx "5.0.0-beta.6" + use-subscription "1.5.1" + optionalDependencies: + "@next/swc-android-arm64" "12.0.8" + "@next/swc-darwin-arm64" "12.0.8" + "@next/swc-darwin-x64" "12.0.8" + "@next/swc-linux-arm-gnueabihf" "12.0.8" + "@next/swc-linux-arm64-gnu" "12.0.8" + "@next/swc-linux-arm64-musl" "12.0.8" + "@next/swc-linux-x64-gnu" "12.0.8" + "@next/swc-linux-x64-musl" "12.0.8" + "@next/swc-win32-arm64-msvc" "12.0.8" + "@next/swc-win32-ia32-msvc" "12.0.8" + "@next/swc-win32-x64-msvc" "12.0.8" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -4978,6 +5177,11 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5414,11 +5618,30 @@ pn@^1.1.0: resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== +popmotion@11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9" + integrity sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA== + dependencies: + framesync "6.0.1" + hey-listen "^1.0.8" + style-value-types "5.0.0" + tslib "^2.1.0" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= +postcss@8.2.15: + version "8.2.15" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.15.tgz#9e66ccf07292817d226fc315cbbf9bc148fbca65" + integrity sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q== + dependencies: + colorette "^1.2.2" + nanoid "^3.1.23" + source-map "^0.6.1" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -5556,7 +5779,7 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -react-dom@^16.14.0: +react-dom@16.14.0, react-dom@^16.14.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== @@ -5566,17 +5789,22 @@ react-dom@^16.14.0: prop-types "^15.6.2" scheduler "^0.19.1" +react-is@17.0.2, react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-is@^16.12.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-refresh@0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" + integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== -react@^16.14.0: +react@16.14.0, react@^16.14.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== @@ -5621,6 +5849,15 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" +readable-stream@^3.5.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + realpath-native@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-2.0.0.tgz#7377ac429b6e1fd599dc38d08ed942d0d7beb866" @@ -5938,7 +6175,7 @@ sade@^1.4.2: dependencies: mri "^1.1.0" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -6219,6 +6456,11 @@ source-map-url@^0.4.0: resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= +source-map@0.7.3, source-map@^0.7.3: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + source-map@^0.5.0, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" @@ -6229,11 +6471,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@^0.7.3: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - sourcemap-codec@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" @@ -6317,11 +6554,24 @@ stealthy-require@^1.1.1: resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= +stream-browserify@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== +string-hash@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" + integrity sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs= + string-length@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-3.1.0.tgz#107ef8c23456e187a8abd4a61162ff4ac6e25837" @@ -6393,6 +6643,13 @@ string.prototype.trimstart@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + strip-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" @@ -6453,6 +6710,38 @@ strip-json-comments@^3.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-value-types@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-5.0.0.tgz#76c35f0e579843d523187989da866729411fc8ad" + integrity sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA== + dependencies: + hey-listen "^1.0.8" + tslib "^2.1.0" + +styled-jsx@5.0.0-beta.6: + version "5.0.0-beta.6" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.0-beta.6.tgz#666552f8831a06f80c9084a47afc4b32b0c9f461" + integrity sha512-b1cM7Xyp2r1lsNpvoZ6wmTI8qxD0557vH2feHakNU8LMkzfJDgTQMul6O7sSYY0GxQ73pKEN69hCDp71w6Q0nA== + dependencies: + "@babel/plugin-syntax-jsx" "7.14.5" + "@babel/types" "7.15.0" + convert-source-map "1.7.0" + loader-utils "1.2.3" + source-map "0.7.3" + string-hash "1.1.3" + stylis "3.5.4" + stylis-rule-sheet "0.0.10" + +stylis-rule-sheet@0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + integrity sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw== + +stylis@3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -6474,6 +6763,13 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + supports-color@^9.2.1: version "9.2.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.2.1.tgz#599dc9d45acf74c6176e0d880bab1d7d718fe891" @@ -6863,11 +7159,23 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= +use-subscription@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" + integrity sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA== + dependencies: + object-assign "^4.1.1" + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"