diff --git a/jest.config.cjs b/jest.config.cjs index 598de83..bd9e68b 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -4,6 +4,9 @@ module.exports = { '^.+\\.js$': 'babel-jest', '^.+\\.ts$': 'ts-jest', }, + setupFilesAfterEnv: [ + '@testing-library/jest-dom/extend-expect', + ], testEnvironment: "jsdom", moduleFileExtensions: ['js', 'ts', 'svelte'], moduleNameMapper: { diff --git a/src/lib/components/switch/switch.test.ts b/src/lib/components/switch/switch.test.ts index aefcbd8..90e4703 100644 --- a/src/lib/components/switch/switch.test.ts +++ b/src/lib/components/switch/switch.test.ts @@ -1,6 +1,8 @@ import { render } from "@testing-library/svelte"; import TestRenderer from "../../test-utils/TestRenderer.svelte"; import { Switch } from "."; +import { assertSwitch, getSwitch, SwitchState } from "$lib/test-utils/accessibility-assertions"; +import Span from "$lib/internal/elements/Span.svelte"; jest.mock('../../hooks/use-id') describe('Safe guards', () => { @@ -13,3 +15,100 @@ describe('Safe guards', () => { }); }) }) + +describe('Rendering', () => { + // TODO: handle these render prop (slot prop) tests + + // it('should be possible to render an (on) Switch using a render prop', () => { + // render(TestRenderer, { + // + // {({ checked }) => {checked ? 'On' : 'Off'}} + // + // ) + + // assertSwitch({ state: SwitchState.On, textContent: 'On' }) + // }) + + // it('should be possible to render an (off) Switch using a render prop', () => { + // render( + // + // {({ checked }) => {checked ? 'On' : 'Off'}} + // + // ) + + // assertSwitch({ state: SwitchState.Off, textContent: 'Off' }) + // }) + + it('should be possible to render an (on) Switch using an `as` prop', () => { + render(TestRenderer, { + allProps: [ + Switch, + { as: "span", checked: true, onChange: console.log }, + ] + }); + assertSwitch({ state: SwitchState.On, tag: 'span' }) + }) + + it('should be possible to render an (off) Switch using an `as` prop', () => { + render(TestRenderer, { + allProps: [ + Switch, + { as: "span", checked: false, onChange: console.log }, + ] + }); + assertSwitch({ state: SwitchState.Off, tag: 'span' }) + }) + + it('should be possible to use the switch contents as the label', () => { + render(TestRenderer, { + allProps: [ + Switch, + { checked: false, onChange: console.log }, + [ + Span, + {}, + "Enable notifications" + ] + ] + }); + assertSwitch({ state: SwitchState.Off, label: 'Enable notifications' }) + }) + + describe('`type` attribute', () => { + it('should set the `type` to "button" by default', async () => { + render(TestRenderer, { + allProps: [ + Switch, + { checked: false, onChange: console.log }, + "Trigger" + ] + }); + + expect(getSwitch()).toHaveAttribute('type', 'button') + }) + + it('should not set the `type` to "button" if it already contains a `type`', async () => { + render(TestRenderer, { + allProps: [ + Switch, + { checked: false, onChange: console.log, type: "submit" }, + "Trigger" + ] + }); + + expect(getSwitch()).toHaveAttribute('type', 'submit') + }) + + it('should not set the type if the "as" prop is not a "button"', async () => { + render(TestRenderer, { + allProps: [ + Switch, + { checked: false, onChange: console.log, as: "div" }, + "Trigger" + ] + }); + + expect(getSwitch()).not.toHaveAttribute('type') + }) + }) +}) diff --git a/src/lib/test-utils/accessibility-assertions.ts b/src/lib/test-utils/accessibility-assertions.ts new file mode 100644 index 0000000..ae9404b --- /dev/null +++ b/src/lib/test-utils/accessibility-assertions.ts @@ -0,0 +1,1370 @@ +import { isFocusableElement, FocusableMode } from '../utils/focus-management' + +function assertNever(x: never): never { + throw new Error('Unexpected object: ' + x) +} + +// --- + +export function getMenuButton(): HTMLElement | null { + return document.querySelector('button,[role="button"],[id^="headlessui-menu-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 + 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') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case MenuState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + 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: any) { + 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: any) { + 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: any) { + 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: any) { + Error.captureStackTrace(err, assertNoActiveMenuItem) + throw err + } +} + +export function assertMenu( + options: { + attributes?: Record + 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: any) { + Error.captureStackTrace(err, assertMenu) + throw err + } +} + +export function assertMenuItem( + item: HTMLElement | null, + options?: { tag?: string; attributes?: Record } +) { + 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') + if (!item.getAttribute('aria-disabled')) 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: any) { + 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"],[id^="headlessui-listbox-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 + textContent?: string + state: ListboxState + orientation?: 'horizontal' | 'vertical' + }, + listbox = getListbox() +) { + let { orientation = 'vertical' } = options + + 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('aria-orientation', orientation) + 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('aria-orientation', orientation) + 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: any) { + Error.captureStackTrace(err, assertListbox) + throw err + } +} + +export function assertListboxButton( + options: { + attributes?: Record + 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') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case ListboxState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + 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: any) { + Error.captureStackTrace(err, assertListboxButton) + throw err + } +} + +export function assertListboxLabel( + options: { + attributes?: Record + 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: any) { + 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: any) { + 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: any) { + 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: any) { + 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: any) { + 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: any) { + 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: any) { + Error.captureStackTrace(err, assertNoSelectedListboxOption) + throw err + } +} + +export function assertListboxOption( + item: HTMLElement | null, + options?: { + tag?: string + attributes?: Record + 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') + if (!item.getAttribute('aria-disabled')) 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: any) { + 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: any) { + 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 + 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') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case DisclosureState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + 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: any) { + Error.captureStackTrace(err, assertDisclosureButton) + throw err + } +} + +export function assertDisclosurePanel( + options: { + attributes?: Record + 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: any) { + 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 + 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') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case PopoverState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + 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: any) { + Error.captureStackTrace(err, assertPopoverButton) + throw err + } +} + +export function assertPopoverPanel( + options: { + attributes?: Record + 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: any) { + 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 getDialogs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="dialog"]')) +} + +export function getDialogTitle(): HTMLElement | null { + return document.querySelector('[id^="headlessui-dialog-title-"]') +} + +export function getDialogDescription(): HTMLElement | null { + return document.querySelector('[id^="headlessui-description-"]') +} + +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 { + /** 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 + 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: any) { + Error.captureStackTrace(err, assertDialog) + throw err + } +} + +export function assertDialogTitle( + options: { + attributes?: Record + 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: any) { + Error.captureStackTrace(err, assertDialogTitle) + throw err + } +} + +export function assertDialogDescription( + options: { + attributes?: Record + 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: any) { + Error.captureStackTrace(err, assertDialogDescription) + throw err + } +} + +export function assertDialogOverlay( + options: { + attributes?: Record + 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: any) { + Error.captureStackTrace(err, assertDialogOverlay) + throw err + } +} + +// --- + +export function getRadioGroup(): HTMLElement | null { + return document.querySelector('[role="radiogroup"]') +} + +export function getRadioGroupLabel(): HTMLElement | null { + return document.querySelector('[id^="headlessui-label-"]') +} + +export function getRadioGroupOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[id^="headlessui-radiogroup-option-"]')) +} + +// --- + +export function assertRadioGroupLabel( + options: { + attributes?: Record + textContent?: string + }, + label = getRadioGroupLabel(), + radioGroup = getRadioGroup() +) { + try { + if (label === null) return expect(label).not.toBe(null) + if (radioGroup === null) return expect(radioGroup).not.toBe(null) + + expect(label).toHaveAttribute('id') + expect(radioGroup).toHaveAttribute('aria-labelledby', label.id) + + if (options.textContent) expect(label).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err: any) { + Error.captureStackTrace(err, assertRadioGroupLabel) + throw err + } +} + +// --- + +export function getTabList(): HTMLElement | null { + return document.querySelector('[role="tablist"]') +} + +export function getTabs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[id^="headlessui-tabs-tab-"]')) +} + +export function getPanels(): HTMLElement[] { + return Array.from(document.querySelectorAll('[id^="headlessui-tabs-panel-"]')) +} + +// --- + +export function assertTabs( + { + active, + orientation = 'horizontal', + }: { + active: number + orientation?: 'vertical' | 'horizontal' + }, + list = getTabList(), + tabs = getTabs(), + panels = getPanels() +) { + try { + if (list === null) return expect(list).not.toBe(null) + + expect(list).toHaveAttribute('role', 'tablist') + expect(list).toHaveAttribute('aria-orientation', orientation) + + let activeTab = tabs.find(tab => tab.dataset.headlessuiIndex === '' + active) + let activePanel = panels.find(panel => panel.dataset.headlessuiIndex === '' + active) + + for (let tab of tabs) { + expect(tab).toHaveAttribute('id') + expect(tab).toHaveAttribute('role', 'tab') + expect(tab).toHaveAttribute('type', 'button') + + if (tab === activeTab) { + expect(tab).toHaveAttribute('aria-selected', 'true') + expect(tab).toHaveAttribute('tabindex', '0') + } else { + expect(tab).toHaveAttribute('aria-selected', 'false') + expect(tab).toHaveAttribute('tabindex', '-1') + } + + if (tab.hasAttribute('aria-controls')) { + let controlsId = tab.getAttribute('aria-controls')! + let panel = document.getElementById(controlsId) + + expect(panel).not.toBe(null) + expect(panels).toContain(panel) + expect(panel).toHaveAttribute('aria-labelledby', tab.id) + } + } + + for (let panel of panels) { + expect(panel).toHaveAttribute('id') + expect(panel).toHaveAttribute('role', 'tabpanel') + + let controlledById = panel.getAttribute('aria-labelledby')! + let tab = document.getElementById(controlledById) + + expect(tabs).toContain(tab) + expect(tab).toHaveAttribute('aria-controls', panel.id) + + if (panel === activePanel) { + expect(panel).toHaveAttribute('tabindex', '0') + } else { + expect(panel).toHaveAttribute('tabindex', '-1') + } + } + } catch (err: any) { + Error.captureStackTrace(err, assertTabs) + 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: any) { + expect(document.activeElement?.outerHTML).toBe(element.outerHTML) + } + } catch (err: any) { + 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: any) { + 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: any) { + 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: any) { + Error.captureStackTrace(err, assertVisible) + throw err + } +} + +// --- + +export function assertFocusable(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true) + } catch (err: any) { + Error.captureStackTrace(err, assertFocusable) + throw err + } +} + +export function assertNotFocusable(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false) + } catch (err: any) { + Error.captureStackTrace(err, assertNotFocusable) + 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 +}