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:
+6
-2
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user