From 8553808f8255f37d67de049000b3dfa99eeb880b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Oct 2020 16:56:38 +0200 Subject: [PATCH 01/15] add unmount strategy to README (React) --- packages/@headlessui-react/README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/@headlessui-react/README.md b/packages/@headlessui-react/README.md index 2f6722a11a..9e821d84a0 100644 --- a/packages/@headlessui-react/README.md +++ b/packages/@headlessui-react/README.md @@ -726,10 +726,13 @@ function MyDropdown() { ##### Props -| Prop | Type | Default | Description | -| -------- | ------------------- | ------- | --------------------------------------------------------------------------- | -| `as` | String \| Component | `div` | The element or component the `Menu.Items` should render as. | -| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| Prop | Type | Default | Description | +| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- | +| `as` | String \| Component | `div` | The element or component the `Menu.Items` should render as. | +| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. | + +> **note**: `static` and `unmount` can not be used at the same time. You will get a TypeScript error if you try to do it. ##### Render prop object @@ -1231,10 +1234,13 @@ function MyListbox() { ##### Props -| Prop | Type | Default | Description | -| -------- | ------------------- | ------- | --------------------------------------------------------------------------- | -| `as` | String \| Component | `ul` | The element or component the `Listbox.Options` should render as. | -| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| Prop | Type | Default | Description | +| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- | +| `as` | String \| Component | `ul` | The element or component the `Listbox.Options` should render as. | +| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. | + +> **note**: `static` and `unmount` can not be used at the same time. You will get a TypeScript error if you try to do it. ##### Render prop object From 797e38414163d04c5f90738ea26e273050b253e0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Oct 2020 16:57:36 +0200 Subject: [PATCH 02/15] add unmount strategy to README (Vue) --- packages/@headlessui-vue/README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/@headlessui-vue/README.md b/packages/@headlessui-vue/README.md index 74fef41e50..5a48a2877c 100644 --- a/packages/@headlessui-vue/README.md +++ b/packages/@headlessui-vue/README.md @@ -338,10 +338,11 @@ To tell an element to render its children directly with no wrapper element, use ##### Props -| Prop | Type | Default | Description | -| -------- | ------------------- | ------- | --------------------------------------------------------------------------- | -| `as` | String \| Component | `div` | The element or component the `MenuItems` should render as. | -| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| Prop | Type | Default | Description | +| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- | +| `as` | String \| Component | `div` | The element or component the `MenuItems` should render as. | +| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. | ##### Slot props @@ -1029,10 +1030,11 @@ export default { ##### Props -| Prop | Type | Default | Description | -| -------- | ------------------- | ------- | --------------------------------------------------------------------------- | -| `as` | String \| Component | `ul` | The element or component the `ListboxOptions` should render as. | -| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| Prop | Type | Default | Description | +| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- | +| `as` | String \| Component | `ul` | The element or component the `ListboxOptions` should render as. | +| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | +| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. | ##### Slot props From 766c45a823ab2d073be5349ccbff315628095140 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Oct 2020 16:58:39 +0200 Subject: [PATCH 03/15] add different render features (React) --- packages/@headlessui-react/src/types.ts | 14 +++ .../@headlessui-react/src/utils/render.ts | 94 ++++++++++++++++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/types.ts b/packages/@headlessui-react/src/types.ts index d0d32d79f9..262974d796 100644 --- a/packages/@headlessui-react/src/types.ts +++ b/packages/@headlessui-react/src/types.ts @@ -1,3 +1,8 @@ +// A unique placeholder we can use as some defaults. This is nice because we can use this instead of +// defaulting to null / never / ... and possibly collide with actual data. +const __: unique symbol = Symbol('__placeholder__') +export type __ = typeof __ + export type PropsOf = TTag extends React.ElementType ? React.ComponentProps : never @@ -6,3 +11,12 @@ export type Props = { as?: TTag children?: React.ReactNode | ((bag: TSlot) => React.ReactElement) } & Omit, TOmitableProps> + +type Without = { [P in Exclude]?: never } +export type XOR = T extends __ + ? U + : U extends __ + ? T + : T | U extends object + ? (Without & U) | (Without & T) + : T | U diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts index 0c73cab84f..5ec6ad4853 100644 --- a/packages/@headlessui-react/src/utils/render.ts +++ b/packages/@headlessui-react/src/utils/render.ts @@ -1,12 +1,88 @@ import * as React from 'react' -import { Props } from '../types' +import { Props, XOR, __ } from '../types' +import { match } from './match' -export function render( +export enum Features { + /** No features at all */ + None = 0, + + /** + * When used, this will allow us to use one of the render strategies. + * + * **The render strategies are:** + * - **Unmount** _(Will unmount the component.)_ + * - **Hidden** _(Will hide the component using the [hidden] attribute.)_ + */ + RenderStrategy = 1, + + /** + * When used, this will allow the user of our component to be in control. This can be used when + * you want to transition based on some state. + */ + Static = 2, +} + +export enum RenderStrategy { + Unmount, + Hidden, +} + +type PropsForFeature = { + [P in TPassedInFeatures]: P extends TForFeature ? TProps : __ +}[TPassedInFeatures] + +export type PropsForFeatures = XOR< + PropsForFeature, + PropsForFeature +> + +export function render( + props: Props & PropsForFeatures, + propsBag: TBag, + defaultTag: React.ElementType, + features?: TFeature, + visible: boolean = true +) { + // Visible always render + if (visible) return _render(props, propsBag, defaultTag) + + const featureFlags = features ?? Features.None + + if (featureFlags & Features.Static) { + const { static: isStatic = false, ...rest } = props as PropsForFeatures + + // When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else + if (isStatic) return _render(rest, propsBag, defaultTag) + } + + if (featureFlags & Features.RenderStrategy) { + const { unmount = true, ...rest } = props as PropsForFeatures + const strategy = unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden + + return match(strategy, { + [RenderStrategy.Unmount]() { + return null + }, + [RenderStrategy.Hidden]() { + return _render( + { ...rest, ...{ hidden: true, style: { display: 'none' } } }, + propsBag, + defaultTag + ) + }, + }) + } + + // No features enabled, just render + return _render(props, propsBag, defaultTag) +} + +function _render( props: Props, bag: TBag, tag: React.ElementType ) { - const { as: Component = tag, children, ...passThroughProps } = props + const { as: Component = tag, children, ...passThroughProps } = omit(props, ['unmount', 'static']) const resolvedChildren = (typeof children === 'function' ? children(bag) : children) as | React.ReactElement @@ -16,7 +92,7 @@ export function render( if (Object.keys(passThroughProps).length > 0) { if (Array.isArray(resolvedChildren) && resolvedChildren.length > 1) { const err = new Error('You should only render 1 child') - if (Error.captureStackTrace) Error.captureStackTrace(err, render) + if (Error.captureStackTrace) Error.captureStackTrace(err, _render) throw err } @@ -24,7 +100,7 @@ export function render( const err = new Error( `You should render an element as a child. Did you forget the as="..." prop?` ) - if (Error.captureStackTrace) Error.captureStackTrace(err, render) + if (Error.captureStackTrace) Error.captureStackTrace(err, _render) throw err } @@ -92,3 +168,11 @@ function compact>(object: T) { } return clone } + +function omit>(object: T, keysToOmit: string[] = []) { + let clone = Object.assign({}, object) + for (let key of keysToOmit) { + if (key in clone) delete clone[key] + } + return clone +} From 4200bcf54cc434effdeec5844ef967b23f506aba Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Oct 2020 16:59:20 +0200 Subject: [PATCH 04/15] use render features in Menu and Listbox (React) --- .../src/components/listbox/listbox.test.tsx | 473 ++++++++++-------- .../src/components/listbox/listbox.tsx | 56 +-- .../src/components/menu/menu.test.tsx | 340 +++++++------ .../src/components/menu/menu.tsx | 28 +- .../test-utils/accessibility-assertions.ts | 120 ++++- 5 files changed, 589 insertions(+), 428 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index 236b214aaa..50d19e74fe 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -74,10 +74,10 @@ describe('safeguards', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) }) @@ -105,18 +105,18 @@ describe('Rendering', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) }) @@ -138,14 +138,14 @@ describe('Rendering', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-2' }, }) assertListboxLabel({ attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: false }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) @@ -153,7 +153,7 @@ describe('Rendering', () => { attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: true }), }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertListboxLabelLinkedWithListbox() assertListboxButtonLinkedWithListboxLabel() }) @@ -179,7 +179,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: false }), tag: 'p', }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxLabel({ @@ -187,7 +187,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: true }), tag: 'p', }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) }) @@ -208,20 +208,20 @@ describe('Rendering', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) @@ -242,20 +242,20 @@ describe('Rendering', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) @@ -278,10 +278,10 @@ describe('Rendering', () => { // await new Promise(requestAnimationFrame) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-2' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) assertListboxButtonLinkedWithListboxLabel() }) ) @@ -305,19 +305,19 @@ describe('Rendering', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, textContent: JSON.stringify({ open: true }), }) assertActiveElement(getListbox()) @@ -339,6 +339,26 @@ describe('Rendering', () => { // Let's verify that the Listbox is already there expect(getListbox()).not.toBe(null) }) + + it('should be possible to use a different render strategy for the Listbox.Options', async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListbox({ state: ListboxState.InvisibleHidden }) + + // Let's open the Listbox, to see if it is not hidden anymore + await click(getListboxButton()) + + assertListbox({ state: ListboxState.Visible }) + }) }) describe('Listbox.Option', () => { @@ -355,19 +375,19 @@ describe('Rendering', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, textContent: JSON.stringify({ active: false, selected: false, disabled: false }), }) }) @@ -397,10 +417,10 @@ describe('Rendering composition', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open Listbox await click(getListboxButton()) @@ -472,10 +492,10 @@ describe('Rendering composition', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open Listbox await click(getListboxButton()) @@ -503,10 +523,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -514,10 +534,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Enter) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -549,10 +569,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -562,10 +582,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -584,10 +604,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -595,10 +615,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Enter) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -614,6 +634,67 @@ describe('Keyboard interactions', () => { }) ) + it( + 'should be possible to open the listbox with Enter, and focus the selected option (when using the `hidden` render strategy)', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.InvisibleHidden, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.InvisibleHidden }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ + state: ListboxState.Visible, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + const options = getListboxOptions() + + // Hover over Option A + await mouseMove(options[0]) + + // Verify that Option A is active + assertActiveListboxOption(options[0]) + + // Verify that Option B is still selected + assertListboxOption(options[1], { selected: true }) + + // Close/Hide the listbox + await press(Keys.Escape) + + // Re-open the listbox + await click(getListboxButton()) + + // Verify we have listbox options + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + it( 'should be possible to open the listbox with Enter, and focus the selected option (with a list of objects)', suppressConsoleLogs(async () => { @@ -637,10 +718,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -648,10 +729,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Enter) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -677,14 +758,14 @@ describe('Keyboard interactions', () => { ) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.Enter) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -708,10 +789,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -745,10 +826,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -784,10 +865,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -814,23 +895,23 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) // Close listbox await press(Keys.Enter) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -866,16 +947,16 @@ describe('Keyboard interactions', () => { render() assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) // Activate the first listbox option const options = getListboxOptions() @@ -885,8 +966,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) @@ -920,10 +1001,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -931,10 +1012,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Space) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -963,10 +1044,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -976,10 +1057,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -998,10 +1079,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1009,10 +1090,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Space) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1038,14 +1119,14 @@ describe('Keyboard interactions', () => { ) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.Space) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -1069,10 +1150,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1106,10 +1187,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1145,10 +1226,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1189,16 +1270,16 @@ describe('Keyboard interactions', () => { render() assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) // Activate the first listbox option const options = getListboxOptions() @@ -1208,8 +1289,8 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) @@ -1248,10 +1329,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Space) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1261,8 +1342,8 @@ describe('Keyboard interactions', () => { await press(Keys.Escape) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -1286,10 +1367,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1297,10 +1378,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Enter) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1316,8 +1397,8 @@ describe('Keyboard interactions', () => { await press(Keys.Tab) // Verify it is still open - assertListboxButton({ state: ListboxState.Open }) - assertListbox({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) }) ) @@ -1337,10 +1418,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1348,10 +1429,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.Enter) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1367,8 +1448,8 @@ describe('Keyboard interactions', () => { await press(shift(Keys.Tab)) // Verify it is still open - assertListboxButton({ state: ListboxState.Open }) - assertListbox({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) }) ) @@ -1390,10 +1471,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1401,10 +1482,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.ArrowDown) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1435,10 +1516,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1448,10 +1529,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -1470,10 +1551,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1481,10 +1562,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.ArrowDown) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1510,14 +1591,14 @@ describe('Keyboard interactions', () => { ) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.ArrowDown) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -1539,10 +1620,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1587,10 +1668,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1629,10 +1710,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1665,10 +1746,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1676,10 +1757,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.ArrowUp) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1710,10 +1791,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1723,10 +1804,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -1745,10 +1826,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1756,10 +1837,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.ArrowUp) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1785,14 +1866,14 @@ describe('Keyboard interactions', () => { ) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.ArrowUp) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -1818,10 +1899,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1856,10 +1937,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1898,10 +1979,10 @@ describe('Keyboard interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1909,10 +1990,10 @@ describe('Keyboard interactions', () => { // Open listbox await press(Keys.ArrowUp) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -2688,18 +2769,18 @@ describe('Mouse interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -2727,20 +2808,20 @@ describe('Mouse interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Try to open the listbox await click(getListboxButton()) // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -2759,18 +2840,18 @@ describe('Mouse interactions', () => { ) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -2803,15 +2884,15 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) // Click to close await click(getListboxButton()) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -2855,13 +2936,13 @@ describe('Mouse interactions', () => { ) // Verify that the window is closed - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Click something that is not related to the listbox await click(document.body) // Should still be closed - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -2881,14 +2962,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) // Click something that is not related to the listbox await click(document.body) // Should be closed now - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -2955,14 +3036,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) // Click the listbox button again await click(getListboxButton()) // Should be closed now - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -3211,14 +3292,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() // We should be able to click the first option await click(options[1]) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('bob') @@ -3264,14 +3345,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() // We should be able to click the first option await click(options[1]) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) expect(handleChange).toHaveBeenCalledTimes(0) @@ -3302,7 +3383,7 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() @@ -3334,7 +3415,7 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 031239d650..b0d46bd1fa 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -6,7 +6,7 @@ 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 { forwardRefWithAs, render } from '../../utils/render' +import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render' import { match } from '../../utils/match' import { disposables } from '../../utils/disposables' import { Keys } from '../keyboard' @@ -114,7 +114,11 @@ const reducers: { action: Extract ) => StateDefinition } = { - [ActionTypes.CloseListbox]: state => ({ ...state, listboxState: ListboxStates.Closed }), + [ActionTypes.CloseListbox]: state => ({ + ...state, + activeOptionIndex: null, + listboxState: ListboxStates.Closed, + }), [ActionTypes.OpenListbox]: state => ({ ...state, listboxState: ListboxStates.Open }), [ActionTypes.GoToOption]: (state, action) => { const activeOptionIndex = calculateActiveOptionIndex(state, action.focus, action.id) @@ -375,11 +379,7 @@ function Label( () => ({ open: state.listboxState === ListboxStates.Open }), [state] ) - const propsWeControl = { - ref: state.labelRef, - id, - onPointerUp: handlePointerUp, - } + const propsWeControl = { ref: state.labelRef, id, onPointerUp: handlePointerUp } return render({ ...props, ...propsWeControl }, propsBag, DEFAULT_LABEL_TAG) } @@ -397,24 +397,15 @@ type OptionsPropsWeControl = const DEFAULT_OPTIONS_TAG = 'ul' type OptionsRenderPropArg = { open: boolean } - -type ListboxOptionsProp = Props & { - static?: boolean -} +const OptionsRenderFeatures = Features.RenderStrategy | Features.Static const Options = forwardRefWithAs(function Options< TTag extends React.ElementType = typeof DEFAULT_OPTIONS_TAG ->(props: ListboxOptionsProp, ref: React.Ref) { - const { - enter, - enterFrom, - enterTo, - leave, - leaveFrom, - leaveTo, - static: isStatic = false, - ...passthroughProps - } = props +>( + props: Props & + PropsForFeatures, + ref: React.Ref +) { const [state, dispatch] = useListboxContext([Listbox.name, Options.name].join('.')) const optionsRef = useSyncRefs(state.optionsRef, ref) @@ -501,13 +492,14 @@ const Options = forwardRefWithAs(function Options< role: 'listbox', tabIndex: 0, } - - if (!isStatic && state.listboxState === ListboxStates.Closed) return null + const passthroughProps = props return render( - { ...passthroughProps, ...propsWeControl, ...{ ref: optionsRef } }, + { ...passthroughProps, ...propsWeControl, ref: optionsRef }, propsBag, - DEFAULT_OPTIONS_TAG + DEFAULT_OPTIONS_TAG, + OptionsRenderFeatures, + state.listboxState === ListboxStates.Open ) }) @@ -570,17 +562,19 @@ function Option< }, [bag, id]) useIsoMorphicEffect(() => { + if (state.listboxState !== ListboxStates.Open) return if (!selected) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) document.getElementById(id)?.focus?.() - }, []) + }, [state.listboxState]) useIsoMorphicEffect(() => { + if (state.listboxState !== ListboxStates.Open) return if (!active) return const d = disposables() d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) return d.dispose - }, [active]) + }, [active, state.listboxState]) const handleClick = React.useCallback( (event: { preventDefault: Function }) => { @@ -627,11 +621,7 @@ function Option< onPointerLeave: handlePointerLeave, } - return render( - { ...passthroughProps, ...propsWeControl }, - propsBag, - DEFAULT_OPTION_TAG - ) + return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_OPTION_TAG) } function resolvePropValue(property: TProperty, bag: TBag) { diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 055bc6d933..d0fb46f828 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -68,10 +68,10 @@ describe('Safe guards', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) }) @@ -99,18 +99,18 @@ describe('Rendering', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) ) }) @@ -131,20 +131,20 @@ describe('Rendering', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) ) @@ -165,20 +165,20 @@ describe('Rendering', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) ) }) @@ -201,19 +201,19 @@ describe('Rendering', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, textContent: JSON.stringify({ open: true }), }) }) @@ -234,6 +234,26 @@ describe('Rendering', () => { // Let's verify that the Menu is already there expect(getMenu()).not.toBe(null) }) + + it('should be possible to use a different render strategy for the Menu.Items', async () => { + render( + + Trigger + + Item A + Item B + Item C + + + ) + + assertMenu({ state: MenuState.InvisibleHidden }) + + // Let's open the Menu, to see if it is not hidden anymore + await click(getMenuButton()) + + assertMenu({ state: MenuState.Visible }) + }) }) describe('Menu.Item', () => { @@ -250,19 +270,19 @@ describe('Rendering', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, textContent: JSON.stringify({ active: false, disabled: false }), }) }) @@ -292,10 +312,10 @@ describe('Rendering composition', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) @@ -349,10 +369,10 @@ describe('Rendering composition', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) @@ -381,10 +401,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -393,9 +413,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -425,10 +445,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -438,10 +458,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) @@ -455,14 +475,14 @@ describe('Keyboard interactions', () => { ) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Enter) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -485,10 +505,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -522,10 +542,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -561,10 +581,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -591,23 +611,23 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Close menu await press(Keys.Enter) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -632,16 +652,16 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Activate the first menu item const items = getMenuItems() @@ -651,8 +671,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -684,16 +704,16 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Activate the second menu item const items = getMenuItems() @@ -703,8 +723,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button got "clicked" expect(clickHandler).toHaveBeenCalledTimes(1) @@ -745,10 +765,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -757,9 +777,9 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -787,10 +807,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -800,10 +820,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) @@ -817,14 +837,14 @@ describe('Keyboard interactions', () => { ) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Space) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -847,10 +867,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -884,10 +904,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -923,10 +943,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -953,23 +973,23 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Close menu await press(Keys.Space) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -994,16 +1014,16 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Activate the first menu item const items = getMenuItems() @@ -1013,8 +1033,8 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the "click" went through on the `a` tag expect(clickHandler).toHaveBeenCalled() @@ -1047,9 +1067,9 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1058,8 +1078,8 @@ describe('Keyboard interactions', () => { await press(Keys.Escape) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -1083,10 +1103,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1095,9 +1115,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1112,8 +1132,8 @@ describe('Keyboard interactions', () => { await press(Keys.Tab) // Verify it is still open - assertMenuButton({ state: MenuState.Open }) - assertMenu({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) + assertMenu({ state: MenuState.Visible }) }) ) @@ -1132,10 +1152,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1144,9 +1164,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1161,8 +1181,8 @@ describe('Keyboard interactions', () => { await press(shift(Keys.Tab)) // Verify it is still open - assertMenuButton({ state: MenuState.Open }) - assertMenu({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) + assertMenu({ state: MenuState.Visible }) }) ) }) @@ -1183,10 +1203,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1195,9 +1215,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowDown) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1227,10 +1247,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1240,10 +1260,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) @@ -1257,14 +1277,14 @@ describe('Keyboard interactions', () => { ) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowDown) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -1285,10 +1305,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1333,10 +1353,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1375,10 +1395,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1411,10 +1431,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1423,9 +1443,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1455,10 +1475,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1468,10 +1488,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) @@ -1485,14 +1505,14 @@ describe('Keyboard interactions', () => { ) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowUp) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -1517,10 +1537,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1555,10 +1575,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1597,10 +1617,10 @@ describe('Keyboard interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1609,9 +1629,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -2360,18 +2380,18 @@ describe('Mouse interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -2398,20 +2418,20 @@ describe('Mouse interactions', () => { ) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Try to open the menu await click(getMenuButton()) // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) @@ -2433,14 +2453,14 @@ describe('Mouse interactions', () => { await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Click to close await click(getMenuButton()) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) @@ -2484,13 +2504,13 @@ describe('Mouse interactions', () => { ) // Verify that the window is closed - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Click something that is not related to the menu await click(document.body) // Should still be closed - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) ) @@ -2510,13 +2530,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) // Click something that is not related to the menu await click(document.body) // Should be closed now - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -2539,13 +2559,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) // Click the menu button again await click(getMenuButton()) // Should be closed now - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -2828,14 +2848,14 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) expect(clickHandler).toHaveBeenCalled() }) ) @@ -2861,11 +2881,11 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) // We should be able to click the first item await click(getMenuItems()[1]) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(1) @@ -2875,7 +2895,7 @@ describe('Mouse interactions', () => { // Click the last item, which should close and invoke the handler await click(getMenuItems()[2]) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(2) @@ -2900,13 +2920,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) ) @@ -2926,7 +2946,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() @@ -2957,7 +2977,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() @@ -2991,7 +3011,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index a2a9948211..d6eac725ca 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { Props } from '../../types' import { match } from '../../utils/match' -import { forwardRefWithAs, render } from '../../utils/render' +import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render' import { disposables } from '../../utils/disposables' import { useDisposables } from '../../hooks/use-disposables' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' @@ -108,7 +108,11 @@ const reducers: { action: Extract ) => StateDefinition } = { - [ActionTypes.CloseMenu]: state => ({ ...state, menuState: MenuStates.Closed }), + [ActionTypes.CloseMenu]: state => ({ + ...state, + activeItemIndex: null, + menuState: MenuStates.Closed, + }), [ActionTypes.OpenMenu]: state => ({ ...state, menuState: MenuStates.Open }), [ActionTypes.GoToItem]: (state, action) => { const activeItemIndex = calculateActiveItemIndex(state, action.focus, action.id) @@ -348,14 +352,15 @@ type ItemsPropsWeControl = const DEFAULT_ITEMS_TAG = 'div' type ItemsRenderPropArg = { open: boolean } +const ItemsRenderFeatures = Features.RenderStrategy | Features.Static const Items = forwardRefWithAs(function Items< TTag extends React.ElementType = typeof DEFAULT_ITEMS_TAG >( - props: Props & { static?: boolean }, + props: Props & + PropsForFeatures, ref: React.Ref ) { - const { static: isStatic = false, ...passthroughProps } = props const [state, dispatch] = useMenuContext([Menu.name, Items.name].join('.')) const itemsRef = useSyncRefs(state.itemsRef, ref) @@ -434,13 +439,14 @@ const Items = forwardRefWithAs(function Items< role: 'menu', tabIndex: 0, } - - if (!isStatic && state.menuState === MenuStates.Closed) return null + const passthroughProps = props return render( - { ...passthroughProps, ...propsWeControl, ...{ ref: itemsRef } }, + { ...passthroughProps, ...propsWeControl, ref: itemsRef }, propsBag, - DEFAULT_ITEMS_TAG + DEFAULT_ITEMS_TAG, + ItemsRenderFeatures, + state.menuState === MenuStates.Open ) }) @@ -529,11 +535,7 @@ function Item( onPointerLeave: handlePointerLeave, } - return render( - { ...passthroughProps, ...propsWeControl }, - propsBag, - DEFAULT_ITEM_TAG - ) + return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_ITEM_TAG) } function resolvePropValue(property: TProperty, bag: TBag) { diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index dd45b20318..d70f60f95d 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -27,8 +27,14 @@ export function getMenuItems(): HTMLElement[] { // --- export enum MenuState { - Open, - Closed, + /** The menu is visible to the user. */ + Visible, + + /** The menu is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The menu is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, } export function assertMenuButton( @@ -47,12 +53,17 @@ export function assertMenuButton( expect(button).toHaveAttribute('aria-haspopup') switch (options.state) { - case MenuState.Open: + case MenuState.Visible: expect(button).toHaveAttribute('aria-controls') expect(button).toHaveAttribute('aria-expanded', 'true') break - case MenuState.Closed: + case MenuState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + break + + case MenuState.InvisibleUnmounted: expect(button).not.toHaveAttribute('aria-controls') expect(button).not.toHaveAttribute('aria-expanded') break @@ -124,27 +135,37 @@ export function assertMenu( ) { try { switch (options.state) { - case MenuState.Open: + case MenuState.InvisibleHidden: if (menu === null) return expect(menu).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(menu).toHaveAttribute('aria-labelledby') + assertHidden(menu) - // Check that we have the correct values for certain attributes + expect(menu).toHaveAttribute('aria-labelledby') expect(menu).toHaveAttribute('role', 'menu') - if (options.textContent) { - expect(menu).toHaveTextContent(options.textContent) + if (options.textContent) expect(menu).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) } + break + + case MenuState.Visible: + if (menu === null) return expect(menu).not.toBe(null) + + assertVisible(menu) + + expect(menu).toHaveAttribute('aria-labelledby') + expect(menu).toHaveAttribute('role', 'menu') + + if (options.textContent) expect(menu).toHaveTextContent(options.textContent) - // Ensure menu button has the following attributes for (let attributeName in options.attributes) { expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) } break - case MenuState.Closed: + case MenuState.InvisibleUnmounted: expect(menu).toBe(null) break @@ -217,8 +238,14 @@ export function getListboxOptions(): HTMLElement[] { // --- export enum ListboxState { - Open, - Closed, + /** The listbox is visible to the user. */ + Visible, + + /** The listbox is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The listbox is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, } export function assertListbox( @@ -231,27 +258,37 @@ export function assertListbox( ) { try { switch (options.state) { - case ListboxState.Open: + case ListboxState.InvisibleHidden: if (listbox === null) return expect(listbox).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(listbox).toHaveAttribute('aria-labelledby') + assertHidden(listbox) - // Check that we have the correct values for certain attributes + expect(listbox).toHaveAttribute('aria-labelledby') expect(listbox).toHaveAttribute('role', 'listbox') - if (options.textContent) { - expect(listbox).toHaveTextContent(options.textContent) + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) } + break + + case ListboxState.Visible: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertVisible(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) - // Ensure listbox button has the following attributes for (let attributeName in options.attributes) { expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) } break - case ListboxState.Closed: + case ListboxState.InvisibleUnmounted: expect(listbox).toBe(null) break @@ -280,12 +317,17 @@ export function assertListboxButton( expect(button).toHaveAttribute('aria-haspopup') switch (options.state) { - case ListboxState.Open: + case ListboxState.Visible: expect(button).toHaveAttribute('aria-controls') expect(button).toHaveAttribute('aria-expanded', 'true') break - case ListboxState.Closed: + case ListboxState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + break + + case ListboxState.InvisibleUnmounted: expect(button).not.toHaveAttribute('aria-controls') expect(button).not.toHaveAttribute('aria-expanded') break @@ -567,3 +609,29 @@ export function assertActiveElement(element: HTMLElement | null) { throw err } } + +// --- + +export function assertHidden(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(element).toHaveAttribute('hidden') + expect(element).toHaveStyle({ display: 'none' }) + } catch (err) { + Error.captureStackTrace(err, assertHidden) + throw err + } +} + +export function assertVisible(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(element).not.toHaveAttribute('hidden') + expect(element).not.toHaveStyle({ display: 'none' }) + } catch (err) { + Error.captureStackTrace(err, assertVisible) + throw err + } +} From 1208e01bd66b9aadb17d9c2279645f1974605a12 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Oct 2020 18:28:38 +0200 Subject: [PATCH 05/15] add different render features (Vue) --- packages/@headlessui-vue/src/utils/render.ts | 77 +++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-vue/src/utils/render.ts b/packages/@headlessui-vue/src/utils/render.ts index c80e225e24..d521ba0e43 100644 --- a/packages/@headlessui-vue/src/utils/render.ts +++ b/packages/@headlessui-vue/src/utils/render.ts @@ -1,6 +1,73 @@ import { h, cloneVNode, Slots } from 'vue' +import { match } from './match' + +export enum Features { + /** No features at all */ + None = 0, + + /** + * When used, this will allow us to use one of the render strategies. + * + * **The render strategies are:** + * - **Unmount** _(Will unmount the component.)_ + * - **Hidden** _(Will hide the component using the [hidden] attribute.)_ + */ + RenderStrategy = 1, + + /** + * When used, this will allow the user of our component to be in control. This can be used when + * you want to transition based on some state. + */ + Static = 2, +} + +enum RenderStrategy { + Unmount, + Hidden, +} export function render({ + visible = true, + features = Features.None, + ...main +}: { + props: Record + slot: Record + attrs: Record + slots: Slots +} & { + features?: Features + visible?: boolean +}) { + // Visible always render + if (visible) return _render(main) + + if (features & Features.Static) { + // When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else + if (main.props.static) return _render(main) + } + + if (features & Features.RenderStrategy) { + const strategy = main.props.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden + + return match(strategy, { + [RenderStrategy.Unmount]() { + return null + }, + [RenderStrategy.Hidden]() { + return _render({ + ...main, + props: { ...main.props, hidden: true, style: { display: 'none' } }, + }) + }, + }) + } + + // No features enabled, just render + return _render(main) +} + +function _render({ props, attrs, slots, @@ -11,7 +78,7 @@ export function render({ attrs: Record slots: Slots }) { - const { as, ...passThroughProps } = props + const { as, ...passThroughProps } = omit(props, ['unmount', 'static']) const children = slots.default?.(slot) @@ -30,3 +97,11 @@ export function render({ return h(as, passThroughProps, children) } + +function omit>(object: T, keysToOmit: string[] = []) { + let clone = Object.assign({}, object) + for (let key of keysToOmit) { + if (key in clone) delete clone[key] + } + return clone +} From 59c00d44c64d18dbd99681b68e1f2dbfb9bd63fc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Oct 2020 18:29:08 +0200 Subject: [PATCH 06/15] use render features in Menu and Listbox (Vue) --- .../src/components/listbox/listbox.test.tsx | 447 +++++++++++------- .../src/components/listbox/listbox.ts | 33 +- .../src/components/menu/menu.test.tsx | 360 +++++++------- .../src/components/menu/menu.ts | 18 +- .../test-utils/accessibility-assertions.ts | 120 ++++- 5 files changed, 586 insertions(+), 392 deletions(-) diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index 441e164438..4417cdec7b 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -1,4 +1,4 @@ -import { defineComponent, ref, watch } from 'vue' +import { defineComponent, nextTick, ref, watch } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } from './listbox' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -85,10 +85,10 @@ describe('safeguards', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) }) @@ -119,18 +119,18 @@ describe('Rendering', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) }) @@ -155,14 +155,14 @@ describe('Rendering', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-2' }, }) assertListboxLabel({ attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: false }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) @@ -170,7 +170,7 @@ describe('Rendering', () => { attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: true }), }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertListboxLabelLinkedWithListbox() assertListboxButtonLinkedWithListboxLabel() }) @@ -199,7 +199,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: false }), tag: 'p', }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxLabel({ @@ -207,7 +207,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: true }), tag: 'p', }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) }) @@ -231,20 +231,20 @@ describe('Rendering', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) @@ -266,20 +266,20 @@ describe('Rendering', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) }) ) @@ -304,10 +304,10 @@ describe('Rendering', () => { await new Promise(requestAnimationFrame) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-2' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) assertListboxButtonLinkedWithListboxLabel() }) ) @@ -330,19 +330,19 @@ describe('Rendering', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, textContent: JSON.stringify({ open: true }), }) assertActiveElement(getListbox()) @@ -367,6 +367,31 @@ describe('Rendering', () => { // Let's verify that the Listbox is already there expect(getListbox()).not.toBe(null) }) + + it('should be possible to use a different render strategy for the ListboxOptions', async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + await new Promise(nextTick) + + assertListbox({ state: ListboxState.InvisibleHidden }) + + // Let's open the Listbox, to see if it is not hidden anymore + await click(getListboxButton()) + + assertListbox({ state: ListboxState.Visible }) + }) }) describe('ListboxOption', () => { @@ -386,19 +411,19 @@ describe('Rendering', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, textContent: JSON.stringify({ active: false, selected: false, disabled: false }), }) }) @@ -431,10 +456,10 @@ describe('Rendering composition', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open Listbox await click(getListboxButton()) @@ -509,10 +534,10 @@ describe('Rendering composition', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open Listbox await click(getListboxButton()) @@ -543,10 +568,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -555,9 +580,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -592,10 +617,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -605,10 +630,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -630,10 +655,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -642,9 +667,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -660,6 +685,72 @@ describe('Keyboard interactions', () => { }) ) + it( + 'should be possible to open the listbox with Enter, and focus the selected option (when using the `hidden` render strategy)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + await new Promise(nextTick) + + assertListboxButton({ + state: ListboxState.InvisibleHidden, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.InvisibleHidden }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ + state: ListboxState.Visible, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + const options = getListboxOptions() + + // Hover over Option A + await mouseMove(options[0]) + + // Verify that Option A is active + assertActiveListboxOption(options[0]) + + // Verify that Option B is still selected + assertListboxOption(options[1], { selected: true }) + + // Close/Hide the listbox + await press(Keys.Escape) + + // Re-open the listbox + await click(getListboxButton()) + + // Verify we have listbox options + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + it( 'should be possible to open the listbox with Enter, and focus the selected option (with a list of objects)', suppressConsoleLogs(async () => { @@ -685,10 +776,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -697,9 +788,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -728,14 +819,14 @@ describe('Keyboard interactions', () => { setup: () => ({ value: ref(null) }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.Enter) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -762,10 +853,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -802,10 +893,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -844,10 +935,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -877,23 +968,23 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) // Close listbox await press(Keys.Enter) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -923,16 +1014,16 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) // Activate the first listbox option const options = getListboxOptions() @@ -942,8 +1033,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) @@ -980,10 +1071,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -992,9 +1083,9 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1026,10 +1117,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1039,10 +1130,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -1064,10 +1155,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1076,9 +1167,9 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1107,14 +1198,14 @@ describe('Keyboard interactions', () => { setup: () => ({ value: ref(null) }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.Space) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -1141,10 +1232,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1181,10 +1272,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1223,10 +1314,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1261,16 +1352,16 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) // Activate the first listbox option const options = getListboxOptions() @@ -1280,8 +1371,8 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) @@ -1324,9 +1415,9 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1336,8 +1427,8 @@ describe('Keyboard interactions', () => { await press(Keys.Escape) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -1364,10 +1455,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1376,9 +1467,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1394,8 +1485,8 @@ describe('Keyboard interactions', () => { await press(Keys.Tab) // Verify it is still open - assertListboxButton({ state: ListboxState.Open }) - assertListbox({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) }) ) @@ -1418,10 +1509,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1430,9 +1521,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1448,8 +1539,8 @@ describe('Keyboard interactions', () => { await press(shift(Keys.Tab)) // Verify it is still open - assertListboxButton({ state: ListboxState.Open }) - assertListbox({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) }) ) @@ -1474,10 +1565,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1486,9 +1577,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowDown) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1522,10 +1613,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1535,10 +1626,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -1560,10 +1651,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1572,9 +1663,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowDown) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1603,14 +1694,14 @@ describe('Keyboard interactions', () => { setup: () => ({ value: ref(null) }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.ArrowDown) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -1635,10 +1726,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1686,10 +1777,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1731,10 +1822,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1770,10 +1861,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1782,9 +1873,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1818,10 +1909,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1831,10 +1922,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -1856,10 +1947,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1868,9 +1959,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -1899,14 +1990,14 @@ describe('Keyboard interactions', () => { setup: () => ({ value: ref(null) }), }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() // Open listbox await press(Keys.ArrowUp) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) assertNoActiveListboxOption() @@ -1935,10 +2026,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -1976,10 +2067,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -2021,10 +2112,10 @@ describe('Keyboard interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button getListboxButton()?.focus() @@ -2033,9 +2124,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -2877,18 +2968,18 @@ describe('Mouse interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -2919,20 +3010,20 @@ describe('Mouse interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Try to open the listbox await click(getListboxButton()) // Verify it is still closed assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -2954,18 +3045,18 @@ describe('Mouse interactions', () => { }) assertListboxButton({ - state: ListboxState.Closed, + state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Open listbox await click(getListboxButton()) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) assertListbox({ - state: ListboxState.Open, + state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-options-2' }, }) assertActiveElement(getListbox()) @@ -3002,14 +3093,14 @@ describe('Mouse interactions', () => { await click(getListboxButton()) // Verify it is open - assertListboxButton({ state: ListboxState.Open }) + assertListboxButton({ state: ListboxState.Visible }) // Click to close await click(getListboxButton()) // Verify it is closed - assertListboxButton({ state: ListboxState.Closed }) - assertListbox({ state: ListboxState.Closed }) + assertListboxButton({ state: ListboxState.InvisibleUnmounted }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -3059,13 +3150,13 @@ describe('Mouse interactions', () => { }) // Verify that the window is closed - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Click something that is not related to the listbox await click(document.body) // Should still be closed - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) @@ -3088,14 +3179,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) // Click something that is not related to the listbox await click(document.body) // Should be closed now - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -3168,14 +3259,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) // Click the listbox button again await click(getListboxButton()) // Should be closed now - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getListboxButton()) @@ -3440,14 +3531,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() // We should be able to click the first option await click(options[1]) - assertListbox({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('bob') @@ -3488,14 +3579,14 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() // We should be able to click the first option await click(options[1]) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) expect(handleChange).toHaveBeenCalledTimes(0) @@ -3529,7 +3620,7 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() @@ -3564,7 +3655,7 @@ describe('Mouse interactions', () => { // Open listbox await click(getListboxButton()) - assertListbox({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) const options = getListboxOptions() diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index dcace662f3..97048f8e12 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -12,9 +12,10 @@ import { ComputedRef, watchEffect, toRaw, + watch, } from 'vue' import { match } from '../../utils/match' -import { render } from '../../utils/render' +import { Features, render } from '../../utils/render' import { useId } from '../../hooks/use-id' import { Keys } from '../../keyboard' @@ -142,7 +143,10 @@ export const Listbox = defineComponent({ options, searchQuery, activeOptionIndex, - closeListbox: () => (listboxState.value = ListboxStates.Closed), + closeListbox: () => { + listboxState.value = ListboxStates.Closed + activeOptionIndex.value = null + }, openListbox: () => (listboxState.value = ListboxStates.Open), goToOption(focus: Focus, id?: string) { const nextActiveOptionIndex = calculateActiveOptionIndex(focus, id) @@ -359,15 +363,11 @@ export const ListboxOptions = defineComponent({ props: { as: { type: [Object, String], default: 'ul' }, static: { type: Boolean, default: false }, + unmount: { type: Boolean, default: true }, }, render() { const api = useListboxContext('ListboxOptions') - // `static` is a reserved keyword, therefore aliasing it... - const { static: isStatic, ...passThroughProps } = this.$props - - if (!isStatic && api.listboxState.value === ListboxStates.Closed) return null - const slot = { open: api.listboxState.value === ListboxStates.Open } const propsWeControl = { 'aria-activedescendant': @@ -381,12 +381,15 @@ export const ListboxOptions = defineComponent({ tabIndex: 0, ref: 'el', } + const passThroughProps = this.$props return render({ props: { ...passThroughProps, ...propsWeControl }, slot, attrs: this.$attrs, slots: this.$slots, + features: Features.RenderStrategy | Features.Static, + visible: slot.open, }) }, setup() { @@ -409,11 +412,11 @@ export const ListboxOptions = defineComponent({ // When in type ahead mode, fallthrough case Keys.Enter: event.preventDefault() - api.closeListbox() if (api.activeOptionIndex.value !== null) { const { dataRef } = api.options.value[api.activeOptionIndex.value] api.select(dataRef.value) } + api.closeListbox() nextTick(() => api.buttonRef.value?.focus()) break @@ -496,12 +499,20 @@ export const ListboxOption = defineComponent({ onUnmounted(() => api.unregisterOption(id)) onMounted(() => { - if (!selected.value) return - api.goToOption(Focus.Specific, id) - document.getElementById(id)?.focus?.() + watch( + [api.listboxState, selected], + () => { + if (api.listboxState.value !== ListboxStates.Open) return + if (!selected.value) return + api.goToOption(Focus.Specific, id) + document.getElementById(id)?.focus?.() + }, + { immediate: true } + ) }) watchEffect(() => { + if (api.listboxState.value !== ListboxStates.Open) return if (!active.value) return nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) }) diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index 127e01ca5d..bd7d041361 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -1,4 +1,4 @@ -import { defineComponent, h } from 'vue' +import { defineComponent, h, nextTick } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Menu, MenuButton, MenuItems, MenuItem } from './menu' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -74,10 +74,10 @@ describe('Safe guards', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) }) @@ -96,20 +96,20 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger hidden', }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger visible', }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) it('should be possible to render a Menu using a template `as` prop', async () => { @@ -127,18 +127,18 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) it( @@ -178,20 +178,20 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger hidden', }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger visible', }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) it('should be possible to render a MenuButton using a template `as` prop', async () => { @@ -209,18 +209,18 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1', 'data-open': 'false' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1', 'data-open': 'true' }, }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) it( @@ -262,18 +262,18 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) expect(getMenu()?.firstChild?.textContent).toBe('visible') }) @@ -292,18 +292,18 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Open, attributes: { 'data-open': 'true' } }) + assertMenu({ state: MenuState.Visible, attributes: { 'data-open': 'true' } }) }) it('should yell when we render MenuItems using a template `as` prop that contains multiple children', async () => { @@ -356,6 +356,28 @@ describe('Rendering', () => { // Let's verify that the Menu is already there expect(getMenu()).not.toBe(null) }) + + it('should be possible to use a different render strategy for the MenuItems', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + await new Promise(nextTick) + + assertMenu({ state: MenuState.InvisibleHidden }) + + // Let's open the Menu, to see if it is not hidden anymore + await click(getMenuButton()) + + assertMenu({ state: MenuState.Visible }) + }) }) describe('MenuItem', () => { @@ -374,18 +396,18 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) await click(getMenuButton()) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) expect(getMenuItems()[0]?.textContent).toBe( `Item A - ${JSON.stringify({ active: false, disabled: false })}` ) @@ -410,20 +432,20 @@ describe('Rendering', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) getMenuButton()?.focus() await press(Keys.Enter) assertMenuButton({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertMenuItem(getMenuItems()[0], { tag: 'a', attributes: { 'data-active': 'true', 'data-disabled': 'false' }, @@ -492,10 +514,10 @@ describe('Rendering composition', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) @@ -557,10 +579,10 @@ describe('Rendering composition', () => { }) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) @@ -589,10 +611,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -601,9 +623,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -630,10 +652,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -643,10 +665,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) it('should have no active menu item when there are no menu items at all', async () => { @@ -657,14 +679,14 @@ describe('Keyboard interactions', () => { `) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Enter) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -682,10 +704,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -712,10 +734,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -742,10 +764,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -769,23 +791,23 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Close menu await press(Keys.Enter) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -808,16 +830,16 @@ describe('Keyboard interactions', () => { }) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Activate the first menu item const items = getMenuItems() @@ -827,8 +849,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -860,16 +882,16 @@ describe('Keyboard interactions', () => { }) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Activate the second menu item const items = getMenuItems() @@ -879,8 +901,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button got "clicked" expect(clickHandler).toHaveBeenCalledTimes(1) @@ -918,10 +940,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -930,9 +952,9 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -957,10 +979,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -970,10 +992,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) it('should have no active menu item when there are no menu items at all', async () => { @@ -984,14 +1006,14 @@ describe('Keyboard interactions', () => { `) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Space) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -1009,10 +1031,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1039,10 +1061,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1069,10 +1091,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1098,23 +1120,23 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Close menu await press(Keys.Space) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -1140,16 +1162,16 @@ describe('Keyboard interactions', () => { }) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Activate the first menu item const items = getMenuItems() @@ -1159,8 +1181,8 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the "click" went through on the `a` tag expect(clickHandler).toHaveBeenCalled() @@ -1191,9 +1213,9 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1202,8 +1224,8 @@ describe('Keyboard interactions', () => { await press(Keys.Escape) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -1224,10 +1246,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1236,9 +1258,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1253,8 +1275,8 @@ describe('Keyboard interactions', () => { await press(Keys.Tab) // Verify it is still open - assertMenuButton({ state: MenuState.Open }) - assertMenu({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) + assertMenu({ state: MenuState.Visible }) }) it('should focus trap when we use Shift+Tab', async () => { @@ -1270,10 +1292,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1282,9 +1304,9 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1299,8 +1321,8 @@ describe('Keyboard interactions', () => { await press(shift(Keys.Tab)) // Verify it is still open - assertMenuButton({ state: MenuState.Open }) - assertMenu({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) + assertMenu({ state: MenuState.Visible }) }) }) @@ -1318,10 +1340,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1330,9 +1352,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowDown) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1359,10 +1381,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1372,10 +1394,10 @@ describe('Keyboard interactions', () => { // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) it('should have no active menu item when there are no menu items at all', async () => { @@ -1386,14 +1408,14 @@ describe('Keyboard interactions', () => { `) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowDown) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -1411,10 +1433,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1454,10 +1476,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1489,10 +1511,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1522,10 +1544,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1534,9 +1556,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -1558,14 +1580,14 @@ describe('Keyboard interactions', () => { `) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowUp) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) assertNoActiveMenuItem() }) @@ -1583,10 +1605,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1614,10 +1636,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1653,10 +1675,10 @@ describe('Keyboard interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button getMenuButton()?.focus() @@ -1665,9 +1687,9 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -2282,18 +2304,18 @@ describe('Mouse interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) assertMenu({ - state: MenuState.Open, + state: MenuState.Visible, attributes: { id: 'headlessui-menu-items-2' }, }) assertMenuButtonLinkedWithMenu() @@ -2317,20 +2339,20 @@ describe('Mouse interactions', () => { `) assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Try to open the menu await click(getMenuButton()) // Verify it is still closed assertMenuButton({ - state: MenuState.Closed, + state: MenuState.InvisibleUnmounted, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) it('should be possible to close a menu on click', async () => { @@ -2349,14 +2371,14 @@ describe('Mouse interactions', () => { await click(getMenuButton()) // Verify it is open - assertMenuButton({ state: MenuState.Open }) + assertMenuButton({ state: MenuState.Visible }) // Click to close await click(getMenuButton()) // Verify it is closed - assertMenuButton({ state: MenuState.Closed }) - assertMenu({ state: MenuState.Closed }) + assertMenuButton({ state: MenuState.InvisibleUnmounted }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) it('should focus the menu when you try to focus the button again (when the menu is already open)', async () => { @@ -2397,13 +2419,13 @@ describe('Mouse interactions', () => { `) // Verify that the window is closed - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Click something that is not related to the menu await click(document.body) // Should still be closed - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) }) it('should be possible to click outside of the menu which should close the menu', async () => { @@ -2420,13 +2442,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) // Click something that is not related to the menu await click(document.body) // Should be closed now - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -2446,13 +2468,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) // Click the menu button again await click(getMenuButton()) // Should be closed now - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getMenuButton()) @@ -2706,14 +2728,14 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) expect(clickHandler).toHaveBeenCalled() }) @@ -2737,11 +2759,11 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) // We should be able to click the first item await click(getMenuItems()[1]) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(1) @@ -2751,7 +2773,7 @@ describe('Mouse interactions', () => { // Click the last item, which should close and invoke the handler await click(getMenuItems()[2]) - assertMenu({ state: MenuState.Closed }) + assertMenu({ state: MenuState.InvisibleUnmounted }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(2) @@ -2771,13 +2793,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) }) it('should be possible focus a menu item, so that it becomes active', async () => { @@ -2794,7 +2816,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() @@ -2820,7 +2842,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() @@ -2852,7 +2874,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu({ state: MenuState.Open }) + assertMenu({ state: MenuState.Visible }) const items = getMenuItems() diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index 2f07315623..7738a2ca12 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -11,7 +11,7 @@ import { Ref, } from 'vue' import { match } from '../../utils/match' -import { render } from '../../utils/render' +import { Features, render } from '../../utils/render' import { useId } from '../../hooks/use-id' import { Keys } from '../../keyboard' @@ -121,7 +121,10 @@ export const Menu = defineComponent({ items, searchQuery, activeItemIndex, - closeMenu: () => (menuState.value = MenuStates.Closed), + closeMenu: () => { + menuState.value = MenuStates.Closed + activeItemIndex.value = null + }, openMenu: () => (menuState.value = MenuStates.Open), goToItem(focus: Focus, id?: string) { const nextActiveItemIndex = calculateActiveItemIndex(focus, id) @@ -280,15 +283,11 @@ export const MenuItems = defineComponent({ props: { as: { type: [Object, String], default: 'div' }, static: { type: Boolean, default: false }, + unmount: { type: Boolean, default: true }, }, render() { const api = useMenuContext('MenuItems') - // `static` is a reserved keyword, therefore aliasing it... - const { static: isStatic, ...passThroughProps } = this.$props - - if (!isStatic && api.menuState.value === MenuStates.Closed) return null - const slot = { open: api.menuState.value === MenuStates.Open } const propsWeControl = { 'aria-activedescendant': @@ -302,12 +301,15 @@ export const MenuItems = defineComponent({ tabIndex: 0, ref: 'el', } + const passThroughProps = this.$props return render({ props: { ...passThroughProps, ...propsWeControl }, slot, attrs: this.$attrs, slots: this.$slots, + features: Features.RenderStrategy | Features.Static, + visible: slot.open, }) }, setup() { @@ -330,11 +332,11 @@ export const MenuItems = defineComponent({ // When in type ahead mode, fallthrough case Keys.Enter: event.preventDefault() - api.closeMenu() if (api.activeItemIndex.value !== null) { const { id } = api.items.value[api.activeItemIndex.value] document.getElementById(id)?.click() } + api.closeMenu() nextTick(() => api.buttonRef.value?.focus()) break diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index dd45b20318..d70f60f95d 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -27,8 +27,14 @@ export function getMenuItems(): HTMLElement[] { // --- export enum MenuState { - Open, - Closed, + /** The menu is visible to the user. */ + Visible, + + /** The menu is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The menu is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, } export function assertMenuButton( @@ -47,12 +53,17 @@ export function assertMenuButton( expect(button).toHaveAttribute('aria-haspopup') switch (options.state) { - case MenuState.Open: + case MenuState.Visible: expect(button).toHaveAttribute('aria-controls') expect(button).toHaveAttribute('aria-expanded', 'true') break - case MenuState.Closed: + case MenuState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + break + + case MenuState.InvisibleUnmounted: expect(button).not.toHaveAttribute('aria-controls') expect(button).not.toHaveAttribute('aria-expanded') break @@ -124,27 +135,37 @@ export function assertMenu( ) { try { switch (options.state) { - case MenuState.Open: + case MenuState.InvisibleHidden: if (menu === null) return expect(menu).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(menu).toHaveAttribute('aria-labelledby') + assertHidden(menu) - // Check that we have the correct values for certain attributes + expect(menu).toHaveAttribute('aria-labelledby') expect(menu).toHaveAttribute('role', 'menu') - if (options.textContent) { - expect(menu).toHaveTextContent(options.textContent) + if (options.textContent) expect(menu).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) } + break + + case MenuState.Visible: + if (menu === null) return expect(menu).not.toBe(null) + + assertVisible(menu) + + expect(menu).toHaveAttribute('aria-labelledby') + expect(menu).toHaveAttribute('role', 'menu') + + if (options.textContent) expect(menu).toHaveTextContent(options.textContent) - // Ensure menu button has the following attributes for (let attributeName in options.attributes) { expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) } break - case MenuState.Closed: + case MenuState.InvisibleUnmounted: expect(menu).toBe(null) break @@ -217,8 +238,14 @@ export function getListboxOptions(): HTMLElement[] { // --- export enum ListboxState { - Open, - Closed, + /** The listbox is visible to the user. */ + Visible, + + /** The listbox is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The listbox is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, } export function assertListbox( @@ -231,27 +258,37 @@ export function assertListbox( ) { try { switch (options.state) { - case ListboxState.Open: + case ListboxState.InvisibleHidden: if (listbox === null) return expect(listbox).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(listbox).toHaveAttribute('aria-labelledby') + assertHidden(listbox) - // Check that we have the correct values for certain attributes + expect(listbox).toHaveAttribute('aria-labelledby') expect(listbox).toHaveAttribute('role', 'listbox') - if (options.textContent) { - expect(listbox).toHaveTextContent(options.textContent) + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) } + break + + case ListboxState.Visible: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertVisible(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) - // Ensure listbox button has the following attributes for (let attributeName in options.attributes) { expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) } break - case ListboxState.Closed: + case ListboxState.InvisibleUnmounted: expect(listbox).toBe(null) break @@ -280,12 +317,17 @@ export function assertListboxButton( expect(button).toHaveAttribute('aria-haspopup') switch (options.state) { - case ListboxState.Open: + case ListboxState.Visible: expect(button).toHaveAttribute('aria-controls') expect(button).toHaveAttribute('aria-expanded', 'true') break - case ListboxState.Closed: + case ListboxState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + break + + case ListboxState.InvisibleUnmounted: expect(button).not.toHaveAttribute('aria-controls') expect(button).not.toHaveAttribute('aria-expanded') break @@ -567,3 +609,29 @@ export function assertActiveElement(element: HTMLElement | null) { throw err } } + +// --- + +export function assertHidden(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(element).toHaveAttribute('hidden') + expect(element).toHaveStyle({ display: 'none' }) + } catch (err) { + Error.captureStackTrace(err, assertHidden) + throw err + } +} + +export function assertVisible(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(element).not.toHaveAttribute('hidden') + expect(element).not.toHaveStyle({ display: 'none' }) + } catch (err) { + Error.captureStackTrace(err, assertVisible) + throw err + } +} From 8edf821b910a05a0ff925222f2c56889dfc30dc9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Oct 2020 18:31:45 +0200 Subject: [PATCH 07/15] bump dependencies --- package.json | 2 +- packages/@headlessui-react/package.json | 6 +-- yarn.lock | 69 ++++++++++++++++--------- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index dfbb973264..2a72dde938 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "husky": "^4.3.0", "lint-staged": "^10.4.0", "prismjs": "^1.22.0", - "tailwindcss": "^1.9.1", + "tailwindcss": "^1.9.2", "tsdx": "^0.14.1", "tslib": "^2.0.3", "typescript": "^3.9.7" diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index 90b99878f1..4db2ee5d29 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -34,11 +34,11 @@ "@types/react": "^16.9.52", "@types/react-dom": "^16.9.8", "@popperjs/core": "^2.5.3", - "@testing-library/react": "^11.0.4", + "@testing-library/react": "^11.1.0", "framer-motion": "^2.9.1", "next": "9.5.5", - "react": "^16.13.1", - "react-dom": "^16.13.1", + "react": "^16.14.0", + "react-dom": "^16.14.0", "snapshot-diff": "^0.8.1" } } diff --git a/yarn.lock b/yarn.lock index 3d968c5193..3abb4fb323 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1491,10 +1491,10 @@ hex-rgb "^4.1.0" postcss-selector-parser "^6.0.2" -"@testing-library/dom@^7.24.2", "@testing-library/dom@^7.5.7": - version "7.24.2" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.2.tgz#6d2b7dd21efbd5358b98c2777fc47c252f3ae55e" - integrity sha512-ERxcZSoHx0EcN4HfshySEWmEf5Kkmgi+J7O79yCJ3xggzVlBJ2w/QjJUC+EBkJJ2OeSw48i3IoePN4w8JlVUIA== +"@testing-library/dom@^7.24.3": + version "7.24.3" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.3.tgz#dae3071463cf28dc7755b43d9cf2202e34cbb85d" + integrity sha512-6eW9fUhEbR423FZvoHRwbWm9RUUByLWGayYFNVvqTnQLYvsNpBS4uEuKH9aqr3trhxFwGVneJUonehL3B1sHJw== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.10.3" @@ -1504,10 +1504,24 @@ dom-accessibility-api "^0.5.1" pretty-format "^26.4.2" -"@testing-library/dom@^7.24.3": - version "7.24.3" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.3.tgz#dae3071463cf28dc7755b43d9cf2202e34cbb85d" - integrity sha512-6eW9fUhEbR423FZvoHRwbWm9RUUByLWGayYFNVvqTnQLYvsNpBS4uEuKH9aqr3trhxFwGVneJUonehL3B1sHJw== +"@testing-library/dom@^7.26.0": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.26.0.tgz#da4d052dc426a4ccc916303369c6e7552126f680" + integrity sha512-fyKFrBbS1IigaE3FV21LyeC7kSGF84lqTlSYdKmGaHuK2eYQ/bXVPM5vAa2wx/AU1iPD6oQHsxy2QQ17q9AMCg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.10.3" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.1" + lz-string "^1.4.4" + pretty-format "^26.4.2" + +"@testing-library/dom@^7.5.7": + version "7.24.2" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.2.tgz#6d2b7dd21efbd5358b98c2777fc47c252f3ae55e" + integrity sha512-ERxcZSoHx0EcN4HfshySEWmEf5Kkmgi+J7O79yCJ3xggzVlBJ2w/QjJUC+EBkJJ2OeSw48i3IoePN4w8JlVUIA== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.10.3" @@ -1531,13 +1545,13 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@^11.0.4": - version "11.0.4" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.0.4.tgz#c84082bfe1593d8fcd475d46baee024452f31dee" - integrity sha512-U0fZO2zxm7M0CB5h1+lh31lbAwMSmDMEMGpMT3BUPJwIjDEKYWOV4dx7lb3x2Ue0Pyt77gmz/VropuJnSz/Iew== +"@testing-library/react@^11.1.0": + version "11.1.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.1.0.tgz#dfb4b3177d05a8ccf156b5fd14a5550e91d7ebe4" + integrity sha512-Nfz58jGzW0tgg3irmTB7sa02JLkLnCk+QN3XG6WiaGQYb0Qc4Ok00aujgjdxlIQWZHbb4Zj5ZOIeE9yKFSs4sA== dependencies: "@babel/runtime" "^7.11.2" - "@testing-library/dom" "^7.24.2" + "@testing-library/dom" "^7.26.0" "@testing-library/vue@^5.1.0": version "5.1.0" @@ -6959,6 +6973,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= + magic-string@^0.25.2, magic-string@^0.25.5, magic-string@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" @@ -8410,10 +8429,10 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" -react-dom@^16.13.1: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" - integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== +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== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -8430,10 +8449,10 @@ react-refresh@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.13.1: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" - integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== +react@^16.14.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -9700,10 +9719,10 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" -tailwindcss@^1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.1.tgz#5cd83b7962c0e22d7608bc502daf4185962995fc" - integrity sha512-3faxlyPlcWN8AoNEIVQFNsDcrdXS/D9nOGtdknrXvZp4D4E3AGPO2KRPiGG69B2ZUO0V6RvYiW91L2/n9QnBxg== +tailwindcss@^1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.2.tgz#6a6b423d0a2ff4376ca1a007ef70eae852788562" + integrity sha512-D3uKSZZkh4GaKiZWmPEfNrqEmEuYdwaqXOQ7trYSQQFI5laSD9+b2FUUj5g39nk5R1omKp5tBW9wZsfJq+KIVA== dependencies: "@fullhuman/postcss-purgecss" "^2.1.2" autoprefixer "^9.4.5" From 68a2139e84958fc5670e7868fb1557bdeed6fe1b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 16 Oct 2020 14:52:24 +0200 Subject: [PATCH 08/15] add ability to change the ref property using `refName` Example use case: ```tsx // Some components have this API with an `innerRef`. The suggested approach is to use // `React.forwardRef` so that you get the actual `ref` value. However if you already have this // `innerRef` API than we can use the `refName="innerRef"` to give the `ref` prop a good name. It // defaults to `ref` so that it still works everywhere else. function MyButton({ innerRef, ...props }) { return + " + `) + }) + + it('should be possible to render the children only when the `as` prop is set to React.Fragment', () => { + testRender(Contents) + + expect(contents()).toMatchInlineSnapshot(` + "
+ Contents +
" + `) + }) + + it('should forward all the props to the first child when using an as={React.Fragment}', () => { + testRender( + + {() => Contents} + + ) + + expect(contents()).toMatchInlineSnapshot(` + "
+ + Contents + +
" + `) + }) + + it( + 'should error when we are rendering a React.Fragment with multiple children', + suppressConsoleLogs(() => { + expect.assertions(1) + + return expect(() => { + testRender( + // @ts-expect-error className cannot be applied to a React.Fragment + + Contents A + Contents B + + ) + }).toThrowErrorMatchingInlineSnapshot(`"You should only render 1 child"`) + }) + ) + + it("should not error when we are rendering a React.Fragment with multiple children when we don't passthrough additional props", () => { + testRender( + + Contents A + Contents B + + ) + + expect(contents()).toMatchInlineSnapshot(` + "
+ + Contents A + + + Contents B + +
" + `) + }) + + it( + 'should error when we are applying props to a React.Fragment when we do not have a dedicated element', + suppressConsoleLogs(() => { + expect.assertions(1) + + return expect(() => { + testRender( + // @ts-expect-error className cannot be applied to a React.Fragment + + Contents + + ) + }).toThrowErrorMatchingInlineSnapshot( + `"You should render an element as a child. Did you forget the as=\\"...\\" prop?"` + ) + }) + ) +}) + +// --- + +function testStaticFeature(Dummy) { + it('should be possible to render a `static` dummy component (show = true)', () => { + testRender( + + Contents + + ) + + expect(contents()).toMatchInlineSnapshot(` + "
+
+ Contents +
+
" + `) + }) + + it('should be possible to render a `static` dummy component (show = false)', () => { + testRender( + + Contents + + ) + + expect(contents()).toMatchInlineSnapshot(` + "
+
+ Contents +
+
" + `) + }) +} + +// With the `static` keyword, the user is always in control. When we internally decide to show the +// component or hide it then it won't have any effect. This is useful for when you want to wrap your +// component in a Transition for example so that the Transition component can control the +// showing/hiding based on the `show` prop AND the state of the transition. +describe('Features.Static', () => { + const bag = {} + const EnabledFeatures = Features.Static + function Dummy( + props: Props & { show: boolean } & PropsForFeatures + ) { + const { show, ...rest } = props + return
{render(rest, bag, 'div', EnabledFeatures, show)}
+ } + + testStaticFeature(Dummy) +}) + +// --- + +function testRenderStrategyFeature(Dummy) { + describe('Unmount render strategy', () => { + it('should be possible to render an `unmount` dummy component (show = true)', () => { + testRender( + + Contents + + ) + + expect(contents()).toMatchInlineSnapshot(` + "
+
+ Contents +
+
" + `) + }) + + it('should be possible to render an `unmount` dummy component (show = false)', () => { + testRender( + + Contents + + ) + + // No contents, because we unmounted! + expect(contents()).toMatchInlineSnapshot(` + "
" + `) + }) + }) + + describe('Hidden render strategy', () => { + it('should be possible to render an `unmount={false}` dummy component (show = true)', () => { + testRender( + + Contents + + ) + + expect(contents()).toMatchInlineSnapshot(` + "
+
+ Contents +
+
" + `) + }) + + it('should be possible to render an `unmount={false}` dummy component (show = false)', () => { + testRender( + + Contents + + ) + + // We do have contents, but it is marked as hidden! + expect(contents()).toMatchInlineSnapshot(` + "
+ +
" + `) + }) + }) +} + +describe('Features.RenderStrategy', () => { + const bag = {} + const EnabledFeatures = Features.RenderStrategy + function Dummy( + props: Props & { show: boolean } & PropsForFeatures + ) { + const { show, ...rest } = props + return
{render(rest, bag, 'div', EnabledFeatures, show)}
+ } + + testRenderStrategyFeature(Dummy) +}) + +// --- + +// This should enable the `static` and `unmount` features. However they can't be used together! +describe('Features.Static | Features.RenderStrategy', () => { + const bag = {} + const EnabledFeatures = Features.Static | Features.RenderStrategy + function Dummy( + props: Props & { show: boolean } & PropsForFeatures + ) { + const { show, ...rest } = props + return
{render(rest, bag, 'div', EnabledFeatures, show)}
+ } + + // TODO: Can we "legit" test this? 🤔 + it('should result in a typescript error', () => { + testRender( + // @ts-expect-error static & unmount together are incompatible + + Contents + + ) + }) + + // To avoid duplication, and to make sure that the features tested in isolation can also be + // re-used when they are combined. + testStaticFeature(Dummy) + testRenderStrategyFeature(Dummy) +}) diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts index 98b40391f1..a255c0dc72 100644 --- a/packages/@headlessui-react/src/utils/render.ts +++ b/packages/@headlessui-react/src/utils/render.ts @@ -78,7 +78,7 @@ export function render( - props: Props & { ref?: unknown }, + props: Props & { ref?: unknown }, bag: TBag, tag: React.ElementType ) { From f65721a764a1869060d1d6c342baae7126da04c0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sat, 17 Oct 2020 21:30:37 +0200 Subject: [PATCH 11/15] use render features in Transition (React) --- .../transitions/transition.test.tsx | 268 ++++++++++++++++-- .../src/components/transitions/transition.tsx | 146 ++++++---- 2 files changed, 337 insertions(+), 77 deletions(-) diff --git a/packages/@headlessui-react/src/components/transitions/transition.test.tsx b/packages/@headlessui-react/src/components/transitions/transition.test.tsx index 6acb8a57d6..79a74a12b2 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.test.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.test.tsx @@ -120,7 +120,9 @@ describe('Setup API', () => { it('should be possible to use a render prop', () => { const { container } = render( - {ref => Children} + + {() => Children} + ) expect(container.firstChild).toMatchInlineSnapshot(` @@ -131,12 +133,20 @@ describe('Setup API', () => { }) it( - 'should yell at us when we forget to apply the ref when using a render prop', + 'should yell at us when we forget to forward the ref when using a render prop', suppressConsoleLogs(() => { expect.assertions(1) + function Dummy(props: any) { + return Children + } + expect(() => { - render({() => Children}) + render( + + {() => } + + ) }).toThrowErrorMatchingInlineSnapshot( `"Did you forget to passthrough the \`ref\` to the actual DOM node?"` ) @@ -253,8 +263,10 @@ describe('Setup API', () => { const { container } = render(
- {ref => } - {ref =>
Content
}
+ {() => } + + {() =>
Content
} +
) @@ -278,11 +290,15 @@ describe('Setup API', () => { it('should be possible to use render props on the Transition and Transition.Child components', () => { const { container } = render(
- - {ref => ( -
- {ref => } - {ref =>
Content
}
+ + {() => ( +
+ + {() => } + + + {() =>
Content
} +
)}
@@ -306,16 +322,24 @@ describe('Setup API', () => { }) it( - 'should yell at us when we forgot to apply the ref on one of the Transition.Child components', + 'should yell at us when we forgot to forward the ref on one of the Transition.Child components', suppressConsoleLogs(() => { expect.assertions(1) + function Dummy(props: any) { + return
+ } + expect(() => { render(
- {ref => } - {() =>
Content
}
+ + {() => Sidebar} + + + {() => Content} +
) @@ -326,21 +350,23 @@ describe('Setup API', () => { ) it( - 'should yell at us when we forgot to apply a ref on the Transition component', + 'should yell at us when we forgot to forward a ref on the Transition component', suppressConsoleLogs(() => { expect.assertions(1) + function Dummy(props: any) { + return
+ } + expect(() => { render(
- + {() => ( -
- {ref => } - - {ref =>
Content
} -
-
+ + {() => } + {() =>
Content
}
+
)}
@@ -503,6 +529,53 @@ describe('Transitions', () => { `) }) + it('should transition in completely (duration defined in seconds) in (render strategy = hidden)', async () => { + const enterDuration = 50 + + function Example() { + const [show, setShow] = React.useState(false) + + return ( + <> + + + + Hello! + + + + + ) + } + + const timeline = await executeTimeline(, [ + // Toggle to show + ({ getByTestId }) => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - hidden=\\"\\" + - style=\\"display: none;\\" + + class=\\"enter from\\" + + style=\\"\\" + + Render 2: + - class=\\"enter from\\" + + class=\\"enter to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter to\\" + + class=\\"\\"" + `) + }) + it('should transition in completely', async () => { const enterDuration = 50 @@ -606,6 +679,57 @@ describe('Transitions', () => { }) ) + it( + 'should transition out completely (render strategy = hidden)', + suppressConsoleLogs(async () => { + const leaveDuration = 50 + + function Example() { + const [show, setShow] = React.useState(true) + + return ( + <> + + + + Hello! + + + + + ) + } + + const timeline = await executeTimeline(, [ + // Toggle to hide + ({ getByTestId }) => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + -
+ +
+ + Render 2: + - class=\\"leave from\\" + + class=\\"leave to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"leave to\\" + + class=\\"\\" + + hidden=\\"\\" + + style=\\"display: none;\\"" + `) + }) + ) + it( 'should transition in and out completely', suppressConsoleLogs(async () => { @@ -690,6 +814,108 @@ describe('Transitions', () => { `) }) ) + + it( + 'should transition in and out completely (render strategy = hidden)', + suppressConsoleLogs(async () => { + const enterDuration = 50 + const leaveDuration = 75 + + function Example() { + const [show, setShow] = React.useState(false) + + return ( + <> + + + + + Hello! + + + + + ) + } + + const timeline = await executeTimeline(, [ + // Toggle to show + ({ getByTestId }) => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + + // Toggle to hide + ({ getByTestId }) => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + + // Toggle to show + ({ getByTestId }) => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - hidden=\\"\\" + - style=\\"display: none;\\" + + class=\\"enter enter-from\\" + + style=\\"\\" + + Render 2: + - class=\\"enter enter-from\\" + + class=\\"enter enter-to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter enter-to\\" + + class=\\"\\" + + Render 4: + - class=\\"\\" + + class=\\"leave leave-from\\" + + Render 5: + - class=\\"leave leave-from\\" + + class=\\"leave leave-to\\" + + Render 6: Transition took at least 75ms (yes) + - class=\\"leave leave-to\\" + - style=\\"\\" + + class=\\"\\" + + hidden=\\"\\" + + style=\\"display: none;\\" + + Render 7: + - class=\\"\\" + - hidden=\\"\\" + - style=\\"display: none;\\" + + class=\\"enter enter-from\\" + + style=\\"\\" + + Render 8: + - class=\\"enter enter-from\\" + + class=\\"enter enter-to\\" + + Render 9: Transition took at least 75ms (yes) + - class=\\"enter enter-to\\" + + class=\\"\\"" + `) + }) + ) }) describe('nested transitions', () => { diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index 37c035c894..121e6d271a 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -1,11 +1,13 @@ import * as React from 'react' +import { Props } from 'types' import { useId } from '../../hooks/use-id' import { useIsInitialRender } from '../../hooks/use-is-initial-render' +import { match } from '../../utils/match' import { useIsMounted } from '../../hooks/use-is-mounted' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' -import { match } from '../../utils/match' +import { Features, PropsForFeatures, render, RenderStrategy } from '../../utils/render' import { Reason, transition } from './utils/transition' type ID = ReturnType @@ -43,24 +45,9 @@ export type TransitionEvents = Partial<{ afterLeave(): void }> -type HTMLTags = keyof JSX.IntrinsicElements -type HTMLTagProps = JSX.IntrinsicElements[TTag] - -type AsShortcut = { - children?: React.ReactNode - as?: TTag -} & Omit, 'ref'> - -type AsRenderPropFunction = { - children: (ref: React.MutableRefObject) => JSX.Element -} - -type BaseConfig = Partial<{ appear: boolean }> - -type TransitionChildProps = BaseConfig & - (AsShortcut | AsRenderPropFunction) & - TransitionClasses & - TransitionEvents +type TransitionChildProps = Props & + PropsForFeatures & + Partial<{ appear: boolean } & TransitionClasses & TransitionEvents> function useTransitionContext() { const context = React.useContext(TransitionContext) @@ -83,16 +70,23 @@ function useParentNesting() { } type NestingContextValues = { - children: React.MutableRefObject + children: React.MutableRefObject<{ id: ID; state: TreeStates }[]> register: (id: ID) => () => void - unregister: (id: ID) => void + unregister: (id: ID, strategy?: RenderStrategy) => void } const NestingContext = React.createContext(null) +function hasChildren( + bag: NestingContextValues['children'] | { children: NestingContextValues['children'] } +): boolean { + if ('children' in bag) return hasChildren(bag.children) + return bag.current.filter(({ state }) => state === TreeStates.Visible).length > 0 +} + function useNesting(done?: () => void) { const doneRef = React.useRef(done) - const transitionableChildren = React.useRef([]) + const transitionableChildren = React.useRef([]) const mounted = useIsMounted() React.useEffect(() => { @@ -100,14 +94,20 @@ function useNesting(done?: () => void) { }, [done]) const unregister = React.useCallback( - (childId: ID) => { - const idx = transitionableChildren.current.indexOf(childId) - + (childId: ID, strategy = RenderStrategy.Hidden) => { + const idx = transitionableChildren.current.findIndex(({ id }) => id === childId) if (idx === -1) return - transitionableChildren.current.splice(idx, 1) + match(strategy, { + [RenderStrategy.Unmount]() { + transitionableChildren.current.splice(idx, 1) + }, + [RenderStrategy.Hidden]() { + transitionableChildren.current[idx].state = TreeStates.Hidden + }, + }) - if (transitionableChildren.current.length <= 0 && mounted.current) { + if (!hasChildren(transitionableChildren) && mounted.current) { doneRef.current?.() } }, @@ -116,8 +116,14 @@ function useNesting(done?: () => void) { const register = React.useCallback( (childId: ID) => { - transitionableChildren.current.push(childId) - return () => unregister(childId) + const child = transitionableChildren.current.find(({ id }) => id === childId) + if (!child) { + transitionableChildren.current.push({ id: childId, state: TreeStates.Visible }) + } else if (child.state !== TreeStates.Visible) { + child.state = TreeStates.Visible + } + + return () => unregister(childId, RenderStrategy.Unmount) }, [transitionableChildren, unregister] ) @@ -156,7 +162,15 @@ function useEvents(events: TransitionEvents) { return eventsRef } -function TransitionChild(props: TransitionChildProps) { +// --- + +const DEFAULT_TRANSITION_CHILD_TAG = 'div' +type TransitionChildRenderPropArg = React.MutableRefObject +const TransitionChildRenderFeatures = Features.RenderStrategy + +function TransitionChild( + props: TransitionChildProps +) { const { // Event "handlers" beforeEnter, @@ -171,13 +185,11 @@ function TransitionChild(props: TransitionChildPr leave, leaveFrom, leaveTo, - - // .. - children, ...rest } = props const container = React.useRef(null) const [state, setState] = React.useState(TreeStates.Visible) + const strategy = rest.unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden const { show, appear } = useTransitionContext() const { register, unregister } = useParentNesting() @@ -202,6 +214,23 @@ function TransitionChild(props: TransitionChildPr return register(id) }, [register, id]) + useIsoMorphicEffect(() => { + // If we are in another mode than the Hidden mode then ignore + if (strategy !== RenderStrategy.Hidden) return + if (!id) return + + // Make sure that we are visible + if (show && state !== TreeStates.Visible) { + setState(TreeStates.Visible) + return + } + + match(state, { + [TreeStates.Hidden]: () => unregister(id), + [TreeStates.Visible]: () => register(id), + }) + }, [state, id, register, unregister, show, strategy]) + const enterClasses = useSplitClasses(enter) const enterFromClasses = useSplitClasses(enterFrom) const enterToClasses = useSplitClasses(enterTo) @@ -243,7 +272,7 @@ function TransitionChild(props: TransitionChildPr // When we don't have children anymore we can safely unregister from the parent and hide // ourselves. - if (nesting.children.current.length <= 0) { + if (!hasChildren(nesting)) { setState(TreeStates.Hidden) unregister(id) events.current.afterLeave() @@ -266,32 +295,27 @@ function TransitionChild(props: TransitionChildPr leaveToClasses, ]) - // Unmount the whole tree - if (state === TreeStates.Hidden) return null + const propsBag = {} + const propsWeControl = { ref: container } + const passthroughProps = rest - if (typeof children === 'function') { - return ( - - {(children as AsRenderPropFunction['children'])(container)} - - ) - } - - const { as: Component = 'div', ...passthroughProps } = rest as AsShortcut return ( - {/* @ts-expect-error Expression produces a union type that is too complex to represent. */} - - {children} - + {render( + { ...passthroughProps, ...propsWeControl }, + propsBag, + DEFAULT_TRANSITION_CHILD_TAG, + TransitionChildRenderFeatures, + state === TreeStates.Visible + )} ) } -export function Transition( +export function Transition( props: TransitionChildProps & { show: boolean; appear?: boolean } ) { - const { show, appear = false, ...rest } = props + const { show, appear = false, unmount, ...passthroughProps } = props if (![true, false].includes(show)) { throw new Error('A is used but it is missing a `show={true | false}` prop.') @@ -312,18 +336,28 @@ export function Transition( React.useEffect(() => { if (show) { setState(TreeStates.Visible) - } else if (nestingBag.children.current.length <= 0) { + } else if (!hasChildren(nestingBag)) { setState(TreeStates.Hidden) } }, [show, nestingBag]) + const sharedProps = { unmount } + const propsBag = {} + return ( - {match(state, { - [TreeStates.Visible]: () => , - [TreeStates.Hidden]: null, - })} + {render( + { + ...sharedProps, + as: React.Fragment, + children: , + }, + propsBag, + React.Fragment, + TransitionChildRenderFeatures, + state === TreeStates.Visible + )} ) From da85aba90b7db9f0a2fd2cffc5155be8e4266f18 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 18 Oct 2020 02:08:09 +0200 Subject: [PATCH 12/15] add/update Transition examples to also showcase the `unmount={false}` render strategy --- .../component-examples/peek-a-boo.tsx | 38 +++++++++++++++++++ .../layout-with-sidebar.tsx | 9 +++-- 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 packages/@headlessui-react/pages/transitions/component-examples/peek-a-boo.tsx diff --git a/packages/@headlessui-react/pages/transitions/component-examples/peek-a-boo.tsx b/packages/@headlessui-react/pages/transitions/component-examples/peek-a-boo.tsx new file mode 100644 index 0000000000..93184231ba --- /dev/null +++ b/packages/@headlessui-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() { + const [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + Contents to show and hide + +
+
+ + ) +} diff --git a/packages/@headlessui-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx b/packages/@headlessui-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx index b0dfcaf4a4..f6b31290e6 100644 --- a/packages/@headlessui-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx +++ b/packages/@headlessui-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx @@ -26,9 +26,10 @@ export default function App() {
{/* Off-canvas menu for mobile */} - + {/* Off-canvas menu overlay, show/hide based on off-canvas menu state. */} - {ref => ( -
+ {() => ( +
setMobileOpen(false)} className="absolute inset-0 opacity-75 bg-cool-gray-600" @@ -48,6 +49,7 @@ export default function App() { {/* Off-canvas menu, show/hide based on off-canvas menu state. */}
Date: Sun, 18 Oct 2020 13:14:30 +0200 Subject: [PATCH 13/15] bump dependencies --- package.json | 6 +- packages/@headlessui-react/package.json | 2 +- packages/@headlessui-vue/package.json | 4 +- yarn.lock | 122 +++++++++++++++++++----- 4 files changed, 106 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 2a72dde938..249cfff304 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,11 @@ "devDependencies": { "@tailwindcss/ui": "^0.6.2", "@testing-library/jest-dom": "^5.11.4", - "@types/node": "^14.11.8", + "@types/node": "^14.11.10", "husky": "^4.3.0", - "lint-staged": "^10.4.0", + "lint-staged": "^10.4.2", "prismjs": "^1.22.0", - "tailwindcss": "^1.9.2", + "tailwindcss": "^1.9.4", "tsdx": "^0.14.1", "tslib": "^2.0.3", "typescript": "^3.9.7" diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index 4db2ee5d29..29ab3eaa2f 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -31,7 +31,7 @@ "react": ">=16" }, "devDependencies": { - "@types/react": "^16.9.52", + "@types/react": "^16.9.53", "@types/react-dom": "^16.9.8", "@popperjs/core": "^2.5.3", "@testing-library/react": "^11.1.0", diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json index 15a3639f76..d635a1ba51 100644 --- a/packages/@headlessui-vue/package.json +++ b/packages/@headlessui-vue/package.json @@ -34,8 +34,8 @@ "@popperjs/core": "^2.5.3", "@testing-library/vue": "^5.1.0", "@types/debounce": "^1.2.0", - "@vue/compiler-sfc": "3.0.0", - "@vue/test-utils": "^2.0.0-beta.6", + "@vue/compiler-sfc": "3.0.1", + "@vue/test-utils": "^2.0.0-beta.7", "husky": "^4.3.0", "vite": "^1.0.0-rc.4", "vue": "^3.0.0-rc.13", diff --git a/yarn.lock b/yarn.lock index 3abb4fb323..cb8078c3bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -363,6 +363,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== +"@babel/parser@^7.12.0": + version "7.12.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.3.tgz#a305415ebe7a6c7023b40b5122a0662d928334cd" + integrity sha512-kFsOS0IbsuhO5ojF8Hc8z/8vEIOkylVBrjiZUbLTE3XFe0Qi+uu6HjzQixkFaqr0ZPAMZcBVxEwmsnsLPZ2Xsw== + "@babel/plugin-proposal-async-generator-functions@^7.10.4": version "7.10.5" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558" @@ -1065,6 +1070,15 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@babel/types@^7.12.0": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.1.tgz#e109d9ab99a8de735be287ee3d6a9947a190c4ae" + integrity sha512-BzSY3NJBKM4kyatSOWh3D/JJ2O3CVzBybHWxtgxnggaxEuaSTTDqeiSb/xk9lrkw2Tbqyivw5ZU4rT+EfznQsA== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1793,10 +1807,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.1.tgz#56af902ad157e763f9ba63d671c39cda3193c835" integrity sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw== -"@types/node@^14.11.8": - version "14.11.8" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.8.tgz#fe2012f2355e4ce08bca44aeb3abbb21cf88d33f" - integrity sha512-KPcKqKm5UKDkaYPTuXSx8wEP7vE9GnuaXIZKijwRYcePpZFDVuy2a57LarFKiORbHOuTOOwYzxVxcUzsh2P2Pw== +"@types/node@^14.11.10": + version "14.11.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.10.tgz#8c102aba13bf5253f35146affbf8b26275069bef" + integrity sha512-yV1nWZPlMFpoXyoknm4S56y2nlTAuFYaJuQtYRAOU7xA/FJ9RY0Xm7QOkaYMMmr8ESdHIuUb6oQgR/0+2NqlyA== "@types/normalize-package-data@^2.4.0": version "2.4.0" @@ -1848,10 +1862,10 @@ "@types/prop-types" "*" csstype "^3.0.2" -"@types/react@^16.9.52": - version "16.9.52" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.52.tgz#c46c72d1a1d8d9d666f4dd2066c0e22600ccfde1" - integrity sha512-EHRjmnxiNivwhGdMh9sz1Yw9AUxTSZFxKqdBWAAzyZx3sufWwx6ogqHYh/WB1m/I4ZpjkoZLExF5QTy2ekVi/Q== +"@types/react@^16.9.53": + version "16.9.53" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.53.tgz#40cd4f8b8d6b9528aedd1fff8fcffe7a112a3d23" + integrity sha512-4nW60Sd4L7+WMXH1D6jCdVftuW7j4Za6zdp6tJ33Rqv0nk1ZAmQKML9ZLD4H0dehA3FZxXR/GM8gXplf82oNGw== dependencies: "@types/prop-types" "*" csstype "^3.0.2" @@ -1961,6 +1975,17 @@ estree-walker "^2.0.1" source-map "^0.6.1" +"@vue/compiler-core@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.0.1.tgz#3ce57531078c6220be7ea458e41e4bab3522015b" + integrity sha512-BbQQj9YVNaNWEPnP4PiFKgW8QSGB3dcPSKCtekx1586m4VA1z8hHNLQnzeygtV8BM4oU6yriiWmOIYghbJHwFw== + dependencies: + "@babel/parser" "^7.12.0" + "@babel/types" "^7.12.0" + "@vue/shared" "3.0.1" + estree-walker "^2.0.1" + source-map "^0.6.1" + "@vue/compiler-dom@3.0.0", "@vue/compiler-dom@^3.0.0-rc.5": version "3.0.0" resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.0.0.tgz#4cbb48fcf1f852daef2babcf9953b681ac463526" @@ -1969,7 +1994,37 @@ "@vue/compiler-core" "3.0.0" "@vue/shared" "3.0.0" -"@vue/compiler-sfc@3.0.0", "@vue/compiler-sfc@^3.0.0-rc.5": +"@vue/compiler-dom@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.0.1.tgz#00b12f2e4aa55e624e2a5257e4bed93cf7555f0b" + integrity sha512-8cjgswVU2YmV35H9ARZmSlDr1P9VZxUihRwefkrk6Vrsb7kui5C3d/WQ2/su34FSDpyMU1aacUOiL2CV/vdX6w== + dependencies: + "@vue/compiler-core" "3.0.1" + "@vue/shared" "3.0.1" + +"@vue/compiler-sfc@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.0.1.tgz#f340f8f75b5c1c4509e0f3a12c79d1544899b663" + integrity sha512-VO5gJ7SyHw0hf1rkKXRlxjXI9+Q4ngcuUWYnyjOSDch7Wtt2IdOEiC82KFWIkfWMpHqA5HPzL2nDmys3y9d19w== + dependencies: + "@babel/parser" "^7.12.0" + "@babel/types" "^7.12.0" + "@vue/compiler-core" "3.0.1" + "@vue/compiler-dom" "3.0.1" + "@vue/compiler-ssr" "3.0.1" + "@vue/shared" "3.0.1" + consolidate "^0.16.0" + estree-walker "^2.0.1" + hash-sum "^2.0.0" + lru-cache "^5.1.1" + magic-string "^0.25.7" + merge-source-map "^1.1.0" + postcss "^7.0.32" + postcss-modules "^3.2.2" + postcss-selector-parser "^6.0.4" + source-map "^0.6.1" + +"@vue/compiler-sfc@^3.0.0-rc.5": version "3.0.0" resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.0.0.tgz#efa38037984bd64aae315828aa5c1248c6eadca9" integrity sha512-1Bn4L5jNRm6tlb79YwqYUGGe+Yc9PRoRSJi67NJX6icdhf84+tRMtESbx1zCLL9QixQXu2+7aLkXHxvh4RpqAA== @@ -1999,6 +2054,14 @@ "@vue/compiler-dom" "3.0.0" "@vue/shared" "3.0.0" +"@vue/compiler-ssr@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.0.1.tgz#0455b011d72d4ed02faa93610f14981c3d44a079" + integrity sha512-U0Vb7BOniw9rY0/YvXNw5EuIuO0dCoZd3XhnDjAKL9A5pSBxTlx6fPJeQ53gV0XH40M5z8q4yXukFqSVTXC6hQ== + dependencies: + "@vue/compiler-dom" "3.0.1" + "@vue/shared" "3.0.1" + "@vue/reactivity@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.0.0.tgz#fd15632a608650ce2a969c721787e27e2c80aa6b" @@ -2028,6 +2091,11 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.0.tgz#ec089236629ecc0f10346b92f101ff4339169f1a" integrity sha512-4XWL/avABGxU2E2ZF1eZq3Tj7fvksCMssDZUHOykBIMmh5d+KcAnQMC5XHMhtnA0NAvktYsA2YpdsVwVmhWzvA== +"@vue/shared@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.1.tgz#48196c056726aa7466d0182698524c84f203006b" + integrity sha512-/X6AUbTFCyD2BcJnBoacUct8qcv1A5uk1+N+3tbzDVuhGPRmoYrTSnNUuF53C/GIsTkChrEiXaJh2kyo/0tRvw== + "@vue/test-utils@^1.0.3", "@vue/test-utils@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.1.0.tgz#76305e73a786c921ede1352849614e26c7113f94" @@ -2037,10 +2105,10 @@ lodash "^4.17.15" pretty "^2.0.0" -"@vue/test-utils@^2.0.0-beta.6": - version "2.0.0-beta.6" - resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.0-beta.6.tgz#2f7a653b0025cd4236968269c5972e807fa1fb2c" - integrity sha512-nBj5HHoTD+2xg0OQ93p/Hil5SkFUcNJ5BA2RUnHlOH6a4PVskgMK8dOLyVcZ1ZJif7knjt7yQVJ6K6YwIzeR1A== +"@vue/test-utils@^2.0.0-beta.7": + version "2.0.0-beta.7" + resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.0-beta.7.tgz#27751991e0b013ee4af487e51e16a58d477e5857" + integrity sha512-cAe7VqoxxkxTr/2N93UpW/LQbcUVKC+QRA3ZBq5ZWImtAf/8jtcdC2mQ9g4AKmSvyaKQtqxrRn4i/y5z7yrrKA== "@webassemblyjs/ast@1.9.0": version "1.9.0" @@ -6762,10 +6830,10 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= -lint-staged@^10.4.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.4.0.tgz#d18628f737328e0bbbf87d183f4020930e9a984e" - integrity sha512-uaiX4U5yERUSiIEQc329vhCTDDwUcSvKdRLsNomkYLRzijk3v8V9GWm2Nz0RMVB87VcuzLvtgy6OsjoH++QHIg== +lint-staged@^10.4.2: + version "10.4.2" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.4.2.tgz#9fee4635c4b5ddb845746f237c6d43494ccd21c1" + integrity sha512-OLCA9K1hS+Sl179SO6kX0JtnsaKj/MZalEhUj5yAgXsb63qPI/Gfn6Ua1KuZdbfkZNEu3/n5C/obYCu70IMt9g== dependencies: chalk "^4.1.0" cli-truncate "^2.1.0" @@ -8166,6 +8234,16 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: indexes-of "^1.0.1" uniq "^1.0.1" +postcss-selector-parser@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3" + integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw== + dependencies: + cssesc "^3.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + util-deprecate "^1.0.2" + postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" @@ -9719,10 +9797,10 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" -tailwindcss@^1.9.2: - version "1.9.2" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.2.tgz#6a6b423d0a2ff4376ca1a007ef70eae852788562" - integrity sha512-D3uKSZZkh4GaKiZWmPEfNrqEmEuYdwaqXOQ7trYSQQFI5laSD9+b2FUUj5g39nk5R1omKp5tBW9wZsfJq+KIVA== +tailwindcss@^1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.4.tgz#5ae8ff84bc8234df22ba5f2c7feafb64bb14da55" + integrity sha512-CVeP4J1pDluBM/AF11JPku9Cx+VwQ6MbOcnlobnWVVZnq+xku8sa+XXmYzy/GvE08qD8w+OmpSdN21ZFPoVDRg== dependencies: "@fullhuman/postcss-purgecss" "^2.1.2" autoprefixer "^9.4.5" @@ -10261,7 +10339,7 @@ use@^3.1.0: 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, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, 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= From 7f8b0d53f0a215103acad3aa915cda1a2854c310 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 18 Oct 2020 14:03:50 +0200 Subject: [PATCH 14/15] add example with nested unmount/hide transitions --- .../component-examples/nested/hidden.tsx | 60 +++++++++++++++++++ .../component-examples/nested/unmount.tsx | 60 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/@headlessui-react/pages/transitions/component-examples/nested/hidden.tsx create mode 100644 packages/@headlessui-react/pages/transitions/component-examples/nested/unmount.tsx diff --git a/packages/@headlessui-react/pages/transitions/component-examples/nested/hidden.tsx b/packages/@headlessui-react/pages/transitions/component-examples/nested/hidden.tsx new file mode 100644 index 0000000000..7cec0cc0a8 --- /dev/null +++ b/packages/@headlessui-react/pages/transitions/component-examples/nested/hidden.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + const [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + ) +} + +function Box({ children }: { children?: React.ReactNode }) { + return ( + +
+ This is a box + {children} +
+
+ ) +} diff --git a/packages/@headlessui-react/pages/transitions/component-examples/nested/unmount.tsx b/packages/@headlessui-react/pages/transitions/component-examples/nested/unmount.tsx new file mode 100644 index 0000000000..4751229201 --- /dev/null +++ b/packages/@headlessui-react/pages/transitions/component-examples/nested/unmount.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + const [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + ) +} + +function Box({ children }: { children?: React.ReactNode }) { + return ( + +
+ This is a box + {children} +
+
+ ) +} From 2ff01b9b39011630209076f5511480b9996fe506 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 18 Oct 2020 14:10:03 +0200 Subject: [PATCH 15/15] add unmount to Transition documentation --- packages/@headlessui-react/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@headlessui-react/README.md b/packages/@headlessui-react/README.md index 9e821d84a0..ef63d79b59 100644 --- a/packages/@headlessui-react/README.md +++ b/packages/@headlessui-react/README.md @@ -317,6 +317,7 @@ function MyComponent({ isShowing }) { | `show` | Boolean | Whether the children should be shown or hidden. | | `as` | String Component _(Default: `'div'`)_ | The element or component to render in place of the `Transition` itself. | | `appear` | Boolean _(Default: `false`)_ | Whether the transition should run on initial mount. | +| `unmount` | Boolean _(Default: `true`)_ | Whether the element should be unmounted or hidden based on the show state. | | `enter` | String _(Default: '')_ | Classes to add to the transitioning element during the entire enter phase. | | `enterFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the enter phase starts. | | `enterTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the enter phase starts. | @@ -358,6 +359,7 @@ function MyComponent({ isShowing }) { | ----------- | ------------------------------------- | ------------------------------------------------------------------------------------- | | `as` | String Component _(Default: `'div'`)_ | The element or component to render in place of the `Transition.Child` itself. | | `appear` | Boolean _(Default: `false`)_ | Whether the transition should run on initial mount. | +| `unmount` | Boolean _(Default: `true`)_ | Whether the element should be unmounted or hidden based on the show state. | | `enter` | String _(Default: '')_ | Classes to add to the transitioning element during the entire enter phase. | | `enterFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the enter phase starts. | | `enterTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the enter phase starts. |