Open closed state (#466)

* simplify examples by using the implicit open/closed state

* introduce Open/Closed context (React)

* use Open/Closed context in Dialog component (React)

* use Open/Closed context in Disclosure component (React)

* use Open/Closed context in Listbox component (React)

* use Open/Closed context in Menu component (React)

* use Open/Closed context in Popover component (React)

* use Open/Closed context in Transition component (React)

* introduce Open/Closed context (Vue)

* use Open/Closed context in Dialog component (Vue)

* use Open/Closed context in Disclosure component (Vue)

* use Open/Closed context in Listbox component (Vue)

* use Open/Closed context in Menu component (Vue)

* use Open/Closed context in Popover component (Vue)

* use Open/Closed context in Transition component (Vue)

* use a ref in the Description comopnent

This allows us to update the ref and everything should work after that.
Currently we only saw the "current" state.

* add more Vue examples

* update changelog
This commit is contained in:
Robin Malfait
2021-05-03 13:11:19 +02:00
committed by GitHub
parent 91007b75ef
commit 34a10538d6
34 changed files with 1460 additions and 341 deletions
+6 -2
View File
@@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased - React]
- Nothing yet!
### Added
- Introduce Open/Closed state, to simplify component communication ([#466](https://github.com/tailwindlabs/headlessui/pull/466))
## [Unreleased - Vue]
- Nothing yet!
### Added
- Introduce Open/Closed state, to simplify component communication ([#466](https://github.com/tailwindlabs/headlessui/pull/466))
## [@headlessui/react@v1.1.1] - 2021-04-28
@@ -20,7 +20,7 @@ function Nested({ onClose, level = 0 }) {
return (
<>
<Dialog open={true} onClose={onClose} className="fixed z-10 inset-0">
{true && <Dialog.Overlay className="fixed inset-0 bg-gray-500 opacity-25" />}
<Dialog.Overlay className="fixed inset-0 bg-gray-500 opacity-25" />
<div
className="z-10 fixed left-12 top-24 bg-white w-96 p-4"
style={{
@@ -69,8 +69,8 @@ export default function Home() {
<button onClick={() => setNested(true)}>Show nested</button>
{nested && <Nested onClose={() => setNested(false)} />}
<Transition show={isOpen} as={Fragment}>
<Dialog open={isOpen} onClose={setIsOpen} static>
<Transition show={isOpen} as={Fragment} afterLeave={() => console.log('done')}>
<Dialog onClose={setIsOpen}>
<div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
@@ -136,96 +136,78 @@ export default function Home() {
</p>
<div className="relative inline-block text-left mt-10">
<Menu>
{({ open }) => (
<>
<span className="rounded-md shadow-sm">
<Menu.Button
ref={trigger}
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Choose a reason</span>
<svg
className="w-5 h-5 ml-2 -mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
<span className="rounded-md shadow-sm">
<Menu.Button
ref={trigger}
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Choose a reason</span>
<svg
className="w-5 h-5 ml-2 -mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<Portal>
<Menu.Items
ref={container}
static
className="z-20 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Transition
enter="transition duration-300 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Portal>
<Menu.Items
ref={container}
className="z-20 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Menu.Item
as="a"
href="#account-settings"
className={resolveClass}
>
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
Account settings
</Menu.Item>
<Menu.Item as="a" href="#support" className={resolveClass}>
Support
</Menu.Item>
<Menu.Item
as="a"
disabled
href="#new-feature"
className={resolveClass}
>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item
as="a"
href="#account-settings"
className={resolveClass}
>
Account settings
</Menu.Item>
<Menu.Item
as="a"
href="#support"
className={resolveClass}
>
Support
</Menu.Item>
<Menu.Item
as="a"
disabled
href="#new-feature"
className={resolveClass}
>
New feature (soon)
</Menu.Item>
<Menu.Item
as="a"
href="#license"
className={resolveClass}
>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item
as="a"
href="#sign-out"
className={resolveClass}
>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Portal>
</Transition>
</>
)}
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Portal>
</Transition>
</Menu>
</div>
</div>
@@ -6,25 +6,18 @@ export default function Home() {
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="w-full max-w-xs mx-auto">
<Disclosure>
{({ open }) => (
<>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Button>Trigger</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel static className="p-4 bg-white mt-4">
Content
</Disclosure.Panel>
</Transition>
</>
)}
<Transition
enter="transition duration-1000 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-1000 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className="p-4 bg-white mt-4">Content</Disclosure.Panel>
</Transition>
</Disclosure>
</div>
</div>
@@ -26,73 +26,65 @@ export default function Home() {
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="inline-block mt-64 text-left">
<Menu>
{({ open }) => (
<>
<span className="rounded-md shadow-sm">
<Menu.Button
ref={trigger}
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<span className="rounded-md shadow-sm">
<Menu.Button
ref={trigger}
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<div ref={container} className="w-56">
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items
static
className="bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div ref={container} className="w-56">
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items className="bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none">
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item>
{data => (
<a href="#support" className={resolveClass(data)}>
Support
</a>
)}
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</div>
</>
)}
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item>
{data => (
<a href="#support" className={resolveClass(data)}>
Support
</a>
)}
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</div>
</Menu>
</div>
</div>
@@ -18,65 +18,57 @@ export default function Home() {
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="relative inline-block text-left">
<Menu>
{({ open }) => (
<>
<span className="rounded-md shadow-sm">
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800">
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<span className="rounded-md shadow-sm">
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800">
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items
static
className="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<Transition
enter="transition duration-1000 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-1000 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none">
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item as="a" href="#support" className={resolveClass}>
Support
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item as="a" href="#support" className={resolveClass}>
Support
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
@@ -41,38 +41,30 @@ export default function Home() {
<div className="flex justify-center items-center space-x-12 p-12">
<button>Previous</button>
<Popover.Group as="nav" ar-label="Mythical University" className="flex space-x-3">
<Popover.Group as="nav" aria-label="Mythical University" className="flex space-x-3">
<Popover as="div" className="relative">
{({ open }) => (
<>
<Transition
as={Fragment}
show={open}
enter="transition ease-out duration-300 transform"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-300 transform"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Popover.Overlay
static
className="bg-opacity-75 bg-gray-500 fixed inset-0 z-20"
></Popover.Overlay>
</Transition>
<Transition
as={Fragment}
enter="transition ease-out duration-300 transform"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-300 transform"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Popover.Overlay className="bg-opacity-75 bg-gray-500 fixed inset-0 z-20"></Popover.Overlay>
</Transition>
<Popover.Button className="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900 relative z-30">
Normal
</Popover.Button>
<Popover.Panel className="absolute flex flex-col w-64 bg-gray-100 border-2 border-blue-900 z-30">
{links.map((link, i) => (
<Link key={link} hidden={i === 2}>
Normal - {link}
</Link>
))}
</Popover.Panel>
</>
)}
<Popover.Button className="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900 relative z-30">
Normal
</Popover.Button>
<Popover.Panel className="absolute flex flex-col w-64 bg-gray-100 border-2 border-blue-900 z-30">
{links.map((link, i) => (
<Link key={link} hidden={i === 2}>
Normal - {link}
</Link>
))}
</Popover.Panel>
</Popover>
<Popover as="div" className="relative">
@@ -17,6 +17,7 @@ import {
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys } from '../../test-utils/interactions'
import { PropsOf } from '../../types'
import { Transition } from '../transitions/transition'
jest.mock('../../hooks/use-id')
@@ -95,7 +96,6 @@ describe('Rendering', () => {
'should complain when an `onClose` prop is provided without an `open` prop',
suppressConsoleLogs(async () => {
expect(() =>
// @ts-expect-error
render(<Dialog as="div" onClose={() => {}} />)
).toThrowErrorMatchingInlineSnapshot(
`"You provided an \`onClose\` prop to the \`Dialog\`, but forgot an \`open\` prop."`
@@ -352,6 +352,44 @@ describe('Rendering', () => {
})
})
describe('Composition', () => {
it(
'should be possible to open the Dialog via a Transition component',
suppressConsoleLogs(async () => {
render(
<Transition show={true}>
<Dialog onClose={console.log}>
<Dialog.Description>{JSON.stringify}</Dialog.Description>
<TabSentinel />
</Dialog>
</Transition>
)
assertDialog({ state: DialogState.Visible })
assertDialogDescription({
state: DialogState.Visible,
textContent: JSON.stringify({ open: true }),
})
})
)
it(
'should be possible to close the Dialog via a Transition component',
suppressConsoleLogs(async () => {
render(
<Transition show={false}>
<Dialog onClose={console.log}>
<Dialog.Description>{JSON.stringify}</Dialog.Description>
<TabSentinel />
</Dialog>
</Transition>
)
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)
})
describe('Keyboard interactions', () => {
describe('`Escape` key', () => {
it(
@@ -32,6 +32,7 @@ import { ForcePortalRoot } from '../../internal/portal-force-root'
import { contains } from '../../internal/dom-containers'
import { Description, useDescriptions } from '../description/description'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State } from '../../internal/open-closed'
enum DialogStates {
Open,
@@ -108,7 +109,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
>(
props: Props<TTag, DialogRenderPropArg, DialogPropsWeControl> &
PropsForFeatures<typeof DialogRenderFeatures> & {
open: boolean
open?: boolean
onClose(value: boolean): void
initialFocus?: MutableRefObject<HTMLElement | null>
},
@@ -116,12 +117,21 @@ let DialogRoot = forwardRefWithAs(function Dialog<
) {
let { open, onClose, initialFocus, ...rest } = props
let usesOpenClosedState = useOpenClosed()
if (open === undefined && usesOpenClosedState !== null) {
// Update the `open` prop based on the open closed state
open = match(usesOpenClosedState, {
[State.Open]: true,
[State.Closed]: false,
})
}
let containers = useRef<Set<HTMLElement>>(new Set())
let internalDialogRef = useRef<HTMLDivElement | null>(null)
let dialogRef = useSyncRefs(internalDialogRef, ref)
// Validations
let hasOpen = props.hasOwnProperty('open')
let hasOpen = props.hasOwnProperty('open') || usesOpenClosedState !== null
let hasOnClose = props.hasOwnProperty('onClose')
if (!hasOpen && !hasOnClose) {
throw new Error(
@@ -152,8 +162,14 @@ let DialogRoot = forwardRefWithAs(function Dialog<
`You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: ${onClose}`
)
}
let dialogState = open ? DialogStates.Open : DialogStates.Closed
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return dialogState === DialogStates.Open
})()
let [state, dispatch] = useReducer(stateReducer, {
titleId: null,
@@ -283,7 +299,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
slot,
defaultTag: DEFAULT_DIALOG_TAG,
features: DialogRenderFeatures,
visible: dialogState === DialogStates.Open,
visible,
name: 'Dialog',
})}
</DescriptionProvider>
@@ -1,4 +1,4 @@
import React, { createElement } from 'react'
import React, { createElement, useEffect } from 'react'
import { render } from '@testing-library/react'
import { Disclosure } from './disclosure'
@@ -11,11 +11,22 @@ import {
getDisclosurePanel,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
import { Transition } from '../transitions/transition'
jest.mock('../../hooks/use-id')
afterAll(() => jest.restoreAllMocks())
function nextFrame() {
return new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve()
})
})
})
}
describe('Safe guards', () => {
it.each([
['Disclosure.Button', Disclosure.Button],
@@ -232,6 +243,63 @@ describe('Rendering', () => {
})
})
describe('Composition', () => {
function Debug({ fn, name }: { fn: (text: string) => void; name: string }) {
useEffect(() => {
fn(`Mounting - ${name}`)
return () => {
fn(`Unmounting - ${name}`)
}
}, [fn, name])
return null
}
it(
'should be possible to control the Disclosure.Panel by wrapping it in a Transition component',
suppressConsoleLogs(async () => {
let orderFn = jest.fn()
render(
<Disclosure>
<Disclosure.Button>Trigger</Disclosure.Button>
<Debug name="Disclosure" fn={orderFn} />
<Transition>
<Debug name="Transition" fn={orderFn} />
<Disclosure.Panel>
<Transition.Child>
<Debug name="Transition.Child" fn={orderFn} />
</Transition.Child>
</Disclosure.Panel>
</Transition>
</Disclosure>
)
// Verify the Disclosure is hidden
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
// Open the Disclosure component
await click(getDisclosureButton())
// Verify the Disclosure is visible
assertDisclosurePanel({ state: DisclosureState.Visible })
// Unmount the full tree
await click(getDisclosureButton())
// Wait for all transitions to finish
await nextFrame()
// Verify that we tracked the `mounts` and `unmounts` in the correct order
expect(orderFn.mock.calls).toEqual([
['Mounting - Disclosure'],
['Mounting - Transition'],
['Mounting - Transition.Child'],
['Unmounting - Transition.Child'],
['Unmounting - Transition'],
])
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it(
@@ -23,6 +23,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useId } from '../../hooks/use-id'
import { Keys } from '../keyboard'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
enum DisclosureStates {
Open,
@@ -137,12 +138,19 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
return (
<DisclosureContext.Provider value={reducerBag}>
{render({
props: passthroughProps,
slot,
defaultTag: DEFAULT_DISCLOSURE_TAG,
name: 'Disclosure',
})}
<OpenClosedProvider
value={match(disclosureState, {
[DisclosureStates.Open]: State.Open,
[DisclosureStates.Closed]: State.Closed,
})}
>
{render({
props: passthroughProps,
slot,
defaultTag: DEFAULT_DISCLOSURE_TAG,
name: 'Disclosure',
})}
</OpenClosedProvider>
</DisclosureContext.Provider>
)
}
@@ -248,6 +256,15 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
dispatch({ type: ActionTypes.LinkPanel })
})
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return state.disclosureState === DisclosureStates.Open
})()
// Unlink on "unmount" myself
useEffect(() => () => dispatch({ type: ActionTypes.UnlinkPanel }), [dispatch])
@@ -273,7 +290,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible: state.disclosureState === DisclosureStates.Open,
visible,
name: 'Disclosure.Panel',
})
})
@@ -1,4 +1,4 @@
import React, { createElement, useState } from 'react'
import React, { createElement, useState, useEffect } from 'react'
import { render } from '@testing-library/react'
import { Listbox } from './listbox'
@@ -36,6 +36,7 @@ import {
ListboxState,
getByText,
} from '../../test-utils/accessibility-assertions'
import { Transition } from '../transitions/transition'
jest.mock('../../hooks/use-id')
@@ -546,6 +547,72 @@ describe('Rendering composition', () => {
)
})
describe('Composition', () => {
function Debug({ fn, name }: { fn: (text: string) => void; name: string }) {
useEffect(() => {
fn(`Mounting - ${name}`)
return () => {
fn(`Unmounting - ${name}`)
}
}, [fn, name])
return null
}
it(
'should be possible to wrap the Listbox.Options with a Transition component',
suppressConsoleLogs(async () => {
let orderFn = jest.fn()
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button>Trigger</Listbox.Button>
<Debug name="Listbox" fn={orderFn} />
<Transition>
<Debug name="Transition" fn={orderFn} />
<Listbox.Options>
<Listbox.Option value="a">
{data => (
<>
{JSON.stringify(data)}
<Debug name="Listbox.Option" fn={orderFn} />
</>
)}
</Listbox.Option>
</Listbox.Options>
</Transition>
</Listbox>
)
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })
await click(getListboxButton())
assertListboxButton({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({
state: ListboxState.Visible,
textContent: JSON.stringify({ active: false, selected: false, disabled: false }),
})
await click(getListboxButton())
// Verify that we tracked the `mounts` and `unmounts` in the correct order
expect(orderFn.mock.calls).toEqual([
['Mounting - Listbox'],
['Mounting - Transition'],
['Mounting - Listbox.Option'],
['Unmounting - Transition'],
['Unmounting - Listbox.Option'],
])
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it(
@@ -31,6 +31,7 @@ import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
enum ListboxStates {
Open,
@@ -240,12 +241,19 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
return (
<ListboxContext.Provider value={reducerBag}>
{render({
props: passThroughProps,
slot,
defaultTag: DEFAULT_LISTBOX_TAG,
name: 'Listbox',
})}
<OpenClosedProvider
value={match(listboxState, {
[ListboxStates.Open]: State.Open,
[ListboxStates.Closed]: State.Closed,
})}
>
{render({
props: passThroughProps,
slot,
defaultTag: DEFAULT_LISTBOX_TAG,
name: 'Listbox',
})}
</OpenClosedProvider>
</ListboxContext.Provider>
)
}
@@ -426,6 +434,15 @@ let Options = forwardRefWithAs(function Options<
let d = useDisposables()
let searchDisposables = useDisposables()
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return state.listboxState === ListboxStates.Open
})()
useIsoMorphicEffect(() => {
let container = state.optionsRef.current
if (!container) return
@@ -531,7 +548,7 @@ let Options = forwardRefWithAs(function Options<
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
visible: state.listboxState === ListboxStates.Open,
visible,
name: 'Listbox.Options',
})
})
@@ -1,4 +1,4 @@
import React, { createElement } from 'react'
import React, { createElement, useEffect } from 'react'
import { render } from '@testing-library/react'
import { Menu } from './menu'
@@ -31,6 +31,7 @@ import {
Keys,
MouseButton,
} from '../../test-utils/interactions'
import { Transition } from '../transitions/transition'
jest.mock('../../hooks/use-id')
@@ -431,6 +432,72 @@ describe('Rendering composition', () => {
)
})
describe('Composition', () => {
function Debug({ fn, name }: { fn: (text: string) => void; name: string }) {
useEffect(() => {
fn(`Mounting - ${name}`)
return () => {
fn(`Unmounting - ${name}`)
}
}, [fn, name])
return null
}
it(
'should be possible to wrap the Menu.Items with a Transition component',
suppressConsoleLogs(async () => {
let orderFn = jest.fn()
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Debug name="Menu" fn={orderFn} />
<Transition>
<Debug name="Transition" fn={orderFn} />
<Menu.Items>
<Menu.Item as="a">
{data => (
<>
{JSON.stringify(data)}
<Debug name="Menu.Item" fn={orderFn} />
</>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
)
assertMenuButton({
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.InvisibleUnmounted })
await click(getMenuButton())
assertMenuButton({
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({
state: MenuState.Visible,
textContent: JSON.stringify({ active: false, disabled: false }),
})
await click(getMenuButton())
// Verify that we tracked the `mounts` and `unmounts` in the correct order
expect(orderFn.mock.calls).toEqual([
['Mounting - Menu'],
['Mounting - Transition'],
['Mounting - Menu.Item'],
['Unmounting - Transition'],
['Unmounting - Menu.Item'],
])
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it(
@@ -33,6 +33,7 @@ import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
enum MenuStates {
Open,
@@ -197,7 +198,14 @@ export function Menu<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
return (
<MenuContext.Provider value={reducerBag}>
{render({ props, slot, defaultTag: DEFAULT_MENU_TAG, name: 'Menu' })}
<OpenClosedProvider
value={match(menuState, {
[MenuStates.Open]: State.Open,
[MenuStates.Closed]: State.Closed,
})}
>
{render({ props, slot, defaultTag: DEFAULT_MENU_TAG, name: 'Menu' })}
</OpenClosedProvider>
</MenuContext.Provider>
)
}
@@ -330,6 +338,15 @@ let Items = forwardRefWithAs(function Items<TTag extends ElementType = typeof DE
let id = `headlessui-menu-items-${useId()}`
let searchDisposables = useDisposables()
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return state.menuState === MenuStates.Open
})()
useEffect(() => {
let container = state.itemsRef.current
if (!container) return
@@ -455,7 +472,7 @@ let Items = forwardRefWithAs(function Items<TTag extends ElementType = typeof DE
slot,
defaultTag: DEFAULT_ITEMS_TAG,
features: ItemsRenderFeatures,
visible: state.menuState === MenuStates.Open,
visible,
name: 'Menu.Items',
})
})
@@ -1,4 +1,4 @@
import React, { createElement } from 'react'
import React, { createElement, useEffect } from 'react'
import { render } from '@testing-library/react'
import { Popover } from './popover'
@@ -16,11 +16,22 @@ import {
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton, shift } from '../../test-utils/interactions'
import { Portal } from '../portal/portal'
import { Transition } from '../transitions/transition'
jest.mock('../../hooks/use-id')
afterAll(() => jest.restoreAllMocks())
function nextFrame() {
return new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve()
})
})
})
}
describe('Safe guards', () => {
it.each([
['Popover.Button', Popover.Button],
@@ -376,6 +387,57 @@ describe('Rendering', () => {
})
})
describe('Composition', () => {
function Debug({ fn, name }: { fn: (text: string) => void; name: string }) {
useEffect(() => {
fn(`Mounting - ${name}`)
return () => {
fn(`Unmounting - ${name}`)
}
}, [fn, name])
return null
}
it(
'should be possible to wrap the Popover.Panel with a Transition component',
suppressConsoleLogs(async () => {
let orderFn = jest.fn()
render(
<Popover>
<Popover.Button>Trigger</Popover.Button>
<Debug name="Popover" fn={orderFn} />
<Transition>
<Debug name="Transition" fn={orderFn} />
<Popover.Panel>
<Transition.Child>
<Debug name="Transition.Child" fn={orderFn} />
</Transition.Child>
</Popover.Panel>
</Transition>
</Popover>
)
// Open the popover
await click(getPopoverButton())
// Close the popover
await click(getPopoverButton())
// Wait for all transitions to finish
await nextFrame()
// Verify that we tracked the `mounts` and `unmounts` in the correct order
expect(orderFn.mock.calls).toEqual([
['Mounting - Popover'],
['Mounting - Transition'],
['Mounting - Transition.Child'],
['Unmounting - Transition.Child'],
['Unmounting - Transition'],
])
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it(
@@ -33,6 +33,7 @@ import {
FocusableMode,
} from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
enum PopoverStates {
Open,
@@ -220,12 +221,19 @@ export function Popover<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
return (
<PopoverContext.Provider value={reducerBag}>
{render({
props,
slot,
defaultTag: DEFAULT_POPOVER_TAG,
name: 'Popover',
})}
<OpenClosedProvider
value={match(popoverState, {
[PopoverStates.Open]: State.Open,
[PopoverStates.Closed]: State.Closed,
})}
>
{render({
props,
slot,
defaultTag: DEFAULT_POPOVER_TAG,
name: 'Popover',
})}
</OpenClosedProvider>
</PopoverContext.Provider>
)
}
@@ -469,6 +477,15 @@ let Overlay = forwardRefWithAs(function Overlay<
let id = `headlessui-popover-overlay-${useId()}`
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return popoverState === PopoverStates.Open
})()
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
@@ -493,7 +510,7 @@ let Overlay = forwardRefWithAs(function Overlay<
slot,
defaultTag: DEFAULT_OVERLAY_TAG,
features: OverlayRenderFeatures,
visible: popoverState === PopoverStates.Open,
visible,
name: 'Popover.Overlay',
})
})
@@ -521,6 +538,15 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
dispatch({ type: ActionTypes.SetPanel, panel })
})
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return state.popoverState === PopoverStates.Open
})()
let handleKeyDown = useCallback(
(event: KeyboardEvent) => {
switch (event.key) {
@@ -630,7 +656,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible: state.popoverState === PopoverStates.Open,
visible,
name: 'Popover.Panel',
})}
</PopoverPanelContext.Provider>
@@ -22,6 +22,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { Features, PropsForFeatures, render, RenderStrategy } from '../../utils/render'
import { Reason, transition } from './utils/transition'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
type ID = ReturnType<typeof useId>
@@ -318,24 +319,40 @@ function TransitionChild<TTag extends ElementType = typeof DEFAULT_TRANSITION_CH
return (
<NestingContext.Provider value={nesting}>
{render({
props: { ...passthroughProps, ...propsWeControl },
defaultTag: DEFAULT_TRANSITION_CHILD_TAG,
features: TransitionChildRenderFeatures,
visible: state === TreeStates.Visible,
name: 'Transition.Child',
})}
<OpenClosedProvider
value={match(state, {
[TreeStates.Visible]: State.Open,
[TreeStates.Hidden]: State.Closed,
})}
>
{render({
props: { ...passthroughProps, ...propsWeControl },
defaultTag: DEFAULT_TRANSITION_CHILD_TAG,
features: TransitionChildRenderFeatures,
visible: state === TreeStates.Visible,
name: 'Transition.Child',
})}
</OpenClosedProvider>
</NestingContext.Provider>
)
}
export function Transition<TTag extends ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>(
props: TransitionChildProps<TTag> & { show: boolean; appear?: boolean }
props: TransitionChildProps<TTag> & { show?: boolean; appear?: boolean }
) {
// @ts-expect-error
let { show, appear = false, unmount, ...passthroughProps } = props as typeof props
if (![true, false].includes(show)) {
let usesOpenClosedState = useOpenClosed()
if (show === undefined && usesOpenClosedState !== null) {
show = match(usesOpenClosedState, {
[State.Open]: true,
[State.Closed]: false,
})
}
if (![true, false].includes((show as unknown) as boolean)) {
throw new Error('A <Transition /> is used but it is missing a `show={true | false}` prop.')
}
@@ -347,7 +364,7 @@ export function Transition<TTag extends ElementType = typeof DEFAULT_TRANSITION_
let initial = useIsInitialRender()
let transitionBag = useMemo<TransitionContextValues>(
() => ({ show, appear: appear || !initial }),
() => ({ show: show as boolean, appear: appear || !initial }),
[show, appear, initial]
)
@@ -0,0 +1,29 @@
import React, {
createContext,
useContext,
// Types
ReactNode,
ReactElement,
} from 'react'
let Context = createContext<State | null>(null)
Context.displayName = 'OpenClosedContext'
export enum State {
Open,
Closed,
}
export function useOpenClosed() {
return useContext(Context)
}
interface Props {
value: State
children: ReactNode
}
export function OpenClosedProvider({ value, children }: Props): ReactElement {
return <Context.Provider value={value}>{children}</Context.Provider>
}
@@ -8,7 +8,7 @@
</button>
<TransitionRoot :show="isOpen" as="template">
<Dialog :open="isOpen" @close="setIsOpen" static>
<Dialog @close="setIsOpen">
<div class="fixed z-10 inset-0 overflow-y-auto">
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
@@ -74,7 +74,7 @@
permanently removed. This action cannot be undone.
</p>
<div class="relative inline-block text-left mt-10">
<Menu v-slot="{ open }">
<Menu>
<span class="rounded-md shadow-sm">
<MenuButton
ref="trigger"
@@ -95,9 +95,16 @@
</MenuButton>
</span>
<Portal v-if="open">
<TransitionRoot
enter="transition duration-300 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Portal>
<MenuItems
static
ref="container"
class="z-20 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
@@ -135,6 +142,7 @@
</div>
</MenuItems>
</Portal>
</TransitionRoot>
</Menu>
</div>
</div>
@@ -0,0 +1,33 @@
<template>
<div class="flex justify-center w-screen h-full p-12 bg-gray-50">
<div class="w-full max-w-xs mx-auto">
<Disclosure>
<DisclosureButton>Trigger</DisclosureButton>
<TransitionRoot
enter="transition duration-1000 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-1000 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<DisclosurePanel class="p-4 bg-white mt-4">Content</DisclosurePanel>
</TransitionRoot>
</Disclosure>
</div>
</div>
</template>
<script>
import { Disclosure, DisclosureButton, DisclosurePanel, TransitionRoot } from '@headlessui/vue'
export default {
components: {
Disclosure,
DisclosureButton,
DisclosurePanel,
TransitionRoot,
},
}
</script>
@@ -94,6 +94,17 @@
}
]
},
{
"name": "Disclosure",
"path": "/disclosure",
"children": [
{
"name": "Disclosure",
"path": "/disclosure/disclosure",
"component": "./components/disclosure/disclosure.vue"
}
]
},
{
"name": "Dialog",
"path": "/dialog",
@@ -11,6 +11,7 @@ import {
// Types
ComputedRef,
InjectionKey,
Ref,
} from 'vue'
import { useId } from '../../hooks/use-id'
@@ -20,7 +21,7 @@ import { render } from '../../utils/render'
let DescriptionContext = Symbol('DescriptionContext') as InjectionKey<{
register(value: string): () => void
slot: Record<string, any>
slot: Ref<Record<string, any>>
name: string
props: Record<string, any>
}>
@@ -34,11 +35,11 @@ function useDescriptionContext() {
}
export function useDescriptions({
slot = {},
slot = ref({}),
name = 'Description',
props = {},
}: {
slot?: Record<string, unknown>
slot?: Ref<Record<string, unknown>>
name?: string
props?: Record<string, unknown>
} = {}): ComputedRef<string | undefined> {
@@ -70,7 +71,7 @@ export let Description = defineComponent({
as: { type: [Object, String], default: 'p' },
},
render() {
let { name = 'Description', slot = {}, props = {} } = this.context
let { name = 'Description', slot = ref({}), props = {} } = this.context
let passThroughProps = this.$props
let propsWeControl = {
...Object.entries(props).reduce(
@@ -82,7 +83,7 @@ export let Description = defineComponent({
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
slot: slot.value,
attrs: this.$attrs,
slots: this.$slots,
name,
@@ -2,6 +2,7 @@ import { defineComponent, ref, nextTick, h } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Dialog, DialogOverlay, DialogTitle, DialogDescription } from './dialog'
import { TransitionRoot } from '../transitions/transition'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
import {
DialogState,
@@ -429,6 +430,52 @@ describe('Rendering', () => {
})
})
describe('Composition', () => {
it(
'should be possible to open the Dialog via a Transition component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { TransitionRoot },
template: `
<TransitionRoot show>
<Dialog @close="() => {}">
<DialogDescription v-slot="data">{{JSON.stringify(data)}}</DialogDescription>
<TabSentinel />
</Dialog>
</Transition>
`,
})
await new Promise<void>(nextTick)
assertDialog({ state: DialogState.Visible })
assertDialogDescription({
state: DialogState.Visible,
textContent: JSON.stringify({ open: true }),
})
})
)
it(
'should be possible to close the Dialog via a Transition component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { TransitionRoot },
template: `
<TransitionRoot :show="false">
<Dialog @close="() => {}">
<DialogDescription v-slot="data">{{JSON.stringify(data)}}</DialogDescription>
<TabSentinel />
</Dialog>
</Transition>
`,
})
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)
})
describe('Keyboard interactions', () => {
describe('`Escape` key', () => {
it(
@@ -31,6 +31,7 @@ import { match } from '../../utils/match'
import { ForcePortalRoot } from '../../internal/portal-force-root'
import { Description, useDescriptions } from '../description/description'
import { dom } from '../../utils/dom'
import { useOpenClosed, State } from '../../internal/open-closed'
enum DialogStates {
Open,
@@ -88,7 +89,8 @@ export let Dialog = defineComponent({
onClick: this.handleClick,
onKeydown: this.handleKeyDown,
}
let { open, initialFocus, ...passThroughProps } = this.$props
let { open: _, initialFocus, ...passThroughProps } = this.$props
let slot = { open: this.dialogState === DialogStates.Open }
return h(ForcePortalRoot, { force: true }, () =>
@@ -100,7 +102,7 @@ export let Dialog = defineComponent({
slot,
attrs: this.$attrs,
slots: this.$slots,
visible: open,
visible: this.visible,
features: Features.RenderStrategy | Features.Static,
name: 'Dialog',
})
@@ -112,23 +114,43 @@ export let Dialog = defineComponent({
setup(props, { emit }) {
let containers = ref<Set<HTMLElement>>(new Set())
let usesOpenClosedState = useOpenClosed()
let open = computed(() => {
// @ts-expect-error We are comparing to a uuid stirng at runtime
if (props.open === Missing && usesOpenClosedState !== null) {
// Update the `open` prop based on the open closed state
return match(usesOpenClosedState.value, {
[State.Open]: true,
[State.Closed]: false,
})
}
return props.open
})
// Validations
// @ts-expect-error We are comparing to a uuid stirng at runtime
let hasOpen = props.open !== Missing
let hasOpen = props.open !== Missing || usesOpenClosedState !== null
if (!hasOpen) {
throw new Error(`You forgot to provide an \`open\` prop to the \`Dialog\`.`)
}
if (typeof props.open !== 'boolean') {
if (typeof open.value !== 'boolean') {
throw new Error(
`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${
props.open === Missing ? undefined : props.open
open.value === Missing ? undefined : props.open
}`
)
}
let dialogState = computed(() => (props.open ? DialogStates.Open : DialogStates.Closed))
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return dialogState.value === DialogStates.Open
})
let internalDialogRef = ref<HTMLDivElement | null>(null)
let enabled = ref(dialogState.value === DialogStates.Open)
@@ -154,7 +176,7 @@ export let Dialog = defineComponent({
let describedby = useDescriptions({
name: 'DialogDescription',
slot: { open: props.open },
slot: computed(() => ({ open: open.value })),
})
let titleId = ref<StateDefinition['titleId']['value']>(null)
@@ -235,6 +257,8 @@ export let Dialog = defineComponent({
dialogState,
titleId,
describedby,
visible,
open,
handleClick(event: MouseEvent) {
event.stopPropagation()
},
@@ -1,4 +1,4 @@
import { defineComponent, nextTick } from 'vue'
import { defineComponent, nextTick, ref, watch } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
@@ -11,14 +11,10 @@ import {
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
jest.mock('../../hooks/use-id')
beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
})
afterAll(() => jest.restoreAllMocks())
function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) {
@@ -266,6 +262,120 @@ describe('Rendering', () => {
})
})
describe('Composition', () => {
let OpenClosedWrite = defineComponent({
props: { open: { type: Boolean } },
setup(props, { slots }) {
useOpenClosedProvider(ref(props.open ? State.Open : State.Closed))
return () => slots.default?.()
},
})
let OpenClosedRead = defineComponent({
emits: ['read'],
setup(_, { slots, emit }) {
let state = useOpenClosed()
watch([state], ([value]) => emit('read', value))
return () => slots.default?.()
},
})
it(
'should always open the DisclosurePanel because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: `
<Disclosure>
<DisclosureButton>Trigger</DisclosureButton>
<OpenClosedWrite :open="true">
<DisclosurePanel v-slot="data">
{{JSON.stringify(data)}}
</DisclosurePanel>
</OpenClosedWrite>
</Disclosure>
`,
})
// Verify the Disclosure is visible
assertDisclosurePanel({ state: DisclosureState.Visible })
// Let's try and open the Disclosure
await click(getDisclosureButton())
// Verify the Disclosure is still visible
assertDisclosurePanel({ state: DisclosureState.Visible })
})
)
it(
'should always close the DisclosurePanel because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: `
<Disclosure>
<DisclosureButton>Trigger</DisclosureButton>
<OpenClosedWrite :open="false">
<DisclosurePanel v-slot="data">
{{JSON.stringify(data)}}
</DisclosurePanel>
</OpenClosedWrite>
</Disclosure>
`,
})
// Verify the Disclosure is hidden
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
// Let's try and open the Disclosure
await click(getDisclosureButton())
// Verify the Disclosure is still hidden
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
})
)
it(
'should be possible to read the OpenClosed state',
suppressConsoleLogs(async () => {
let readFn = jest.fn()
renderTemplate({
components: { OpenClosedRead },
template: `
<Disclosure>
<DisclosureButton>Trigger</DisclosureButton>
<OpenClosedRead @read="readFn">
<DisclosurePanel></DisclosurePanel>
</OpenClosedRead>
</Disclosure>
`,
setup() {
return { readFn }
},
})
await new Promise<void>(nextTick)
// Verify the Disclosure is hidden
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
// Let's toggle the Disclosure 3 times
await click(getDisclosureButton())
await click(getDisclosureButton())
await click(getDisclosureButton())
// Verify the Disclosure is visible
assertDisclosurePanel({ state: DisclosureState.Visible })
expect(readFn).toHaveBeenCalledTimes(3)
expect(readFn).toHaveBeenNthCalledWith(1, State.Open)
expect(readFn).toHaveBeenNthCalledWith(2, State.Closed)
expect(readFn).toHaveBeenNthCalledWith(3, State.Open)
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it(
@@ -6,6 +6,7 @@ import { match } from '../../utils/match'
import { render, Features } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { dom } from '../../utils/dom'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
enum DisclosureStates {
Open,
@@ -61,6 +62,14 @@ export let Disclosure = defineComponent({
} as StateDefinition
provide(DisclosureContext, api)
useOpenClosedProvider(
computed(() => {
return match(disclosureState.value, {
[DisclosureStates.Open]: State.Open,
[DisclosureStates.Closed]: State.Closed,
})
})
)
return () => {
let { defaultOpen: _, ...passThroughProps } = props
@@ -159,7 +168,7 @@ export let DisclosurePanel = defineComponent({
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: slot.open,
visible: this.visible,
name: 'DisclosurePanel',
})
},
@@ -167,6 +176,15 @@ export let DisclosurePanel = defineComponent({
let api = useDisclosureContext('DisclosurePanel')
let panelId = `headlessui-disclosure-panel-${useId()}`
return { id: panelId, el: api.panelRef }
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return api.disclosureState.value === DisclosureStates.Open
})
return { id: panelId, el: api.panelRef, visible }
},
})
@@ -35,6 +35,7 @@ import {
MouseButton,
} from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
jest.mock('../../hooks/use-id')
@@ -594,6 +595,126 @@ describe('Rendering composition', () => {
)
})
describe('Composition', () => {
let OpenClosedWrite = defineComponent({
props: { open: { type: Boolean } },
setup(props, { slots }) {
useOpenClosedProvider(ref(props.open ? State.Open : State.Closed))
return () => slots.default?.()
},
})
let OpenClosedRead = defineComponent({
emits: ['read'],
setup(_, { slots, emit }) {
let state = useOpenClosed()
watch([state], ([value]) => emit('read', value))
return () => slots.default?.()
},
})
it(
'should always open the ListboxOptions because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: html`
<Listbox>
<ListboxButton>Trigger</ListboxButton>
<OpenClosedWrite :open="true">
<ListboxOptions v-slot="data">
{{JSON.stringify(data)}}
</ListboxOptions>
</OpenClosedWrite>
</Listbox>
`,
})
await new Promise<void>(nextTick)
// Verify the Listbox is visible
assertListbox({ state: ListboxState.Visible })
// Let's try and open the Listbox
await click(getListboxButton())
// Verify the Listbox is still visible
assertListbox({ state: ListboxState.Visible })
})
)
it(
'should always close the ListboxOptions because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: html`
<Listbox>
<ListboxButton>Trigger</ListboxButton>
<OpenClosedWrite :open="false">
<ListboxOptions v-slot="data">
{{JSON.stringify(data)}}
</ListboxOptions>
</OpenClosedWrite>
</Listbox>
`,
})
await new Promise<void>(nextTick)
// Verify the Listbox is hidden
assertListbox({ state: ListboxState.InvisibleUnmounted })
// Let's try and open the Listbox
await click(getListboxButton())
// Verify the Listbox is still hidden
assertListbox({ state: ListboxState.InvisibleUnmounted })
})
)
it(
'should be possible to read the OpenClosed state',
suppressConsoleLogs(async () => {
let readFn = jest.fn()
renderTemplate({
components: { OpenClosedRead },
template: html`
<Listbox v-model="value">
<ListboxButton>Trigger</ListboxButton>
<OpenClosedRead @read="readFn">
<ListboxOptions>
<ListboxOption value="a">Option A</ListboxOption>
</ListboxOptions>
</OpenClosedRead>
</Listbox>
`,
setup() {
return { value: ref(null), readFn }
},
})
await new Promise<void>(nextTick)
// Verify the Listbox is hidden
assertListbox({ state: ListboxState.InvisibleUnmounted })
// Let's toggle the Listbox 3 times
await click(getListboxButton())
await click(getListboxButton())
await click(getListboxButton())
// Verify the Listbox is visible
assertListbox({ state: ListboxState.Visible })
expect(readFn).toHaveBeenCalledTimes(3)
expect(readFn).toHaveBeenNthCalledWith(1, State.Open)
expect(readFn).toHaveBeenNthCalledWith(2, State.Closed)
expect(readFn).toHaveBeenNthCalledWith(3, State.Open)
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it(
@@ -22,6 +22,8 @@ import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
import { resolvePropValue } from '../../utils/resolve-prop-value'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
import { match } from '../../utils/match'
enum ListboxStates {
Open,
@@ -194,6 +196,14 @@ export let Listbox = defineComponent({
// @ts-expect-error Types of property 'dataRef' are incompatible.
provide(ListboxContext, api)
useOpenClosedProvider(
computed(() =>
match(listboxState.value, {
[ListboxStates.Open]: State.Open,
[ListboxStates.Closed]: State.Closed,
})
)
)
return () => {
let slot = { open: listboxState.value === ListboxStates.Open, disabled }
@@ -360,7 +370,7 @@ export let ListboxOptions = defineComponent({
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: slot.open,
visible: this.visible,
name: 'ListboxOptions',
})
},
@@ -437,7 +447,16 @@ export let ListboxOptions = defineComponent({
}
}
return { id, el: api.optionsRef, handleKeyDown }
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return api.listboxState.value === ListboxStates.Open
})
return { id, el: api.optionsRef, handleKeyDown, visible }
},
})
@@ -1,4 +1,4 @@
import { defineComponent, h, nextTick } from 'vue'
import { defineComponent, h, nextTick, ref, watch } 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'
@@ -30,6 +30,7 @@ import {
MouseButton,
} from '../../test-utils/interactions'
import { jsx } from '../../test-utils/html'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
jest.mock('../../hooks/use-id')
@@ -763,6 +764,124 @@ describe('Rendering composition', () => {
)
})
describe('Composition', () => {
let OpenClosedWrite = defineComponent({
props: { open: { type: Boolean } },
setup(props, { slots }) {
useOpenClosedProvider(ref(props.open ? State.Open : State.Closed))
return () => slots.default?.()
},
})
let OpenClosedRead = defineComponent({
emits: ['read'],
setup(_, { slots, emit }) {
let state = useOpenClosed()
watch([state], ([value]) => emit('read', value))
return () => slots.default?.()
},
})
it(
'should always open the MenuItems because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: jsx`
<Menu>
<MenuButton>Trigger</MenuButton>
<OpenClosedWrite :open="true">
<MenuItems v-slot="data">
{{JSON.stringify(data)}}
</MenuItems>
</OpenClosedWrite>
</Menu>
`,
})
await new Promise<void>(nextTick)
// Verify the Menu is visible
assertMenu({ state: MenuState.Visible })
// Let's try and open the Menu
await click(getMenuButton())
// Verify the Menu is still visible
assertMenu({ state: MenuState.Visible })
})
)
it(
'should always close the MenuItems because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: jsx`
<Menu>
<MenuButton>Trigger</MenuButton>
<OpenClosedWrite :open="false">
<MenuItems v-slot="data">
{{JSON.stringify(data)}}
</MenuItems>
</OpenClosedWrite>
</Menu>
`,
})
await new Promise<void>(nextTick)
// Verify the Menu is hidden
assertMenu({ state: MenuState.InvisibleUnmounted })
// Let's try and open the Menu
await click(getMenuButton())
// Verify the Menu is still hidden
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
it(
'should be possible to read the OpenClosed state',
suppressConsoleLogs(async () => {
let readFn = jest.fn()
renderTemplate({
components: { OpenClosedRead },
template: jsx`
<Menu>
<MenuButton>Trigger</MenuButton>
<OpenClosedRead @read="readFn">
<MenuItems></MenuItems>
</OpenClosedRead>
</Menu>
`,
setup() {
return { readFn }
},
})
await new Promise<void>(nextTick)
// Verify the Menu is hidden
assertMenu({ state: MenuState.InvisibleUnmounted })
// Let's toggle the Menu 3 times
await click(getMenuButton())
await click(getMenuButton())
await click(getMenuButton())
// Verify the Menu is visible
assertMenu({ state: MenuState.Visible })
expect(readFn).toHaveBeenCalledTimes(3)
expect(readFn).toHaveBeenNthCalledWith(1, State.Open)
expect(readFn).toHaveBeenNthCalledWith(2, State.Closed)
expect(readFn).toHaveBeenNthCalledWith(3, State.Open)
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it('should be possible to open the menu with Enter', async () => {
@@ -19,6 +19,8 @@ import { resolvePropValue } from '../../utils/resolve-prop-value'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { match } from '../../utils/match'
enum MenuStates {
Open,
@@ -153,6 +155,14 @@ export let Menu = defineComponent({
// @ts-expect-error Types of property 'dataRef' are incompatible.
provide(MenuContext, api)
useOpenClosedProvider(
computed(() =>
match(menuState.value, {
[MenuStates.Open]: State.Open,
[MenuStates.Closed]: State.Closed,
})
)
)
return () => {
let slot = { open: menuState.value === MenuStates.Open }
@@ -283,7 +293,7 @@ export let MenuItems = defineComponent({
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: slot.open,
visible: this.visible,
name: 'MenuItems',
})
},
@@ -384,7 +394,16 @@ export let MenuItems = defineComponent({
}
}
return { id, el: api.itemsRef, handleKeyDown, handleKeyUp }
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return api.menuState.value === MenuStates.Open
})
return { id, el: api.itemsRef, handleKeyDown, handleKeyUp, visible }
},
})
@@ -1,4 +1,4 @@
import { defineComponent, nextTick } from 'vue'
import { defineComponent, nextTick, ref, watch } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Popover, PopoverGroup, PopoverButton, PopoverPanel, PopoverOverlay } from './popover'
@@ -17,6 +17,7 @@ import {
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton, shift } from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
jest.mock('../../hooks/use-id')
@@ -429,6 +430,124 @@ describe('Rendering', () => {
})
})
describe('Composition', () => {
let OpenClosedWrite = defineComponent({
props: { open: { type: Boolean } },
setup(props, { slots }) {
useOpenClosedProvider(ref(props.open ? State.Open : State.Closed))
return () => slots.default?.()
},
})
let OpenClosedRead = defineComponent({
emits: ['read'],
setup(_, { slots, emit }) {
let state = useOpenClosed()
watch([state], ([value]) => emit('read', value))
return () => slots.default?.()
},
})
it(
'should always open the PopoverPanel because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: html`
<Popover>
<PopoverButton>Trigger</PopoverButton>
<OpenClosedWrite :open="true">
<PopoverPanel v-slot="data">
{{JSON.stringify(data)}}
</PopoverPanel>
</OpenClosedWrite>
</Popover>
`,
})
await new Promise<void>(nextTick)
// Verify the Popover is visible
assertPopoverPanel({ state: PopoverState.Visible })
// Let's try and open the Popover
await click(getPopoverButton())
// Verify the Popover is still visible
assertPopoverPanel({ state: PopoverState.Visible })
})
)
it(
'should always close the PopoverPanel because of a wrapping OpenClosed component',
suppressConsoleLogs(async () => {
renderTemplate({
components: { OpenClosedWrite },
template: html`
<Popover>
<PopoverButton>Trigger</PopoverButton>
<OpenClosedWrite :open="false">
<PopoverPanel v-slot="data">
{{JSON.stringify(data)}}
</PopoverPanel>
</OpenClosedWrite>
</Popover>
`,
})
await new Promise<void>(nextTick)
// Verify the Popover is hidden
assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
// Let's try and open the Popover
await click(getPopoverButton())
// Verify the Popover is still hidden
assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
})
)
it(
'should be possible to read the OpenClosed state',
suppressConsoleLogs(async () => {
let readFn = jest.fn()
renderTemplate({
components: { OpenClosedRead },
template: html`
<Popover>
<PopoverButton>Trigger</PopoverButton>
<OpenClosedRead @read="readFn">
<PopoverPanel></PopoverPanel>
</OpenClosedRead>
</Popover>
`,
setup() {
return { readFn }
},
})
await new Promise<void>(nextTick)
// Verify the Popover is hidden
assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
// Let's toggle the Popover 3 times
await click(getPopoverButton())
await click(getPopoverButton())
await click(getPopoverButton())
// Verify the Popover is visible
assertPopoverPanel({ state: PopoverState.Visible })
expect(readFn).toHaveBeenCalledTimes(3)
expect(readFn).toHaveBeenNthCalledWith(1, State.Open)
expect(readFn).toHaveBeenNthCalledWith(2, State.Closed)
expect(readFn).toHaveBeenNthCalledWith(3, State.Open)
})
)
})
describe('Keyboard interactions', () => {
describe('`Enter` key', () => {
it(
@@ -9,6 +9,7 @@ import {
// Types
InjectionKey,
Ref,
computed,
} from 'vue'
import { match } from '../../utils/match'
@@ -25,6 +26,7 @@ import {
} from '../../utils/focus-management'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
enum PopoverStates {
Open,
@@ -113,6 +115,14 @@ export let Popover = defineComponent({
} as StateDefinition
provide(PopoverContext, api)
useOpenClosedProvider(
computed(() =>
match(popoverState.value, {
[PopoverStates.Open]: State.Open,
[PopoverStates.Closed]: State.Closed,
})
)
)
let registerBag = {
buttonId,
@@ -377,18 +387,28 @@ export let PopoverOverlay = defineComponent({
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: slot.open,
visible: this.visible,
name: 'PopoverOverlay',
})
},
setup() {
let api = usePopoverContext('PopoverOverlay')
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return api.popoverState.value === PopoverStates.Open
})
return {
id: `headlessui-popover-overlay-${useId()}`,
handleClick() {
api.closePopover()
},
visible,
}
},
})
@@ -419,7 +439,7 @@ export let PopoverPanel = defineComponent({
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: slot.open,
visible: this.visible,
name: 'PopoverPanel',
})
},
@@ -498,6 +518,15 @@ export let PopoverPanel = defineComponent({
true
)
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return api.popoverState.value === PopoverStates.Open
})
return {
id: api.panelId,
el: api.panel,
@@ -513,6 +542,7 @@ export let PopoverPanel = defineComponent({
break
}
},
visible,
}
},
})
@@ -21,6 +21,7 @@ import { match } from '../../utils/match'
import { Features, render, RenderStrategy } from '../../utils/render'
import { Reason, transition } from './utils/transition'
import { dom } from '../../utils/dom'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
type ID = ReturnType<typeof useId>
@@ -277,9 +278,16 @@ export let TransitionChild = defineComponent({
{ immediate: true }
)
})
// onUpdated(() => executeTransition(() => {}))
provide(NestingContext, nesting)
useOpenClosedProvider(
computed(() =>
match(state.value, {
[TreeStates.Visible]: State.Open,
[TreeStates.Hidden]: State.Closed,
})
)
)
return { el: container, state }
},
@@ -329,13 +337,26 @@ export let TransitionRoot = defineComponent({
})
},
setup(props) {
let usesOpenClosedState = useOpenClosed()
let show = computed(() => {
if (props.show === null && usesOpenClosedState !== null) {
return match(usesOpenClosedState.value, {
[State.Open]: true,
[State.Closed]: false,
})
}
return props.show
})
watchEffect(() => {
if (![true, false].includes(props.show)) {
if (![true, false].includes(show.value)) {
throw new Error('A <Transition /> is used but it is missing a `:show="true | false"` prop.')
}
})
let state = ref(props.show ? TreeStates.Visible : TreeStates.Hidden)
let state = ref(show.value ? TreeStates.Visible : TreeStates.Hidden)
let nestingBag = useNesting(() => {
state.value = TreeStates.Hidden
@@ -343,7 +364,7 @@ export let TransitionRoot = defineComponent({
let initial = { value: true }
let transitionBag = {
show: computed(() => props.show),
show,
appear: computed(() => props.appear || !initial.value),
}
@@ -351,7 +372,7 @@ export let TransitionRoot = defineComponent({
watchEffect(() => {
initial.value = false
if (props.show) {
if (show.value) {
state.value = TreeStates.Visible
} else if (!hasChildren(nestingBag)) {
state.value = TreeStates.Hidden
@@ -362,6 +383,6 @@ export let TransitionRoot = defineComponent({
provide(NestingContext, nestingBag)
provide(TransitionContext, transitionBag)
return { state }
return { state, show }
},
})
@@ -0,0 +1,23 @@
import {
inject,
provide,
// Types
InjectionKey,
Ref,
} from 'vue'
let Context = Symbol('Context') as InjectionKey<Ref<State>>
export enum State {
Open,
Closed,
}
export function useOpenClosed() {
return inject(Context, null)
}
export function useOpenClosedProvider(value: Ref<State>) {
provide(Context, value)
}