Merge pull request #501 from tailwindlabs/develop

Next release
This commit is contained in:
Robin Malfait
2021-05-10 12:00:38 +02:00
committed by GitHub
47 changed files with 2180 additions and 652 deletions
+45 -15
View File
@@ -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
View File
@@ -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 {