648a2843e6
* add Disclosure component * expose the Disclosure component * add Disclosure example component page * temporary fix selector because of JSDOM bug * add useFocusTrap hook * add FocusTrap component * expose FocusTrap * add Dialog component * add Dialog example component page * expose Dialog * random cleanup * make TypeScript a bit more happy * add Switch.Description component for React * add Switch.Description component for Vue * ensure focus event is triggered on click when element is focusable * remove Dialog.Button and Dialog.Panel from accessibility assertions * add Portal component * expose Portal * always render Dialog in a Portal * add useInertOthers hook This will allow us to mark everything but the current ref as "inert". This is important for screenreaders, to ensure that screenreaders and assistive technology can't interact with other content but the current ref. This implementation is not ideal yet. It doesn't take into account that you can use the hook in 2 different components. For now this is fine, since we only use it in a Dialog and you should also probably only have a single Dialog open at a time. Will improve this in the future! * use the useInertOthers hook * add scroll lock to the dialog * ensure we respect autoFocus on form elements within the Dialog If we have an autoFocus on an input, that input will receive focus. Once we try to focus the first focusable element in the Dialog this could be lead to unwanted behaviour. Therefore we check if the focus already is within the Dialog, if it is, keep it like that. * only mark aria-modal when Dialog is open * add initialFocus option to Dialog, FocusTrap & useFocusTrap * add tests and a few fixes for the initialFocusRef functionality * forward ref to underlying Dialog component * close Dialog when it becomes hidden Could happen when this is in md:hidden for example * prevent infinite loop When we `Tab` in a FocusTrap it will try and focus the Next element. If we are in a state where none of the elements inside the FocusTrap can be focused, then we keep trying to focus the next one in line. This results in an infinite loop... To mitigate this issue, we check if we looped around, if we did, it means that we tried all the other focusable elements, therefore we can stop. * isIntersecting doesn't work in every scenario When page is scrollable, when dialog is translated of the page. Now just checking for sizes, which should be enough for md:hiden cases * render Portal contents in a div Otherwise you can't use multiple Portal components if you render multiple children inside each Portal * ensure the props bag is typed * add getByText and assertContainsActiveElement helpers * add Popover component * expose Popover * add Popover example component page * add quick checks to prevent useless renders * drop incorrect close function * update Changelog * make test error more readable when comparing DOM nodes * actually call .focus() on the element This ensures that the document.activeElement becomes the focused element. * improve useSyncRefs, because ...refs is *always* different * add dedicated focus management utilities * refactor useFocusTrap, use focus management utilities * fix regression while using outside click There might be a chance that you didn't even notice this *bug*. The idea is that when you click outside, that the Menu or Listbox closes. However there is another step that happens: 1. When you click on a focusable item, keep the focus on that item. 2. When you click on a non-focusable item, move focus back to the Menu.Button or Listbox.Button We broke part 2, we never returned to the Menu.Button or Listbox.Button. This is (might) be important for screenreaders so that they don't "get lost", because if you click on a non-focusable item, the document.body becomes the active element. Confusing. * add outside-click to Dialog itself * update docs
1176 lines
32 KiB
TypeScript
1176 lines
32 KiB
TypeScript
function assertNever(x: never): never {
|
|
throw new Error('Unexpected object: ' + x)
|
|
}
|
|
|
|
// ---
|
|
|
|
export function getMenuButton(): HTMLElement | null {
|
|
return document.querySelector('button,[role="button"]')
|
|
}
|
|
|
|
export function getMenuButtons(): HTMLElement[] {
|
|
return Array.from(document.querySelectorAll('button,[role="button"]'))
|
|
}
|
|
|
|
export function getMenu(): HTMLElement | null {
|
|
return document.querySelector('[role="menu"]')
|
|
}
|
|
|
|
export function getMenus(): HTMLElement[] {
|
|
return Array.from(document.querySelectorAll('[role="menu"]'))
|
|
}
|
|
|
|
export function getMenuItems(): HTMLElement[] {
|
|
return Array.from(document.querySelectorAll('[role="menuitem"]'))
|
|
}
|
|
|
|
// ---
|
|
|
|
export enum MenuState {
|
|
/** The menu is visible to the user. */
|
|
Visible,
|
|
|
|
/** The menu is **not** visible to the user. It's still in the DOM, but it is hidden. */
|
|
InvisibleHidden,
|
|
|
|
/** The menu is **not** visible to the user. It's not in the DOM, it is unmounted. */
|
|
InvisibleUnmounted,
|
|
}
|
|
|
|
export function assertMenuButton(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: MenuState
|
|
},
|
|
button = getMenuButton()
|
|
) {
|
|
try {
|
|
if (button === null) return expect(button).not.toBe(null)
|
|
|
|
// Ensure menu button have these properties
|
|
expect(button).toHaveAttribute('id')
|
|
expect(button).toHaveAttribute('aria-haspopup')
|
|
|
|
switch (options.state) {
|
|
case MenuState.Visible:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).toHaveAttribute('aria-expanded', 'true')
|
|
break
|
|
|
|
case MenuState.InvisibleHidden:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
case MenuState.InvisibleUnmounted:
|
|
expect(button).not.toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
|
|
if (options.textContent) {
|
|
expect(button).toHaveTextContent(options.textContent)
|
|
}
|
|
|
|
// Ensure menu button has the following attributes
|
|
for (let attributeName in options.attributes) {
|
|
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertMenuButton)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = getMenu()) {
|
|
try {
|
|
if (button === null) return expect(button).not.toBe(null)
|
|
if (menu === null) return expect(menu).not.toBe(null)
|
|
|
|
// Ensure link between button & menu is correct
|
|
expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id'))
|
|
expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = getMenu()) {
|
|
try {
|
|
if (menu === null) return expect(menu).not.toBe(null)
|
|
if (item === null) return expect(item).not.toBe(null)
|
|
|
|
// Ensure link between menu & menu item is correct
|
|
expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertNoActiveMenuItem(menu = getMenu()) {
|
|
try {
|
|
if (menu === null) return expect(menu).not.toBe(null)
|
|
|
|
// Ensure we don't have an active menu
|
|
expect(menu).not.toHaveAttribute('aria-activedescendant')
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertNoActiveMenuItem)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertMenu(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: MenuState
|
|
},
|
|
menu = getMenu()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case MenuState.InvisibleHidden:
|
|
if (menu === null) return expect(menu).not.toBe(null)
|
|
|
|
assertHidden(menu)
|
|
|
|
expect(menu).toHaveAttribute('aria-labelledby')
|
|
expect(menu).toHaveAttribute('role', 'menu')
|
|
|
|
if (options.textContent) expect(menu).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case MenuState.Visible:
|
|
if (menu === null) return expect(menu).not.toBe(null)
|
|
|
|
assertVisible(menu)
|
|
|
|
expect(menu).toHaveAttribute('aria-labelledby')
|
|
expect(menu).toHaveAttribute('role', 'menu')
|
|
|
|
if (options.textContent) expect(menu).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case MenuState.InvisibleUnmounted:
|
|
expect(menu).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertMenu)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertMenuItem(
|
|
item: HTMLElement | null,
|
|
options?: { tag?: string; attributes?: Record<string, string | null> }
|
|
) {
|
|
try {
|
|
if (item === null) return expect(item).not.toBe(null)
|
|
|
|
// Check that some attributes exists, doesn't really matter what the values are at this point in
|
|
// time, we just require them.
|
|
expect(item).toHaveAttribute('id')
|
|
|
|
// Check that we have the correct values for certain attributes
|
|
expect(item).toHaveAttribute('role', 'menuitem')
|
|
expect(item).toHaveAttribute('tabindex', '-1')
|
|
|
|
// Ensure menu button has the following attributes
|
|
if (options) {
|
|
for (let attributeName in options.attributes) {
|
|
expect(item).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
|
|
if (options.tag) {
|
|
expect(item.tagName.toLowerCase()).toBe(options.tag)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertMenuItem)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function getListboxLabel(): HTMLElement | null {
|
|
return document.querySelector('label,[id^="headlessui-listbox-label"]')
|
|
}
|
|
|
|
export function getListboxButton(): HTMLElement | null {
|
|
return document.querySelector('button,[role="button"]')
|
|
}
|
|
|
|
export function getListboxButtons(): HTMLElement[] {
|
|
return Array.from(document.querySelectorAll('button,[role="button"]'))
|
|
}
|
|
|
|
export function getListbox(): HTMLElement | null {
|
|
return document.querySelector('[role="listbox"]')
|
|
}
|
|
|
|
export function getListboxes(): HTMLElement[] {
|
|
return Array.from(document.querySelectorAll('[role="listbox"]'))
|
|
}
|
|
|
|
export function getListboxOptions(): HTMLElement[] {
|
|
return Array.from(document.querySelectorAll('[role="option"]'))
|
|
}
|
|
|
|
// ---
|
|
|
|
export enum ListboxState {
|
|
/** The listbox is visible to the user. */
|
|
Visible,
|
|
|
|
/** The listbox is **not** visible to the user. It's still in the DOM, but it is hidden. */
|
|
InvisibleHidden,
|
|
|
|
/** The listbox is **not** visible to the user. It's not in the DOM, it is unmounted. */
|
|
InvisibleUnmounted,
|
|
}
|
|
|
|
export function assertListbox(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: ListboxState
|
|
},
|
|
listbox = getListbox()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case ListboxState.InvisibleHidden:
|
|
if (listbox === null) return expect(listbox).not.toBe(null)
|
|
|
|
assertHidden(listbox)
|
|
|
|
expect(listbox).toHaveAttribute('aria-labelledby')
|
|
expect(listbox).toHaveAttribute('role', 'listbox')
|
|
|
|
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case ListboxState.Visible:
|
|
if (listbox === null) return expect(listbox).not.toBe(null)
|
|
|
|
assertVisible(listbox)
|
|
|
|
expect(listbox).toHaveAttribute('aria-labelledby')
|
|
expect(listbox).toHaveAttribute('role', 'listbox')
|
|
|
|
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case ListboxState.InvisibleUnmounted:
|
|
expect(listbox).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertListbox)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertListboxButton(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: ListboxState
|
|
},
|
|
button = getListboxButton()
|
|
) {
|
|
try {
|
|
if (button === null) return expect(button).not.toBe(null)
|
|
|
|
// Ensure menu button have these properties
|
|
expect(button).toHaveAttribute('id')
|
|
expect(button).toHaveAttribute('aria-haspopup')
|
|
|
|
switch (options.state) {
|
|
case ListboxState.Visible:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).toHaveAttribute('aria-expanded', 'true')
|
|
break
|
|
|
|
case ListboxState.InvisibleHidden:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
case ListboxState.InvisibleUnmounted:
|
|
expect(button).not.toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
|
|
if (options.textContent) {
|
|
expect(button).toHaveTextContent(options.textContent)
|
|
}
|
|
|
|
// Ensure menu button has the following attributes
|
|
for (let attributeName in options.attributes) {
|
|
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertListboxButton)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertListboxLabel(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
tag?: string
|
|
textContent?: string
|
|
},
|
|
label = getListboxLabel()
|
|
) {
|
|
try {
|
|
if (label === null) return expect(label).not.toBe(null)
|
|
|
|
// Ensure menu button have these properties
|
|
expect(label).toHaveAttribute('id')
|
|
|
|
if (options.textContent) {
|
|
expect(label).toHaveTextContent(options.textContent)
|
|
}
|
|
|
|
if (options.tag) {
|
|
expect(label.tagName.toLowerCase()).toBe(options.tag)
|
|
}
|
|
|
|
// Ensure menu button has the following attributes
|
|
for (let attributeName in options.attributes) {
|
|
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertListboxLabel)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertListboxButtonLinkedWithListbox(
|
|
button = getListboxButton(),
|
|
listbox = getListbox()
|
|
) {
|
|
try {
|
|
if (button === null) return expect(button).not.toBe(null)
|
|
if (listbox === null) return expect(listbox).not.toBe(null)
|
|
|
|
// Ensure link between button & listbox is correct
|
|
expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id'))
|
|
expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertListboxLabelLinkedWithListbox(
|
|
label = getListboxLabel(),
|
|
listbox = getListbox()
|
|
) {
|
|
try {
|
|
if (label === null) return expect(label).not.toBe(null)
|
|
if (listbox === null) return expect(listbox).not.toBe(null)
|
|
|
|
expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertListboxButtonLinkedWithListboxLabel(
|
|
button = getListboxButton(),
|
|
label = getListboxLabel()
|
|
) {
|
|
try {
|
|
if (button === null) return expect(button).not.toBe(null)
|
|
if (label === null) return expect(label).not.toBe(null)
|
|
|
|
// Ensure link between button & label is correct
|
|
expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertActiveListboxOption(item: HTMLElement | null, listbox = getListbox()) {
|
|
try {
|
|
if (listbox === null) return expect(listbox).not.toBe(null)
|
|
if (item === null) return expect(item).not.toBe(null)
|
|
|
|
// Ensure link between listbox & listbox item is correct
|
|
expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertActiveListboxOption)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertNoActiveListboxOption(listbox = getListbox()) {
|
|
try {
|
|
if (listbox === null) return expect(listbox).not.toBe(null)
|
|
|
|
// Ensure we don't have an active listbox
|
|
expect(listbox).not.toHaveAttribute('aria-activedescendant')
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertNoActiveListboxOption)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertNoSelectedListboxOption(items = getListboxOptions()) {
|
|
try {
|
|
for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertNoSelectedListboxOption)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertListboxOption(
|
|
item: HTMLElement | null,
|
|
options?: {
|
|
tag?: string
|
|
attributes?: Record<string, string | null>
|
|
selected?: boolean
|
|
}
|
|
) {
|
|
try {
|
|
if (item === null) return expect(item).not.toBe(null)
|
|
|
|
// Check that some attributes exists, doesn't really matter what the values are at this point in
|
|
// time, we just require them.
|
|
expect(item).toHaveAttribute('id')
|
|
|
|
// Check that we have the correct values for certain attributes
|
|
expect(item).toHaveAttribute('role', 'option')
|
|
expect(item).toHaveAttribute('tabindex', '-1')
|
|
|
|
// Ensure listbox button has the following attributes
|
|
if (!options) return
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(item).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
|
|
if (options.tag) {
|
|
expect(item.tagName.toLowerCase()).toBe(options.tag)
|
|
}
|
|
|
|
if (options.selected != null) {
|
|
switch (options.selected) {
|
|
case true:
|
|
return expect(item).toHaveAttribute('aria-selected', 'true')
|
|
|
|
case false:
|
|
return expect(item).not.toHaveAttribute('aria-selected')
|
|
|
|
default:
|
|
assertNever(options.selected)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertListboxOption)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function getSwitch(): HTMLElement | null {
|
|
return document.querySelector('[role="switch"]')
|
|
}
|
|
|
|
export function getSwitchLabel(): HTMLElement | null {
|
|
return document.querySelector('label,[id^="headlessui-switch-label"]')
|
|
}
|
|
|
|
// ---
|
|
|
|
export enum SwitchState {
|
|
On,
|
|
Off,
|
|
}
|
|
|
|
export function assertSwitch(
|
|
options: {
|
|
state: SwitchState
|
|
tag?: string
|
|
textContent?: string
|
|
label?: string
|
|
description?: string
|
|
},
|
|
switchElement = getSwitch()
|
|
) {
|
|
try {
|
|
if (switchElement === null) return expect(switchElement).not.toBe(null)
|
|
|
|
expect(switchElement).toHaveAttribute('role', 'switch')
|
|
expect(switchElement).toHaveAttribute('tabindex', '0')
|
|
|
|
if (options.textContent) {
|
|
expect(switchElement).toHaveTextContent(options.textContent)
|
|
}
|
|
|
|
if (options.tag) {
|
|
expect(switchElement.tagName.toLowerCase()).toBe(options.tag)
|
|
}
|
|
|
|
if (options.label) {
|
|
assertLabelValue(switchElement, options.label)
|
|
}
|
|
|
|
if (options.description) {
|
|
assertDescriptionValue(switchElement, options.description)
|
|
}
|
|
|
|
switch (options.state) {
|
|
case SwitchState.On:
|
|
expect(switchElement).toHaveAttribute('aria-checked', 'true')
|
|
break
|
|
|
|
case SwitchState.Off:
|
|
expect(switchElement).toHaveAttribute('aria-checked', 'false')
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertSwitch)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function getDisclosureButton(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-disclosure-button-"]')
|
|
}
|
|
|
|
export function getDisclosurePanel(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-disclosure-panel-"]')
|
|
}
|
|
|
|
// ---
|
|
|
|
export enum DisclosureState {
|
|
/** The disclosure is visible to the user. */
|
|
Visible,
|
|
|
|
/** The disclosure is **not** visible to the user. It's still in the DOM, but it is hidden. */
|
|
InvisibleHidden,
|
|
|
|
/** The disclosure is **not** visible to the user. It's not in the DOM, it is unmounted. */
|
|
InvisibleUnmounted,
|
|
}
|
|
|
|
// ---
|
|
|
|
export function assertDisclosureButton(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: DisclosureState
|
|
},
|
|
button = getDisclosureButton()
|
|
) {
|
|
try {
|
|
if (button === null) return expect(button).not.toBe(null)
|
|
|
|
// Ensure disclosure button have these properties
|
|
expect(button).toHaveAttribute('id')
|
|
|
|
switch (options.state) {
|
|
case DisclosureState.Visible:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).toHaveAttribute('aria-expanded', 'true')
|
|
break
|
|
|
|
case DisclosureState.InvisibleHidden:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
case DisclosureState.InvisibleUnmounted:
|
|
expect(button).not.toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
|
|
if (options.textContent) {
|
|
expect(button).toHaveTextContent(options.textContent)
|
|
}
|
|
|
|
// Ensure disclosure button has the following attributes
|
|
for (let attributeName in options.attributes) {
|
|
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertDisclosureButton)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertDisclosurePanel(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: DisclosureState
|
|
},
|
|
panel = getDisclosurePanel()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case DisclosureState.InvisibleHidden:
|
|
if (panel === null) return expect(panel).not.toBe(null)
|
|
|
|
assertHidden(panel)
|
|
|
|
if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DisclosureState.Visible:
|
|
if (panel === null) return expect(panel).not.toBe(null)
|
|
|
|
assertVisible(panel)
|
|
|
|
if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DisclosureState.InvisibleUnmounted:
|
|
expect(panel).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertDisclosurePanel)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function getPopoverButton(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-popover-button-"]')
|
|
}
|
|
|
|
export function getPopoverPanel(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-popover-panel-"]')
|
|
}
|
|
|
|
export function getPopoverOverlay(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-popover-overlay-"]')
|
|
}
|
|
|
|
// ---
|
|
|
|
export enum PopoverState {
|
|
/** The popover is visible to the user. */
|
|
Visible,
|
|
|
|
/** The popover is **not** visible to the user. It's still in the DOM, but it is hidden. */
|
|
InvisibleHidden,
|
|
|
|
/** The popover is **not** visible to the user. It's not in the DOM, it is unmounted. */
|
|
InvisibleUnmounted,
|
|
}
|
|
|
|
// ---
|
|
|
|
export function assertPopoverButton(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: PopoverState
|
|
},
|
|
button = getPopoverButton()
|
|
) {
|
|
try {
|
|
if (button === null) return expect(button).not.toBe(null)
|
|
|
|
// Ensure popover button have these properties
|
|
expect(button).toHaveAttribute('id')
|
|
|
|
switch (options.state) {
|
|
case PopoverState.Visible:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).toHaveAttribute('aria-expanded', 'true')
|
|
break
|
|
|
|
case PopoverState.InvisibleHidden:
|
|
expect(button).toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
case PopoverState.InvisibleUnmounted:
|
|
expect(button).not.toHaveAttribute('aria-controls')
|
|
expect(button).not.toHaveAttribute('aria-expanded')
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
|
|
if (options.textContent) {
|
|
expect(button).toHaveTextContent(options.textContent)
|
|
}
|
|
|
|
// Ensure popover button has the following attributes
|
|
for (let attributeName in options.attributes) {
|
|
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertPopoverButton)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertPopoverPanel(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: PopoverState
|
|
},
|
|
panel = getPopoverPanel()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case PopoverState.InvisibleHidden:
|
|
if (panel === null) return expect(panel).not.toBe(null)
|
|
|
|
assertHidden(panel)
|
|
|
|
if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case PopoverState.Visible:
|
|
if (panel === null) return expect(panel).not.toBe(null)
|
|
|
|
assertVisible(panel)
|
|
|
|
if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case PopoverState.InvisibleUnmounted:
|
|
expect(panel).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertPopoverPanel)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function assertLabelValue(element: HTMLElement | null, value: string) {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
if (element.hasAttribute('aria-labelledby')) {
|
|
let ids = element.getAttribute('aria-labelledby')!.split(' ')
|
|
expect(ids.map(id => document.getElementById(id)?.textContent).join(' ')).toEqual(value)
|
|
return
|
|
}
|
|
|
|
if (element.hasAttribute('aria-label')) {
|
|
expect(element).toHaveAttribute('aria-label', value)
|
|
return
|
|
}
|
|
|
|
if (element.hasAttribute('id') && document.querySelectorAll(`[for="${element.id}"]`).length > 0) {
|
|
expect(document.querySelector(`[for="${element.id}"]`)).toHaveTextContent(value)
|
|
return
|
|
}
|
|
|
|
expect(element).toHaveTextContent(value)
|
|
}
|
|
|
|
// ---
|
|
|
|
export function assertDescriptionValue(element: HTMLElement | null, value: string) {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
let id = element.getAttribute('aria-describedby')!
|
|
expect(document.getElementById(id)?.textContent).toEqual(value)
|
|
}
|
|
|
|
// ---
|
|
|
|
export function getDialog(): HTMLElement | null {
|
|
return document.querySelector('[role="dialog"]')
|
|
}
|
|
|
|
export function getDialogTitle(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-dialog-title-"]')
|
|
}
|
|
|
|
export function getDialogDescription(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-dialog-description-"]')
|
|
}
|
|
|
|
export function getDialogOverlay(): HTMLElement | null {
|
|
return document.querySelector('[id^="headlessui-dialog-overlay-"]')
|
|
}
|
|
|
|
// ---
|
|
|
|
export enum DialogState {
|
|
/** The dialog is visible to the user. */
|
|
Visible,
|
|
|
|
/** The dialog is **not** visible to the user. It's still in the DOM, but it is hidden. */
|
|
InvisibleHidden,
|
|
|
|
/** The dialog is **not** visible to the user. It's not in the DOM, it is unmounted. */
|
|
InvisibleUnmounted,
|
|
}
|
|
|
|
// ---
|
|
|
|
export function assertDialog(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: DialogState
|
|
},
|
|
dialog = getDialog()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case DialogState.InvisibleHidden:
|
|
if (dialog === null) return expect(dialog).not.toBe(null)
|
|
|
|
assertHidden(dialog)
|
|
|
|
expect(dialog).toHaveAttribute('role', 'dialog')
|
|
expect(dialog).not.toHaveAttribute('aria-modal', 'true')
|
|
|
|
if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(dialog).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.Visible:
|
|
if (dialog === null) return expect(dialog).not.toBe(null)
|
|
|
|
assertVisible(dialog)
|
|
|
|
expect(dialog).toHaveAttribute('role', 'dialog')
|
|
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
|
|
|
if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(dialog).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.InvisibleUnmounted:
|
|
expect(dialog).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertDialog)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertDialogTitle(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: DialogState
|
|
},
|
|
title = getDialogTitle(),
|
|
dialog = getDialog()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case DialogState.InvisibleHidden:
|
|
if (title === null) return expect(title).not.toBe(null)
|
|
if (dialog === null) return expect(dialog).not.toBe(null)
|
|
|
|
assertHidden(title)
|
|
|
|
expect(title).toHaveAttribute('id')
|
|
expect(dialog).toHaveAttribute('aria-labelledby', title.id)
|
|
|
|
if (options.textContent) expect(title).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(title).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.Visible:
|
|
if (title === null) return expect(title).not.toBe(null)
|
|
if (dialog === null) return expect(dialog).not.toBe(null)
|
|
|
|
assertVisible(title)
|
|
|
|
expect(title).toHaveAttribute('id')
|
|
expect(dialog).toHaveAttribute('aria-labelledby', title.id)
|
|
|
|
if (options.textContent) expect(title).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(title).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.InvisibleUnmounted:
|
|
expect(title).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertDialogTitle)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertDialogDescription(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: DialogState
|
|
},
|
|
description = getDialogDescription(),
|
|
dialog = getDialog()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case DialogState.InvisibleHidden:
|
|
if (description === null) return expect(description).not.toBe(null)
|
|
if (dialog === null) return expect(dialog).not.toBe(null)
|
|
|
|
assertHidden(description)
|
|
|
|
expect(description).toHaveAttribute('id')
|
|
expect(dialog).toHaveAttribute('aria-describedby', description.id)
|
|
|
|
if (options.textContent) expect(description).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(description).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.Visible:
|
|
if (description === null) return expect(description).not.toBe(null)
|
|
if (dialog === null) return expect(dialog).not.toBe(null)
|
|
|
|
assertVisible(description)
|
|
|
|
expect(description).toHaveAttribute('id')
|
|
expect(dialog).toHaveAttribute('aria-describedby', description.id)
|
|
|
|
if (options.textContent) expect(description).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(description).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.InvisibleUnmounted:
|
|
expect(description).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertDialogDescription)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertDialogOverlay(
|
|
options: {
|
|
attributes?: Record<string, string | null>
|
|
textContent?: string
|
|
state: DialogState
|
|
},
|
|
overlay = getDialogOverlay()
|
|
) {
|
|
try {
|
|
switch (options.state) {
|
|
case DialogState.InvisibleHidden:
|
|
if (overlay === null) return expect(overlay).not.toBe(null)
|
|
|
|
assertHidden(overlay)
|
|
|
|
if (options.textContent) expect(overlay).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(overlay).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.Visible:
|
|
if (overlay === null) return expect(overlay).not.toBe(null)
|
|
|
|
assertVisible(overlay)
|
|
|
|
if (options.textContent) expect(overlay).toHaveTextContent(options.textContent)
|
|
|
|
for (let attributeName in options.attributes) {
|
|
expect(overlay).toHaveAttribute(attributeName, options.attributes[attributeName])
|
|
}
|
|
break
|
|
|
|
case DialogState.InvisibleUnmounted:
|
|
expect(overlay).toBe(null)
|
|
break
|
|
|
|
default:
|
|
assertNever(options.state)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertDialogOverlay)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function assertActiveElement(element: HTMLElement | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
try {
|
|
// Jest has a weird bug:
|
|
// "Cannot assign to read only property 'Symbol(impl)' of object '[object DOMImplementation]'"
|
|
// when this assertion fails.
|
|
// Therefore we will catch it when something goes wrong, and just look at the outerHTML string.
|
|
expect(document.activeElement).toBe(element)
|
|
} catch (err) {
|
|
expect(document.activeElement?.outerHTML).toBe(element.outerHTML)
|
|
}
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertActiveElement)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertContainsActiveElement(element: HTMLElement | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
expect(element.contains(document.activeElement)).toBe(true)
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertContainsActiveElement)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function assertHidden(element: HTMLElement | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
expect(element).toHaveAttribute('hidden')
|
|
expect(element).toHaveStyle({ display: 'none' })
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertHidden)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function assertVisible(element: HTMLElement | null) {
|
|
try {
|
|
if (element === null) return expect(element).not.toBe(null)
|
|
|
|
expect(element).not.toHaveAttribute('hidden')
|
|
expect(element).not.toHaveStyle({ display: 'none' })
|
|
} catch (err) {
|
|
Error.captureStackTrace(err, assertVisible)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// ---
|
|
|
|
export function getByText(text: string): HTMLElement | null {
|
|
let walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
|
|
acceptNode(node: HTMLElement) {
|
|
if (node.children.length > 0) return NodeFilter.FILTER_SKIP
|
|
return NodeFilter.FILTER_ACCEPT
|
|
},
|
|
})
|
|
|
|
while (walker.nextNode()) {
|
|
if (walker.currentNode.textContent === text) return walker.currentNode as HTMLElement
|
|
}
|
|
|
|
return null
|
|
}
|