Files
headlessui/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
T
Robin Malfait ce23edeee4 Next release (#431)
* Fixed typos (#350)

* chore: Fix typo in render.ts (#347)

* Better vue link (#353)

* Better vue link

* add better React link

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>

* Enable NoScroll feature for the initial useFocusTrap hook (#356)

* enable NoScroll feature for the initial useFocusTrap hook

Once you are using Tab and Shift+Tab it does the scrolling.

Fixes: #345

* update changelog

* Revert "Enable NoScroll feature for the initial useFocusTrap hook (#356)"

This reverts commit 19590b07624d7e3d751cbf11de869dfb0ea432ba.

Solution is not 100% correct, so will revert for now!

* Improve search (#385)

* make search case insensitive for the listbox

* make search case insensitive for the menu

* update changelog

* add `disabled` prop to RadioGroup and RadioGroup Option (#401)

* add `disabled` prop to RadioGroup and RadioGroup Option

Also did some general cleanup which in turn fixed an issue where the
RadioGroup is unreachable when a value is used that doesn't exist in the
list of options.

Fixes: #378

* update changelog

* Fix type of `RadioGroupOption` (#400)

Match RadioGroupOption value types to match modelValue allowed types for RadioGroup

* update changelog

* fix typo's

* chore(CI): update main workflow (#395)

* chore(CI): update main workflow

* Update main.yml

* fix dialog event propagation (#422)

* re-export the `screen` utility for quick debugging purposes

* stop event propagation when clicking inside a Dialog

Fixes: #414

* improve dialog escape (#430)

* Make sure that `Escape` only closes the top most Dialog

* update changelog

* add defaultOpen prop to Disclosure component (#447)

* add defaultOpen prop to Disclosure component

* update changelog

Co-authored-by: Shuvro Roy <shuvro.roy@northsouth.edu>
Co-authored-by: Alex Nault <nault.alex@gmail.com>
Co-authored-by: Eugene Kopich <github@web2033.com>
Co-authored-by: Nathan Shoemark <n.shoemark@gmail.com>
Co-authored-by: Michaël De Boey <info@michaeldeboey.be>
2021-04-26 15:44:10 +02:00

649 lines
18 KiB
TypeScript

import React, { createElement, useState } from 'react'
import { render } from '@testing-library/react'
import { Dialog } from './dialog'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
import {
DialogState,
assertDialog,
assertDialogDescription,
assertDialogOverlay,
assertDialogTitle,
getDialog,
getDialogOverlay,
getByText,
assertActiveElement,
getDialogs,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys } from '../../test-utils/interactions'
import { PropsOf } from '../../types'
jest.mock('../../hooks/use-id')
// @ts-expect-error
global.IntersectionObserver = class FakeIntersectionObserver {
observe() {}
disconnect() {}
}
afterAll(() => jest.restoreAllMocks())
function TabSentinel(props: PropsOf<'div'>) {
return <div tabIndex={0} {...props} />
}
describe('Safe guards', () => {
it.each([
['Dialog.Overlay', Dialog.Overlay],
['Dialog.Title', Dialog.Title],
])(
'should error when we are using a <%s /> without a parent <Dialog />',
suppressConsoleLogs((name, Component) => {
expect(() => render(createElement(Component))).toThrowError(
`<${name} /> is missing a parent <Dialog /> component.`
)
expect.hasAssertions()
})
)
it(
'should be possible to render a Dialog without crashing',
suppressConsoleLogs(async () => {
render(
<Dialog open={false} onClose={console.log}>
<button>Trigger</button>
<Dialog.Overlay />
<Dialog.Title />
<p>Contents</p>
<Dialog.Description />
</Dialog>
)
assertDialog({
state: DialogState.InvisibleUnmounted,
attributes: { id: 'headlessui-dialog-1' },
})
})
)
})
describe('Rendering', () => {
describe('Dialog', () => {
it(
'should complain when the `open` and `onClose` prop are missing',
suppressConsoleLogs(async () => {
// @ts-expect-error
expect(() => render(<Dialog as="div" />)).toThrowErrorMatchingInlineSnapshot(
`"You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component."`
)
expect.hasAssertions()
})
)
it(
'should complain when an `open` prop is provided without an `onClose` prop',
suppressConsoleLogs(async () => {
// @ts-expect-error
expect(() => render(<Dialog as="div" open={false} />)).toThrowErrorMatchingInlineSnapshot(
`"You provided an \`open\` prop to the \`Dialog\`, but forgot an \`onClose\` prop."`
)
expect.hasAssertions()
})
)
it(
'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."`
)
expect.hasAssertions()
})
)
it(
'should complain when an `open` prop is not a boolean',
suppressConsoleLogs(async () => {
expect(() =>
// @ts-expect-error
render(<Dialog as="div" open={null} onClose={console.log} />)
).toThrowErrorMatchingInlineSnapshot(
`"You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: null"`
)
expect.hasAssertions()
})
)
it(
'should complain when an `onClose` prop is not a function',
suppressConsoleLogs(async () => {
expect(() =>
// @ts-expect-error
render(<Dialog as="div" open={false} onClose={null} />)
).toThrowErrorMatchingInlineSnapshot(
`"You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: null"`
)
expect.hasAssertions()
})
)
it(
'should be possible to render a Dialog using a render prop',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(true)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
{data => (
<>
<pre>{JSON.stringify(data)}</pre>
<TabSentinel />
</>
)}
</Dialog>
</>
)
}
render(<Example />)
assertDialog({ state: DialogState.InvisibleUnmounted })
await click(document.getElementById('trigger'))
assertDialog({ state: DialogState.Visible, textContent: JSON.stringify({ open: true }) })
})
)
it('should be possible to always render the Dialog if we provide it a `static` prop (and enable focus trapping based on `open`)', () => {
let focusCounter = jest.fn()
render(
<>
<button>Trigger</button>
<Dialog open={true} onClose={console.log} static>
<p>Contents</p>
<TabSentinel onFocus={focusCounter} />
</Dialog>
</>
)
// Let's verify that the Dialog is already there
expect(getDialog()).not.toBe(null)
expect(focusCounter).toHaveBeenCalledTimes(1)
})
it('should be possible to always render the Dialog if we provide it a `static` prop (and disable focus trapping based on `open`)', () => {
let focusCounter = jest.fn()
render(
<>
<button>Trigger</button>
<Dialog open={false} onClose={console.log} static>
<p>Contents</p>
<TabSentinel onFocus={focusCounter} />
</Dialog>
</>
)
// Let's verify that the Dialog is already there
expect(getDialog()).not.toBe(null)
expect(focusCounter).toHaveBeenCalledTimes(0)
})
it('should be possible to use a different render strategy for the Dialog', async () => {
let focusCounter = jest.fn()
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen} unmount={false}>
<input onFocus={focusCounter} />
</Dialog>
</>
)
}
render(<Example />)
assertDialog({ state: DialogState.InvisibleHidden })
expect(focusCounter).toHaveBeenCalledTimes(0)
// Let's open the Dialog, to see if it is not hidden anymore
await click(document.getElementById('trigger'))
expect(focusCounter).toHaveBeenCalledTimes(1)
assertDialog({ state: DialogState.Visible })
// Let's close the Dialog
await press(Keys.Escape)
expect(focusCounter).toHaveBeenCalledTimes(1)
assertDialog({ state: DialogState.InvisibleHidden })
})
it(
'should add a scroll lock to the html tag',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<input id="a" type="text" />
<input id="b" type="text" />
<input id="c" type="text" />
</Dialog>
</>
)
}
render(<Example />)
// No overflow yet
expect(document.documentElement.style.overflow).toBe('')
let btn = document.getElementById('trigger')
// Open the dialog
await click(btn)
// Expect overflow
expect(document.documentElement.style.overflow).toBe('hidden')
})
)
})
describe('Dialog.Overlay', () => {
it(
'should be possible to render Dialog.Overlay using a render prop',
suppressConsoleLogs(async () => {
let overlay = jest.fn().mockReturnValue(null)
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Overlay>{overlay}</Dialog.Overlay>
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)
assertDialogOverlay({
state: DialogState.InvisibleUnmounted,
attributes: { id: 'headlessui-dialog-overlay-2' },
})
await click(document.getElementById('trigger'))
assertDialogOverlay({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-overlay-2' },
})
expect(overlay).toHaveBeenCalledWith({ open: true })
})
)
})
describe('Dialog.Title', () => {
it(
'should be possible to render Dialog.Title using a render prop',
suppressConsoleLogs(async () => {
render(
<Dialog open={true} onClose={console.log}>
<Dialog.Title>{JSON.stringify}</Dialog.Title>
<TabSentinel />
</Dialog>
)
assertDialog({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-1' },
})
assertDialogTitle({
state: DialogState.Visible,
textContent: JSON.stringify({ open: true }),
})
})
)
})
describe('Dialog.Description', () => {
it(
'should be possible to render Dialog.Description using a render prop',
suppressConsoleLogs(async () => {
render(
<Dialog open={true} onClose={console.log}>
<Dialog.Description>{JSON.stringify}</Dialog.Description>
<TabSentinel />
</Dialog>
)
assertDialog({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-1' },
})
assertDialogDescription({
state: DialogState.Visible,
textContent: JSON.stringify({ open: true }),
})
})
)
})
})
describe('Keyboard interactions', () => {
describe('`Escape` key', () => {
it(
'should be possible to close the dialog with Escape',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)
assertDialog({ state: DialogState.InvisibleUnmounted })
// Open dialog
await click(document.getElementById('trigger'))
// Verify it is open
assertDialog({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-1' },
})
// Close dialog
await press(Keys.Escape)
// Verify it is close
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)
})
})
describe('Mouse interactions', () => {
it(
'should be possible to close a Dialog using a click on the Dialog.Overlay',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Overlay />
Contents
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)
// Open dialog
await click(document.getElementById('trigger'))
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Click to close
await click(getDialogOverlay())
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)
it(
'should be possible to close the dialog, and re-focus the button when we click outside on the body element',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(v => !v)}>Trigger</button>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)
// Open dialog
await click(getByText('Trigger'))
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Click the body to close
await click(document.body)
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify the button is focused
assertActiveElement(getByText('Trigger'))
})
)
it(
'should be possible to close the dialog, and keep focus on the focusable element',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button>Hello</button>
<button onClick={() => setIsOpen(v => !v)}>Trigger</button>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)
// Open dialog
await click(getByText('Trigger'))
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Click the button to close (outside click)
await click(getByText('Hello'))
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify the button is focused
assertActiveElement(getByText('Hello'))
})
)
it(
'should stop propagating click events when clicking on the Dialog.Overlay',
suppressConsoleLogs(async () => {
let wrapperFn = jest.fn()
function Example() {
let [isOpen, setIsOpen] = useState(true)
return (
<div onClick={wrapperFn}>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<Dialog.Overlay />
<TabSentinel />
</Dialog>
</div>
)
}
render(<Example />)
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
// Click the Dialog.Overlay to close the Dialog
await click(getDialogOverlay())
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
})
)
it(
'should stop propagating click events when clicking on an element inside the Dialog',
suppressConsoleLogs(async () => {
let wrapperFn = jest.fn()
function Example() {
let [isOpen, setIsOpen] = useState(true)
return (
<div onClick={wrapperFn}>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<button onClick={() => setIsOpen(false)}>Inside</button>
<TabSentinel />
</Dialog>
</div>
)
}
render(<Example />)
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
// Click the button inside the the Dialog
await click(getByText('Inside'))
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
})
)
})
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)
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>
</>
)
}
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)
})
})