+45
-15
@@ -1,43 +1,73 @@
|
||||
name: CI
|
||||
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
NODE_VERSION: 12.x
|
||||
|
||||
jobs:
|
||||
build:
|
||||
install:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Begin CI...
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node 12
|
||||
- name: Use Node ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 12
|
||||
|
||||
# - name: Use cached node_modules
|
||||
# id: cache
|
||||
# uses: actions/cache@v2
|
||||
# with:
|
||||
# path: node_modules
|
||||
# key: nodeModules-${{ hashFiles('**/yarn.lock') }}
|
||||
# restore-keys: |
|
||||
# nodeModules-
|
||||
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Install dependencies
|
||||
# if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
env:
|
||||
CI: true
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [install]
|
||||
|
||||
steps:
|
||||
- name: Begin CI...
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
env:
|
||||
CI: true
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [install]
|
||||
|
||||
steps:
|
||||
- name: Begin CI...
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Test
|
||||
run: yarn test
|
||||
env:
|
||||
CI: true
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [install]
|
||||
|
||||
steps:
|
||||
- name: Begin CI...
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
|
||||
+12
-2
@@ -7,11 +7,21 @@ 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))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Improve SSR for `Dialog` ([#477](https://github.com/tailwindlabs/headlessui/pull/477))
|
||||
- Delay focus trap initialization ([#477](https://github.com/tailwindlabs/headlessui/pull/477))
|
||||
- Improve incorrect behaviour for nesting `Dialog` components ([#560](https://github.com/tailwindlabs/headlessui/pull/560))
|
||||
|
||||
## [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">
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
getDialogOverlays,
|
||||
} 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 +97,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 +353,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(
|
||||
@@ -599,79 +638,205 @@ describe('Mouse interactions', () => {
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
|
||||
function Nested({ onClose, level = 1 }: { onClose: (value: boolean) => void; level?: number }) {
|
||||
let [showChild, setShowChild] = useState(false)
|
||||
function Nested({ onClose, level = 1 }: { onClose: (value: boolean) => void; level?: number }) {
|
||||
let [showChild, setShowChild] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
<div>
|
||||
<p>Level: {level}</p>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1}</button>
|
||||
</div>
|
||||
{showChild && <Nested onClose={setShowChild} level={level + 1} />}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
<Dialog.Overlay />
|
||||
|
||||
<div>
|
||||
<p>Level: {level}</p>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} a</button>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} b</button>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} c</button>
|
||||
</div>
|
||||
{showChild && <Nested onClose={setShowChild} level={level + 1} />}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Example() {
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open 1</button>
|
||||
{open && <Nested onClose={setOpen} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
it.each`
|
||||
strategy | action
|
||||
${'with `Escape`'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
`(
|
||||
'should be possible to open nested Dialog components and close them $strategy',
|
||||
async ({ action }) => {
|
||||
render(<Example />)
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 a` has focus
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Open Dialog 2 via the second button
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Open Dialog 2 via button b
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3 via button c
|
||||
await click(getByText('Open 3 c'))
|
||||
|
||||
// Verify that the `Open 4 a` has focus
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that the `Open 3 c` button got focused again
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 0 open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Verify that the `Open 1` button got focused again
|
||||
assertActiveElement(getByText('Open 1'))
|
||||
}
|
||||
|
||||
function Example() {
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open 1</button>
|
||||
{open && <Nested onClose={setOpen} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Example />)
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3
|
||||
await click(getByText('Open 3'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import React, {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MutableRefObject,
|
||||
Ref,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { Props } from '../../types'
|
||||
@@ -24,14 +25,15 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
|
||||
import { Keys } from '../keyboard'
|
||||
import { isDisabledReactIssue7711 } from '../../utils/bugs'
|
||||
import { useId } from '../../hooks/use-id'
|
||||
import { useFocusTrap } from '../../hooks/use-focus-trap'
|
||||
import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap'
|
||||
import { useInertOthers } from '../../hooks/use-inert-others'
|
||||
import { Portal } from '../../components/portal/portal'
|
||||
import { StackProvider, StackMessage } from '../../internal/stack-context'
|
||||
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'
|
||||
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
|
||||
import { StackProvider, StackMessage } from '../../internal/stack-context'
|
||||
|
||||
enum DialogStates {
|
||||
Open,
|
||||
@@ -108,20 +110,30 @@ 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>
|
||||
},
|
||||
ref: Ref<HTMLDivElement>
|
||||
) {
|
||||
let { open, onClose, initialFocus, ...rest } = props
|
||||
let [nestedDialogCount, setNestedDialogCount] = useState(0)
|
||||
|
||||
let containers = useRef<Set<HTMLElement>>(new Set())
|
||||
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<MutableRefObject<HTMLElement | null>>>(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 +164,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,
|
||||
@@ -167,13 +185,34 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
let ready = useServerHandoffComplete()
|
||||
let enabled = ready && dialogState === DialogStates.Open
|
||||
let hasNestedDialogs = nestedDialogCount > 1 // 1 is the current dialog
|
||||
let hasParentDialog = useContext(DialogContext) !== null
|
||||
|
||||
// If there are multiple dialogs, then you can be the root, the leaf or one
|
||||
// in between. We only care abou whether you are the top most one or not.
|
||||
let position = !hasNestedDialogs ? 'leaf' : 'parent'
|
||||
|
||||
useFocusTrap(
|
||||
internalDialogRef,
|
||||
enabled
|
||||
? match(position, {
|
||||
parent: FocusTrapFeatures.RestoreFocus,
|
||||
leaf: FocusTrapFeatures.All,
|
||||
})
|
||||
: FocusTrapFeatures.None,
|
||||
{ initialFocus, containers }
|
||||
)
|
||||
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)
|
||||
|
||||
// Handle outside click
|
||||
useWindowEvent('mousedown', event => {
|
||||
let target = event.target as HTMLElement
|
||||
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (containers.current.size !== 1) return
|
||||
if (contains(containers.current, target)) return
|
||||
if (hasNestedDialogs) return
|
||||
if (internalDialogRef.current?.contains(target)) return
|
||||
|
||||
close()
|
||||
})
|
||||
@@ -181,6 +220,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (hasParentDialog) return
|
||||
|
||||
let overflow = document.documentElement.style.overflow
|
||||
let paddingRight = document.documentElement.style.paddingRight
|
||||
@@ -194,7 +234,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
document.documentElement.style.overflow = overflow
|
||||
document.documentElement.style.paddingRight = paddingRight
|
||||
}
|
||||
}, [dialogState])
|
||||
}, [dialogState, hasParentDialog])
|
||||
|
||||
// Trigger close when the FocusTrap gets hidden
|
||||
useEffect(() => {
|
||||
@@ -219,10 +259,6 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
return () => observer.disconnect()
|
||||
}, [dialogState, internalDialogRef, close])
|
||||
|
||||
let enabled = dialogState === DialogStates.Open
|
||||
|
||||
useFocusTrap(containers, enabled, { initialFocus })
|
||||
useInertOthers(internalDialogRef, enabled)
|
||||
let [describedby, DescriptionProvider] = useDescriptions()
|
||||
|
||||
let id = `headlessui-dialog-${useId()}`
|
||||
@@ -251,7 +287,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
onKeyDown(event: ReactKeyboardEvent) {
|
||||
if (event.key !== Keys.Escape) return
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (containers.current.size > 1) return // 1 is myself, otherwise other elements in the Stack
|
||||
if (hasNestedDialogs) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
@@ -261,16 +297,22 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
|
||||
return (
|
||||
<StackProvider
|
||||
onUpdate={(message, element) => {
|
||||
return match(message, {
|
||||
[StackMessage.AddElement]() {
|
||||
type="Dialog"
|
||||
element={internalDialogRef}
|
||||
onUpdate={useCallback((message, type, element) => {
|
||||
if (type !== 'Dialog') return
|
||||
|
||||
match(message, {
|
||||
[StackMessage.Add]() {
|
||||
containers.current.add(element)
|
||||
setNestedDialogCount(count => count + 1)
|
||||
},
|
||||
[StackMessage.RemoveElement]() {
|
||||
containers.current.delete(element)
|
||||
[StackMessage.Remove]() {
|
||||
containers.current.add(element)
|
||||
setNestedDialogCount(count => count - 1)
|
||||
},
|
||||
})
|
||||
}}
|
||||
}, [])}
|
||||
>
|
||||
<ForcePortalRoot force={true}>
|
||||
<Portal>
|
||||
@@ -283,7 +325,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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,23 +8,22 @@ import {
|
||||
|
||||
import { Props } from '../../types'
|
||||
import { render } from '../../utils/render'
|
||||
import { useFocusTrap } from '../../hooks/use-focus-trap'
|
||||
import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap'
|
||||
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
|
||||
|
||||
let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
|
||||
|
||||
export function FocusTrap<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
|
||||
props: Props<TTag> & { initialFocus?: MutableRefObject<HTMLElement | null> }
|
||||
) {
|
||||
let containers = useRef<Set<HTMLElement>>(new Set())
|
||||
let container = useRef<HTMLElement | null>(null)
|
||||
let { initialFocus, ...passthroughProps } = props
|
||||
|
||||
useFocusTrap(containers, true, { initialFocus })
|
||||
let ready = useServerHandoffComplete()
|
||||
useFocusTrap(container, ready ? FocusTrapFeatures.All : FocusTrapFeatures.None, { initialFocus })
|
||||
|
||||
let propsWeControl = {
|
||||
ref(element: HTMLElement | null) {
|
||||
if (!element) return
|
||||
containers.current.add(element)
|
||||
},
|
||||
ref: container,
|
||||
}
|
||||
|
||||
return render({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,8 +14,8 @@ import { createPortal } from 'react-dom'
|
||||
import { Props } from '../../types'
|
||||
import { render } from '../../utils/render'
|
||||
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
|
||||
import { useElementStack, StackProvider } from '../../internal/stack-context'
|
||||
import { usePortalRoot } from '../../internal/portal-force-root'
|
||||
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
|
||||
|
||||
function usePortalTarget(): HTMLElement | null {
|
||||
let forceInRoot = usePortalRoot()
|
||||
@@ -57,7 +57,7 @@ export function Portal<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
|
||||
typeof window === 'undefined' ? null : document.createElement('div')
|
||||
)
|
||||
|
||||
useElementStack(element)
|
||||
let ready = useServerHandoffComplete()
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
if (!target) return
|
||||
@@ -77,20 +77,14 @@ export function Portal<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
|
||||
}
|
||||
}, [target, element])
|
||||
|
||||
return (
|
||||
<StackProvider>
|
||||
{!target || !element
|
||||
? null
|
||||
: createPortal(
|
||||
render({
|
||||
props: passthroughProps,
|
||||
defaultTag: DEFAULT_PORTAL_TAG,
|
||||
name: 'Portal',
|
||||
}),
|
||||
element
|
||||
)}
|
||||
</StackProvider>
|
||||
)
|
||||
if (!ready) return null
|
||||
|
||||
return !target || !element
|
||||
? null
|
||||
: createPortal(
|
||||
render({ props: passthroughProps, defaultTag: DEFAULT_PORTAL_TAG, name: 'Portal' }),
|
||||
element
|
||||
)
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
@@ -22,6 +22,8 @@ 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'
|
||||
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
|
||||
|
||||
type ID = ReturnType<typeof useId>
|
||||
|
||||
@@ -259,11 +261,13 @@ function TransitionChild<TTag extends ElementType = typeof DEFAULT_TRANSITION_CH
|
||||
|
||||
let events = useEvents({ beforeEnter, afterEnter, beforeLeave, afterLeave })
|
||||
|
||||
let ready = useServerHandoffComplete()
|
||||
|
||||
useEffect(() => {
|
||||
if (state === TreeStates.Visible && container.current === null) {
|
||||
if (ready && state === TreeStates.Visible && container.current === null) {
|
||||
throw new Error('Did you forget to passthrough the `ref` to the actual DOM node?')
|
||||
}
|
||||
}, [container, state])
|
||||
}, [container, state, ready])
|
||||
|
||||
// Skipping initial transition
|
||||
let skip = initial && !appear
|
||||
@@ -318,24 +322,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 +367,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]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,98 +2,129 @@ import {
|
||||
useRef,
|
||||
// Types
|
||||
MutableRefObject,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
|
||||
import { Keys } from '../components/keyboard'
|
||||
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
|
||||
import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management'
|
||||
import { contains } from '../internal/dom-containers'
|
||||
import { useWindowEvent } from './use-window-event'
|
||||
import { useIsMounted } from './use-is-mounted'
|
||||
|
||||
export enum Features {
|
||||
/** No features enabled for the `useFocusTrap` hook. */
|
||||
None = 1 << 0,
|
||||
|
||||
/** Ensure that we move focus initially into the container. */
|
||||
InitialFocus = 1 << 1,
|
||||
|
||||
/** Ensure that pressing `Tab` and `Shift+Tab` is trapped within the container. */
|
||||
TabLock = 1 << 2,
|
||||
|
||||
/** Ensure that programmatically moving focus outside of the container is disallowed. */
|
||||
FocusLock = 1 << 3,
|
||||
|
||||
/** Ensure that we restore the focus when unmounting the component that uses this `useFocusTrap` hook. */
|
||||
RestoreFocus = 1 << 4,
|
||||
|
||||
/** Enable all features. */
|
||||
All = InitialFocus | TabLock | FocusLock | RestoreFocus,
|
||||
}
|
||||
|
||||
export function useFocusTrap(
|
||||
containers: MutableRefObject<Set<HTMLElement>>,
|
||||
enabled: boolean = true,
|
||||
options: { initialFocus?: MutableRefObject<HTMLElement | null> } = {}
|
||||
container: MutableRefObject<HTMLElement | null>,
|
||||
features: Features = Features.All,
|
||||
{
|
||||
initialFocus,
|
||||
containers,
|
||||
}: {
|
||||
initialFocus?: MutableRefObject<HTMLElement | null>
|
||||
containers?: MutableRefObject<Set<MutableRefObject<HTMLElement | null>>>
|
||||
} = {}
|
||||
) {
|
||||
let restoreElement = useRef<HTMLElement | null>(
|
||||
typeof window !== 'undefined' ? (document.activeElement as HTMLElement) : null
|
||||
)
|
||||
let previousActiveElement = useRef<HTMLElement | null>(null)
|
||||
let mounted = useRef(false)
|
||||
let mounted = useIsMounted()
|
||||
|
||||
let featuresRestoreFocus = Boolean(features & Features.RestoreFocus)
|
||||
let featuresInitialFocus = Boolean(features & Features.InitialFocus)
|
||||
|
||||
// Capture the currently focused element, before we enable the focus trap.
|
||||
useEffect(() => {
|
||||
if (!featuresRestoreFocus) return
|
||||
|
||||
restoreElement.current = document.activeElement as HTMLElement
|
||||
}, [featuresRestoreFocus])
|
||||
|
||||
// Restore the focus when we unmount the component.
|
||||
useEffect(() => {
|
||||
if (!featuresRestoreFocus) return
|
||||
|
||||
return () => {
|
||||
focusElement(restoreElement.current)
|
||||
restoreElement.current = null
|
||||
}
|
||||
}, [featuresRestoreFocus])
|
||||
|
||||
// Handle initial focus
|
||||
useIsoMorphicEffect(() => {
|
||||
if (!enabled) return
|
||||
if (containers.current.size !== 1) return
|
||||
|
||||
mounted.current = true
|
||||
useEffect(() => {
|
||||
if (!featuresInitialFocus) return
|
||||
if (!container.current) return
|
||||
|
||||
let activeElement = document.activeElement as HTMLElement
|
||||
|
||||
if (options.initialFocus?.current) {
|
||||
if (options.initialFocus?.current === activeElement) {
|
||||
if (initialFocus?.current) {
|
||||
if (initialFocus?.current === activeElement) {
|
||||
previousActiveElement.current = activeElement
|
||||
return // Initial focus ref is already the active element
|
||||
}
|
||||
} else if (contains(containers.current, activeElement)) {
|
||||
} else if (container.current.contains(activeElement)) {
|
||||
previousActiveElement.current = activeElement
|
||||
return // Already focused within Dialog
|
||||
}
|
||||
|
||||
restoreElement.current = activeElement
|
||||
|
||||
// Try to focus the initialFocus ref
|
||||
if (options.initialFocus?.current) {
|
||||
focusElement(options.initialFocus.current)
|
||||
if (initialFocus?.current) {
|
||||
focusElement(initialFocus.current)
|
||||
} else {
|
||||
let couldFocus = false
|
||||
for (let container of containers.current) {
|
||||
let result = focusIn(container, Focus.First)
|
||||
if (result === FocusResult.Success) {
|
||||
couldFocus = true
|
||||
break
|
||||
}
|
||||
if (focusIn(container.current, Focus.First) === FocusResult.Error) {
|
||||
throw new Error('There are no focusable elements inside the <FocusTrap />')
|
||||
}
|
||||
|
||||
if (!couldFocus) throw new Error('There are no focusable elements inside the <FocusTrap />')
|
||||
}
|
||||
|
||||
previousActiveElement.current = document.activeElement as HTMLElement
|
||||
}, [container, initialFocus, featuresInitialFocus])
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
focusElement(restoreElement.current)
|
||||
restoreElement.current = null
|
||||
previousActiveElement.current = null
|
||||
}
|
||||
}, [enabled, containers, mounted, options.initialFocus])
|
||||
|
||||
// Handle Tab & Shift+Tab keyboard events
|
||||
// Handle `Tab` & `Shift+Tab` keyboard events
|
||||
useWindowEvent('keydown', event => {
|
||||
if (!enabled) return
|
||||
if (!(features & Features.TabLock)) return
|
||||
|
||||
if (!container.current) return
|
||||
if (event.key !== Keys.Tab) return
|
||||
if (!document.activeElement) return
|
||||
if (containers.current.size !== 1) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
for (let element of containers.current) {
|
||||
let result = focusIn(
|
||||
element,
|
||||
if (
|
||||
focusIn(
|
||||
container.current,
|
||||
(event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround
|
||||
)
|
||||
|
||||
if (result === FocusResult.Success) {
|
||||
previousActiveElement.current = document.activeElement as HTMLElement
|
||||
break
|
||||
}
|
||||
) === FocusResult.Success
|
||||
) {
|
||||
previousActiveElement.current = document.activeElement as HTMLElement
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent programmatically escaping
|
||||
// Prevent programmatically escaping the container
|
||||
useWindowEvent(
|
||||
'focus',
|
||||
event => {
|
||||
if (!enabled) return
|
||||
if (containers.current.size !== 1) return
|
||||
if (!(features & Features.FocusLock)) return
|
||||
|
||||
let allContainers = new Set(containers?.current)
|
||||
allContainers.add(container)
|
||||
|
||||
if (!allContainers.size) return
|
||||
|
||||
let previous = previousActiveElement.current
|
||||
if (!previous) return
|
||||
@@ -102,7 +133,7 @@ export function useFocusTrap(
|
||||
let toElement = event.target as HTMLElement | null
|
||||
|
||||
if (toElement && toElement instanceof HTMLElement) {
|
||||
if (!contains(containers.current, toElement)) {
|
||||
if (!contains(allContainers, toElement)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
focusElement(previous)
|
||||
@@ -117,3 +148,11 @@ export function useFocusTrap(
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
function contains(containers: Set<MutableRefObject<HTMLElement | null>>, element: HTMLElement) {
|
||||
for (let container of containers) {
|
||||
if (container.current?.contains(element)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
|
||||
import { useServerHandoffComplete } from './use-server-handoff-complete'
|
||||
|
||||
// We used a "simple" approach first which worked for SSR and rehydration on the client. However we
|
||||
// didn't take care of the Suspense case. To fix this we used the approach the @reach-ui/auto-id
|
||||
@@ -7,22 +8,18 @@ import { useIsoMorphicEffect } from './use-iso-morphic-effect'
|
||||
//
|
||||
// Credits: https://github.com/reach/reach-ui/blob/develop/packages/auto-id/src/index.tsx
|
||||
|
||||
let state = { serverHandoffComplete: false }
|
||||
let id = 0
|
||||
function generateId() {
|
||||
return ++id
|
||||
}
|
||||
|
||||
export function useId() {
|
||||
let [id, setId] = useState(state.serverHandoffComplete ? generateId : null)
|
||||
let ready = useServerHandoffComplete()
|
||||
let [id, setId] = useState(ready ? generateId : null)
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
if (id === null) setId(generateId())
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.serverHandoffComplete === false) state.serverHandoffComplete = true
|
||||
}, [])
|
||||
|
||||
return id != null ? '' + id : undefined
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
|
||||
export function useIsMounted() {
|
||||
let mounted = useRef(true)
|
||||
let mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
let state = { serverHandoffComplete: false }
|
||||
|
||||
export function useServerHandoffComplete() {
|
||||
let [serverHandoffComplete, setServerHandoffComplete] = useState(state.serverHandoffComplete)
|
||||
|
||||
useEffect(() => {
|
||||
if (serverHandoffComplete === true) return
|
||||
|
||||
setServerHandoffComplete(true)
|
||||
}, [serverHandoffComplete])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.serverHandoffComplete === false) state.serverHandoffComplete = true
|
||||
}, [])
|
||||
|
||||
return serverHandoffComplete
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function useWindowEvent<TType extends keyof WindowEventMap>(
|
||||
type: TType,
|
||||
listener: (this: Window, ev: WindowEventMap[TType]) => any,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) {
|
||||
let listenerRef = useRef(listener)
|
||||
listenerRef.current = listener
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(type, listener, options)
|
||||
return () => window.removeEventListener(type, listener, options)
|
||||
}, [type, listener, options])
|
||||
function handler(event: WindowEventMap[TType]) {
|
||||
listenerRef.current.call(window, event)
|
||||
}
|
||||
|
||||
window.addEventListener(type, handler, options)
|
||||
return () => window.removeEventListener(type, handler, options)
|
||||
}, [type, options])
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function contains(containers: Set<HTMLElement>, element: HTMLElement) {
|
||||
for (let container of containers) {
|
||||
if (container.contains(element)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -1,37 +1,42 @@
|
||||
import React, { ReactNode, createContext, useContext, useCallback } from 'react'
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
|
||||
// Types
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { useIsoMorphicEffect } from '../hooks/use-iso-morphic-effect'
|
||||
|
||||
type OnUpdate = (message: StackMessage, element: HTMLElement) => void
|
||||
type OnUpdate = (
|
||||
message: StackMessage,
|
||||
type: string,
|
||||
element: MutableRefObject<HTMLElement | null>
|
||||
) => void
|
||||
|
||||
let StackContext = createContext<OnUpdate>(() => {})
|
||||
StackContext.displayName = 'StackContext'
|
||||
|
||||
export enum StackMessage {
|
||||
AddElement,
|
||||
RemoveElement,
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
export function useStackContext() {
|
||||
return useContext(StackContext)
|
||||
}
|
||||
|
||||
export function useElementStack(element: HTMLElement | null) {
|
||||
let notify = useStackContext()
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
if (!element) return
|
||||
|
||||
notify(StackMessage.AddElement, element)
|
||||
return () => notify(StackMessage.RemoveElement, element)
|
||||
}, [element])
|
||||
}
|
||||
|
||||
export function StackProvider({
|
||||
children,
|
||||
onUpdate,
|
||||
type,
|
||||
element,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onUpdate?: OnUpdate
|
||||
type: string
|
||||
element: MutableRefObject<HTMLElement | null>
|
||||
}) {
|
||||
let parentUpdate = useStackContext()
|
||||
|
||||
@@ -46,5 +51,10 @@ export function StackProvider({
|
||||
[parentUpdate, onUpdate]
|
||||
)
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
notify(StackMessage.Add, type, element)
|
||||
return () => notify(StackMessage.Remove, type, element)
|
||||
}, [notify, type, element])
|
||||
|
||||
return <StackContext.Provider value={notify}>{children}</StackContext.Provider>
|
||||
}
|
||||
|
||||
@@ -880,6 +880,10 @@ export function getDialogOverlay(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-overlay-"]')
|
||||
}
|
||||
|
||||
export function getDialogOverlays(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[id^="headlessui-dialog-overlay-"]'))
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
export enum DialogState {
|
||||
|
||||
@@ -44,9 +44,16 @@ export enum Focus {
|
||||
}
|
||||
|
||||
export enum FocusResult {
|
||||
/** Something went wrong while trying to focus. */
|
||||
Error,
|
||||
|
||||
/** When `Focus.WrapAround` is enabled, going from position `N` to `N+1` where `N` is the last index in the array, then we overflow. */
|
||||
Overflow,
|
||||
|
||||
/** Focus was successful. */
|
||||
Success,
|
||||
|
||||
/** When `Focus.WrapAround` is enabled, going from position `N` to `N-1` where `N` is the first index in the array, then we underflow. */
|
||||
Underflow,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
getDialogOverlays,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { click, press, Keys } from '../../test-utils/interactions'
|
||||
import { html } from '../../test-utils/html'
|
||||
@@ -429,6 +431,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(
|
||||
@@ -734,102 +782,243 @@ describe('Mouse interactions', () => {
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
|
||||
let Nested = defineComponent({
|
||||
components: { Dialog },
|
||||
emits: ['close'],
|
||||
props: ['level'],
|
||||
render() {
|
||||
let level = this.$props.level ?? 1
|
||||
return h(Dialog, { open: true, onClose: this.onClose }, () => [
|
||||
h('div', [
|
||||
h('p', `Level: ${level}`),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
let Nested = defineComponent({
|
||||
components: { Dialog, DialogOverlay },
|
||||
emits: ['close'],
|
||||
props: ['level'],
|
||||
render() {
|
||||
let level = this.$props.level ?? 1
|
||||
return h(Dialog, { open: true, onClose: this.onClose }, () => [
|
||||
h(DialogOverlay),
|
||||
h('div', [
|
||||
h('p', `Level: ${level}`),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
`Open ${level + 1}`
|
||||
),
|
||||
]),
|
||||
this.showChild &&
|
||||
h(Nested, {
|
||||
onClose: () => {
|
||||
this.showChild = false
|
||||
},
|
||||
`Open ${level + 1} a`
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
level: level + 1,
|
||||
}),
|
||||
])
|
||||
},
|
||||
setup(_props, { emit }) {
|
||||
let showChild = ref(false)
|
||||
},
|
||||
`Open ${level + 1} b`
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
},
|
||||
`Open ${level + 1} c`
|
||||
),
|
||||
]),
|
||||
this.showChild &&
|
||||
h(Nested, {
|
||||
onClose: () => {
|
||||
this.showChild = false
|
||||
},
|
||||
level: level + 1,
|
||||
}),
|
||||
])
|
||||
},
|
||||
setup(_props, { emit }) {
|
||||
let showChild = ref(false)
|
||||
|
||||
return {
|
||||
showChild,
|
||||
onClose() {
|
||||
emit('close', false)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
renderTemplate({
|
||||
components: { Nested },
|
||||
template: `
|
||||
<button @click="isOpen = true">Open 1</button>
|
||||
<Nested v-if="isOpen" @close="isOpen = false" />
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return { isOpen }
|
||||
},
|
||||
})
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3
|
||||
await click(getByText('Open 3'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
return {
|
||||
showChild,
|
||||
onClose() {
|
||||
emit('close', false)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
it.each`
|
||||
strategy | action
|
||||
${'with `Escape`'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
`(
|
||||
'should be possible to open nested Dialog components and close them $strategy',
|
||||
async ({ action }) => {
|
||||
renderTemplate({
|
||||
components: { Nested },
|
||||
template: `
|
||||
<button @click="isOpen = true">Open 1</button>
|
||||
<Nested v-if="isOpen" @close="isOpen = false" />
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return { isOpen }
|
||||
},
|
||||
})
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 a` has focus
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Open Dialog 2 via the second button
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Open Dialog 2 via button b
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3 via button c
|
||||
await click(getByText('Open 3 c'))
|
||||
|
||||
// Verify that the `Open 4 a` has focus
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that the `Open 3 c` button got focused again
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 0 open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Verify that the `Open 1` button got focused again
|
||||
assertActiveElement(getByText('Open 1'))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -880,6 +880,10 @@ export function getDialogOverlay(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-overlay-"]')
|
||||
}
|
||||
|
||||
export function getDialogOverlays(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[id^="headlessui-dialog-overlay-"]'))
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
export enum DialogState {
|
||||
|
||||
Reference in New Issue
Block a user