From c99b74c089a8ea7a1d4abe355246d7a12f467c36 Mon Sep 17 00:00:00 2001 From: Ryan Gossiaux Date: Tue, 28 Dec 2021 09:23:42 -1000 Subject: [PATCH] Prettier Didn't run this on every single file because it messes up the formatting of the some of the TestRenderer constructions --- .github/workflows/main.yml | 4 +- README.md | 42 +- babel.config.cjs | 4 +- jest.config.cjs | 14 +- src/lib/components/dialog/dialog.test.ts | 109 +- src/lib/components/dialog/index.ts | 1 - .../components/disclosure/disclosure.test.ts | 68 +- src/lib/components/listbox/listbox.test.ts | 45 +- src/lib/components/radio-group/index.ts | 1 - .../radio-group/radio-group.test.ts | 9 +- src/lib/components/switch/switch.test.ts | 424 +++---- src/lib/hooks/use-actions.ts | 2 +- src/lib/hooks/use-portal.ts | 5 +- src/lib/internal/elements/index.ts | 80 +- .../test-utils/accessibility-assertions.ts | 1120 ++++++++++------- src/lib/test-utils/interactions.ts | 290 +++-- src/lib/test-utils/suppress-console-logs.ts | 14 +- src/lib/utils/resolve-button-type.ts | 8 +- src/lib/utils/transition.ts | 2 +- src/routes/popover/_Link.svelte | 1 - svelte.config.js | 2 +- 21 files changed, 1212 insertions(+), 1033 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2200e9f..9d26803 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,9 +2,9 @@ name: Jest on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] workflow_dispatch: jobs: diff --git a/README.md b/README.md index 7201011..d3a5d62 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # svelte-headlessui -This is an unofficial, complete Svelte port of the Headless UI component library (https://headlessui.dev/). It contains **fully accessible, feature-rich, unstyled** UI components. +This is an unofficial, complete Svelte port of the Headless UI component library (https://headlessui.dev/). It contains **fully accessible, feature-rich, unstyled** UI components. ## Who is this for? This library is for you if you fall into one of two categories: -* You want unstyled yet sophisticated customizable UI components that fully follow the WAI-ARIA specs. You want a component library to handle all the messy details (keyboard navigation, focus management, aria-* attributes, and many many more), but you want to style your components yourself and not be constrained by existing design systems like Material UI. -* You want to use the commercial Tailwind UI component library (https://tailwindui.com/) in your Svelte project, and want a drop-in replacement for the React components which power Tailwind UI. +- You want unstyled yet sophisticated customizable UI components that fully follow the WAI-ARIA specs. You want a component library to handle all the messy details (keyboard navigation, focus management, aria-\* attributes, and many many more), but you want to style your components yourself and not be constrained by existing design systems like Material UI. +- You want to use the commercial Tailwind UI component library (https://tailwindui.com/) in your Svelte project, and want a drop-in replacement for the React components which power Tailwind UI. -This project is intended to keep an API as close as possible to the React API for the base Headless UI project, with only a few small differences. While one of the primary goals is to enable using Tailwind UI in a Svelte project with as little effort as possible, **neither Tailwind UI nor Tailwind CSS is required** to use these components. +This project is intended to keep an API as close as possible to the React API for the base Headless UI project, with only a few small differences. While one of the primary goals is to enable using Tailwind UI in a Svelte project with as little effort as possible, **neither Tailwind UI nor Tailwind CSS is required** to use these components. -This project is an **unofficial** port. I have no affiliation with Tailwind Labs and cannot offer commercial support for this project. With that said, I intend to keep it as up to date as possible with the upstream Headless UI project, including porting new components when they are released. +This project is an **unofficial** port. I have no affiliation with Tailwind Labs and cannot offer commercial support for this project. With that said, I intend to keep it as up to date as possible with the upstream Headless UI project, including porting new components when they are released. ## Installation @@ -22,8 +22,10 @@ npm install @rgossiaux/svelte-headlessui ## Usage For now, until I write separate documentation, you can refer to the [Headless UI React documentation](https://headlessui.dev/). The API is nearly identical to the React API there, with the following differences: -* Components do not have . in their names; use `ListboxButton` instead of `Listbox.Button` -* Event handlers are done Svelte-style with custom events: + +- Components do not have . in their names; use `ListboxButton` instead of `Listbox.Button` +- Event handlers are done Svelte-style with custom events: + ``` // React version console.log(value)}> @@ -38,7 +40,8 @@ For now, until I write separate documentation, you can refer to the [Headless UI Note the `.detail` that is needed to get the event value in the Svelte version. -* Instead of render props, we use Svelte's [slot props](https://svelte.dev/tutorial/slot-props): +- Instead of render props, we use Svelte's [slot props](https://svelte.dev/tutorial/slot-props): + ``` // React version @@ -50,30 +53,35 @@ Note the `.detail` that is needed to get the event value in the Svelte version. ``` -* When porting React code, HTML attributes use their real names instead of the camelCased React versions. In particular, use `class=` instead of `className=`. -* When porting React code, use `{#each}` instead of `.map` (don't forget to add a key!): + +- When porting React code, HTML attributes use their real names instead of the camelCased React versions. In particular, use `class=` instead of `className=`. +- When porting React code, use `{#each}` instead of `.map` (don't forget to add a key!): + ``` // React version {people.map((person) => ( {#each people as person (person.id)} )}` style syntax becomes `{#if value} {/if}` in Svelte, of course. -* While the `as` prop is supported, `as={Fragment}` support is not possible due to limitations in Svelte itself. You'll have to settle for rendering a `div` or similar. Usually this won't cause any problems, especially if you are writing your own code, but if you are porting React Headless UI code that both uses `as={Fragment}` and `z-index`, your div could possibly create a new [stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context), causing the `z-index` to not work correctly. If this happens, you can fix by copying the `z-index` (and a relevant `position`) to the component that used to render as a `Fragment`. -Furthermore, specific to Svelte, you may -* Pass [actions](https://svelte.dev/tutorial/actions) to any component in this library using the `use` prop, with the syntax `use={[[action1, action1options], [action2], [action3, action3options], ...]}`, and they will be forwarded to the underlying DOM element. -* Add your own event listeners with modifiers, which will be forwarded to the underyling DOM element. Modifiers are separated with the `!` character instead of the normal `|`: `on:click!capture={(e) => ...}` +Similarly, React's `{value && ()}` style syntax becomes `{#if value} {/if}` in Svelte, of course. + +- While the `as` prop is supported, `as={Fragment}` support is not possible due to limitations in Svelte itself. You'll have to settle for rendering a `div` or similar. Usually this won't cause any problems, especially if you are writing your own code, but if you are porting React Headless UI code that both uses `as={Fragment}` and `z-index`, your div could possibly create a new [stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context), causing the `z-index` to not work correctly. If this happens, you can fix by copying the `z-index` (and a relevant `position`) to the component that used to render as a `Fragment`. + +Furthermore, specific to Svelte, you may + +- Pass [actions](https://svelte.dev/tutorial/actions) to any component in this library using the `use` prop, with the syntax `use={[[action1, action1options], [action2], [action3, action3options], ...]}`, and they will be forwarded to the underlying DOM element. +- Add your own event listeners with modifiers, which will be forwarded to the underyling DOM element. Modifiers are separated with the `!` character instead of the normal `|`: `on:click!capture={(e) => ...}` ## Credits -Credit for everything good about this library goes to Tailwind Labs for writing the original React/Vue versions. All bugs should be assumed to be my fault in the port (though as the codebases are so similar, bugs in upstream will likely affect this library too). +Credit for everything good about this library goes to Tailwind Labs for writing the original React/Vue versions. All bugs should be assumed to be my fault in the port (though as the codebases are so similar, bugs in upstream will likely affect this library too). Additional thanks to https://github.com/hperrin/svelte-material-ui; this well-engineered Svelte library was the source of the action and event forwarding code, with minor modifications. diff --git a/babel.config.cjs b/babel.config.cjs index f5c1c3c..24d5e76 100644 --- a/babel.config.cjs +++ b/babel.config.cjs @@ -1,3 +1,3 @@ module.exports = { - presets: [['@babel/preset-env', { targets: { node: 'current' } }]] -} + presets: [["@babel/preset-env", { targets: { node: "current" } }]], +}; diff --git a/jest.config.cjs b/jest.config.cjs index bd9e68b..80a2ebc 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,15 +1,13 @@ module.exports = { transform: { - '^.+\\.svelte$': ['svelte-jester', { preprocess: true }], - '^.+\\.js$': 'babel-jest', - '^.+\\.ts$': 'ts-jest', + "^.+\\.svelte$": ["svelte-jester", { preprocess: true }], + "^.+\\.js$": "babel-jest", + "^.+\\.ts$": "ts-jest", }, - setupFilesAfterEnv: [ - '@testing-library/jest-dom/extend-expect', - ], + setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"], testEnvironment: "jsdom", - moduleFileExtensions: ['js', 'ts', 'svelte'], + moduleFileExtensions: ["js", "ts", "svelte"], moduleNameMapper: { "\\$lib/(.+)$": "/src/lib/$1", }, -} +}; diff --git a/src/lib/components/dialog/dialog.test.ts b/src/lib/components/dialog/dialog.test.ts index d014349..28484e3 100644 --- a/src/lib/components/dialog/dialog.test.ts +++ b/src/lib/components/dialog/dialog.test.ts @@ -1,4 +1,4 @@ -import { Dialog, DialogDescription, DialogOverlay, DialogTitle } from "." +import { Dialog, DialogDescription, DialogOverlay, DialogTitle } from "."; import TestTabSentinel from "./_TestTabSentinel.svelte"; import ManagedDialog from "./_ManagedDialog.svelte"; import NestedTestComponent from "./_NestedTestComponent.svelte"; @@ -10,95 +10,100 @@ import Div from "$lib/internal/elements/Div.svelte"; import Form from "$lib/internal/elements/Form.svelte"; import P from "$lib/internal/elements/P.svelte"; import Input from "$lib/internal/elements/Input.svelte"; -import { assertActiveElement, assertDialog, assertDialogDescription, DialogState, getByText, getDialog, getDialogOverlay, getDialogOverlays, getDialogs } from "$lib/test-utils/accessibility-assertions"; +import { + assertActiveElement, + assertDialog, + assertDialogDescription, + DialogState, + getByText, + getDialog, + getDialogOverlay, + getDialogOverlays, + getDialogs, +} from "$lib/test-utils/accessibility-assertions"; import { click, Keys, press } from "$lib/test-utils/interactions"; import Transition from "$lib/components/transitions/TransitionRoot.svelte"; import { tick } from "svelte"; let id = 0; -jest.mock('../../hooks/use-id', () => { +jest.mock("../../hooks/use-id", () => { return { useId: jest.fn(() => ++id), - } -}) + }; +}); // @ts-expect-error global.IntersectionObserver = class FakeIntersectionObserver { - observe() { } - disconnect() { } -} + observe() {} + disconnect() {} +}; -beforeEach(() => id = 0) -afterAll(() => jest.restoreAllMocks()) +beforeEach(() => (id = 0)); +afterAll(() => jest.restoreAllMocks()); -describe('Safe guards', () => { +describe("Safe guards", () => { it.each([ - ['DialogOverlay', DialogOverlay], - ['DialogTitle', DialogTitle], + ["DialogOverlay", DialogOverlay], + ["DialogTitle", DialogTitle], ])( - 'should error when we are using a <%s /> without a parent ', + "should error when we are using a <%s /> without a parent ", suppressConsoleLogs((name, Component) => { expect(() => render(Component)).toThrowError( `<${name} /> is missing a parent component.` - ) - expect.hasAssertions() + ); + expect.hasAssertions(); }) - ) + ); it( - 'should be possible to render a Dialog without crashing', + "should be possible to render a Dialog without crashing", suppressConsoleLogs(async () => { - render( - TestRenderer, { + render(TestRenderer, { allProps: [ Dialog, { open: false, onClose: console.log }, [ - [Button, - {}, - "Trigger"], + [Button, {}, "Trigger"], [DialogOverlay], [DialogTitle], [P, {}, "Contents"], - [DialogDescription] - ] - ] - }) + [DialogDescription], + ], + ], + }); - assertDialog({ state: DialogState.InvisibleUnmounted }) + assertDialog({ state: DialogState.InvisibleUnmounted }); }) - ) -}) + ); +}); -describe('Rendering', () => { - describe('Dialog', () => { +describe("Rendering", () => { + describe("Dialog", () => { it( - 'should complain when the `open` and `onClose` prop are missing', - suppressConsoleLogs(async () => { - expect(() => render(Dialog, { as: "div" })).toThrowErrorMatchingInlineSnapshot( - `"You forgot to provide an \`open\` prop to the \`Dialog\` component."` - ) - expect.hasAssertions() - }) - ) - - it( - 'should complain when an `open` prop is not a boolean', + "should complain when the `open` and `onClose` prop are missing", suppressConsoleLogs(async () => { expect(() => - render( - TestRenderer, { - allProps: [ - Dialog, - { open: null, onClose: console.log, as: "div" }, - ] + render(Dialog, { as: "div" }) + ).toThrowErrorMatchingInlineSnapshot( + `"You forgot to provide an \`open\` prop to the \`Dialog\` component."` + ); + expect.hasAssertions(); + }) + ); + + it( + "should complain when an `open` prop is not a boolean", + suppressConsoleLogs(async () => { + expect(() => + render(TestRenderer, { + allProps: [Dialog, { open: null, onClose: console.log, as: "div" }], }) ).toThrowErrorMatchingInlineSnapshot( `"You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: null"` - ) - expect.hasAssertions() + ); + expect.hasAssertions(); }) - ) + ); // TODO: render prop tests! diff --git a/src/lib/components/dialog/index.ts b/src/lib/components/dialog/index.ts index 1495359..3b816f2 100644 --- a/src/lib/components/dialog/index.ts +++ b/src/lib/components/dialog/index.ts @@ -2,4 +2,3 @@ export { default as Dialog } from "./Dialog.svelte"; export { default as DialogTitle } from "./DialogTitle.svelte"; export { default as DialogOverlay } from "./DialogOverlay.svelte"; export { default as DialogDescription } from "./../description/Description.svelte"; - diff --git a/src/lib/components/disclosure/disclosure.test.ts b/src/lib/components/disclosure/disclosure.test.ts index 3f9c007..eb7cc8d 100644 --- a/src/lib/components/disclosure/disclosure.test.ts +++ b/src/lib/components/disclosure/disclosure.test.ts @@ -2,70 +2,76 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from "."; import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs"; import { render } from "@testing-library/svelte"; import TestRenderer from "$lib/test-utils/TestRenderer.svelte"; -import { assertActiveElement, assertDisclosureButton, assertDisclosurePanel, DisclosureState, getByText, getDisclosureButton, getDisclosurePanel } from "$lib/test-utils/accessibility-assertions"; +import { + assertActiveElement, + assertDisclosureButton, + assertDisclosurePanel, + DisclosureState, + getByText, + getDisclosureButton, + getDisclosurePanel, +} from "$lib/test-utils/accessibility-assertions"; import { click, Keys, MouseButton, press } from "$lib/test-utils/interactions"; import { Transition, TransitionChild } from "../transitions"; import TransitionDebug from "./_TransitionDebug.svelte"; let id = 0; -jest.mock('../../hooks/use-id', () => { +jest.mock("../../hooks/use-id", () => { return { useId: jest.fn(() => ++id), - } -}) + }; +}); - -beforeEach(() => id = 0) -afterAll(() => jest.restoreAllMocks()) +beforeEach(() => (id = 0)); +afterAll(() => jest.restoreAllMocks()); function nextFrame() { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { - resolve() - }) - }) - }) + resolve(); + }); + }); + }); } -describe('Safe guards', () => { +describe("Safe guards", () => { it.each([ - ['DisclosureButton', DisclosureButton], - ['DisclosurePanel', DisclosurePanel], + ["DisclosureButton", DisclosureButton], + ["DisclosurePanel", DisclosurePanel], ])( - 'should error when we are using a <%s /> without a parent ', + "should error when we are using a <%s /> without a parent ", suppressConsoleLogs((name, Component) => { expect(() => render(Component)).toThrowError( `<${name} /> is missing a parent component.` - ) + ); }) - ) + ); it( - 'should be possible to render a Disclosure without crashing', + "should be possible to render a Disclosure without crashing", suppressConsoleLogs(async () => { - render( - TestRenderer, { + render(TestRenderer, { allProps: [ Disclosure, {}, [ [DisclosureButton, {}, "Trigger"], [DisclosurePanel, {}, "Contents"], - ] - ] - }) + ], + ], + }); assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted, - attributes: { id: 'headlessui-disclosure-button-1' }, - }) - assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + attributes: { id: "headlessui-disclosure-button-1" }, + }); + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }); }) - ) -}) + ); +}); -describe('Rendering', () => { +describe("Rendering", () => { // describe('Disclosure', () => { // it( // 'should be possible to render a Disclosure using a render prop', @@ -242,7 +248,7 @@ describe('Rendering', () => { // ) // }) - describe('DisclosureButton', () => { + describe("DisclosureButton", () => { // it( // 'should be possible to render a DisclosureButton using a render prop', // suppressConsoleLogs(async () => { diff --git a/src/lib/components/listbox/listbox.test.ts b/src/lib/components/listbox/listbox.test.ts index 811d1af..a25f288 100644 --- a/src/lib/components/listbox/listbox.test.ts +++ b/src/lib/components/listbox/listbox.test.ts @@ -1,9 +1,44 @@ -import { Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions } from "."; +import { + Listbox, + ListboxButton, + ListboxLabel, + ListboxOption, + ListboxOptions, +} from "."; import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs"; import { render } from "@testing-library/svelte"; import TestRenderer from "$lib/test-utils/TestRenderer.svelte"; -import { assertActiveElement, assertActiveListboxOption, assertListbox, assertListboxButton, assertListboxButtonLinkedWithListbox, assertListboxButtonLinkedWithListboxLabel, assertListboxOption, assertNoActiveListboxOption, assertNoSelectedListboxOption, getByText, getListbox, getListboxButton, getListboxButtons, getListboxes, getListboxLabel, getListboxOptions, ListboxState } from "$lib/test-utils/accessibility-assertions"; -import { click, focus, Keys, MouseButton, mouseLeave, mouseMove, press, shift, type, word } from "$lib/test-utils/interactions"; +import { + assertActiveElement, + assertActiveListboxOption, + assertListbox, + assertListboxButton, + assertListboxButtonLinkedWithListbox, + assertListboxButtonLinkedWithListboxLabel, + assertListboxOption, + assertNoActiveListboxOption, + assertNoSelectedListboxOption, + getByText, + getListbox, + getListboxButton, + getListboxButtons, + getListboxes, + getListboxLabel, + getListboxOptions, + ListboxState, +} from "$lib/test-utils/accessibility-assertions"; +import { + click, + focus, + Keys, + MouseButton, + mouseLeave, + mouseMove, + press, + shift, + type, + word, +} from "$lib/test-utils/interactions"; import { Transition } from "../transitions"; import TransitionDebug from "$lib/components/disclosure/_TransitionDebug.svelte"; import ManagedListbox from "./_ManagedListbox.svelte"; @@ -215,9 +250,9 @@ describe('Rendering', () => { // assertListbox({ state: ListboxState.Visible }) // }) // ) - }) + }); - describe('ListboxButton', () => { + describe("ListboxButton", () => { // it( // 'should be possible to render a ListboxButton using a render prop', // suppressConsoleLogs(async () => { diff --git a/src/lib/components/radio-group/index.ts b/src/lib/components/radio-group/index.ts index f6ee723..990cbac 100644 --- a/src/lib/components/radio-group/index.ts +++ b/src/lib/components/radio-group/index.ts @@ -2,4 +2,3 @@ export { default as RadioGroup } from "./RadioGroup.svelte"; export { default as RadioGroupOption } from "./RadioGroupOption.svelte"; export { default as RadioGroupLabel } from "../label/Label.svelte"; export { default as RadioGroupDescription } from "../description/Description.svelte"; - diff --git a/src/lib/components/radio-group/radio-group.test.ts b/src/lib/components/radio-group/radio-group.test.ts index 318c95c..4e5664b 100644 --- a/src/lib/components/radio-group/radio-group.test.ts +++ b/src/lib/components/radio-group/radio-group.test.ts @@ -1,4 +1,11 @@ -import { assertActiveElement, assertFocusable, assertNotFocusable, assertRadioGroupLabel, getByText, getRadioGroupOptions } from "$lib/test-utils/accessibility-assertions"; +import { + assertActiveElement, + assertFocusable, + assertNotFocusable, + assertRadioGroupLabel, + getByText, + getRadioGroupOptions, +} from "$lib/test-utils/accessibility-assertions"; import { render } from "@testing-library/svelte"; import { RadioGroup, RadioGroupLabel, RadioGroupOption } from "."; import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs"; diff --git a/src/lib/components/switch/switch.test.ts b/src/lib/components/switch/switch.test.ts index 2cd4210..29ffbed 100644 --- a/src/lib/components/switch/switch.test.ts +++ b/src/lib/components/switch/switch.test.ts @@ -1,26 +1,29 @@ import { render } from "@testing-library/svelte"; import TestRenderer from "../../test-utils/TestRenderer.svelte"; import { Switch, SwitchDescription, SwitchGroup, SwitchLabel } from "."; -import { assertActiveElement, assertSwitch, getSwitch, getSwitchLabel, SwitchState } from "$lib/test-utils/accessibility-assertions"; +import { + assertActiveElement, + assertSwitch, + getSwitch, + getSwitchLabel, + SwitchState, +} from "$lib/test-utils/accessibility-assertions"; import Button from "$lib/internal/elements/Button.svelte"; import Div from "$lib/internal/elements/Div.svelte"; import Span from "$lib/internal/elements/Span.svelte"; import ManagedSwitch from "./_ManagedSwitch.svelte"; import { click, Keys, press } from "$lib/test-utils/interactions"; -jest.mock('../../hooks/use-id') +jest.mock("../../hooks/use-id"); -describe('Safe guards', () => { - it('should be possible to render a Switch without crashing', () => { +describe("Safe guards", () => { + it("should be possible to render a Switch without crashing", () => { render(TestRenderer, { - allProps: [ - Switch, - { checked: false, onChange: console.log } - ] + allProps: [Switch, { checked: false, onChange: console.log }], }); - }) -}) + }); +}); -describe('Rendering', () => { +describe("Rendering", () => { // TODO: handle these render prop (slot prop) tests // it('should be possible to render an (on) Switch using a render prop', () => { @@ -43,383 +46,334 @@ describe('Rendering', () => { // assertSwitch({ state: SwitchState.Off, textContent: 'Off' }) // }) - it('should be possible to render an (on) Switch using an `as` prop', () => { + it("should be possible to render an (on) Switch using an `as` prop", () => { render(TestRenderer, { - allProps: [ - Switch, - { as: "span", checked: true, onChange: console.log }, - ] + allProps: [Switch, { as: "span", checked: true, onChange: console.log }], }); - assertSwitch({ state: SwitchState.On, tag: 'span' }) - }) + assertSwitch({ state: SwitchState.On, tag: "span" }); + }); - it('should be possible to render an (off) Switch using an `as` prop', () => { + it("should be possible to render an (off) Switch using an `as` prop", () => { render(TestRenderer, { - allProps: [ - Switch, - { as: "span", checked: false, onChange: console.log }, - ] + allProps: [Switch, { as: "span", checked: false, onChange: console.log }], }); - assertSwitch({ state: SwitchState.Off, tag: 'span' }) - }) + assertSwitch({ state: SwitchState.Off, tag: "span" }); + }); - it('should be possible to use the switch contents as the label', () => { + it("should be possible to use the switch contents as the label", () => { render(TestRenderer, { allProps: [ Switch, { checked: false, onChange: console.log }, - [ - Span, - {}, - "Enable notifications" - ] - ] + [Span, {}, "Enable notifications"], + ], }); - assertSwitch({ state: SwitchState.Off, label: 'Enable notifications' }) - }) + assertSwitch({ state: SwitchState.Off, label: "Enable notifications" }); + }); - describe('`type` attribute', () => { + describe("`type` attribute", () => { it('should set the `type` to "button" by default', async () => { render(TestRenderer, { allProps: [ Switch, { checked: false, onChange: console.log }, - "Trigger" - ] + "Trigger", + ], }); - expect(getSwitch()).toHaveAttribute('type', 'button') - }) + 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" - ] + "Trigger", + ], }); - expect(getSwitch()).toHaveAttribute('type', 'submit') - }) + 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" - ] + "Trigger", + ], }); - expect(getSwitch()).not.toHaveAttribute('type') - }) - }) -}) + expect(getSwitch()).not.toHaveAttribute("type"); + }); + }); +}); -describe('Render composition', () => { - it('should be possible to render a Switch.Group, Switch and Switch.Label', () => { +describe("Render composition", () => { + it("should be possible to render a Switch.Group, Switch and Switch.Label", () => { render(TestRenderer, { allProps: [ SwitchGroup, {}, [ - [Switch, - { checked: false, onChange: console.log } - ], - [SwitchLabel, - {}, - "Enable notifications" - ] - ] - ] - }) + [Switch, { checked: false, onChange: console.log }], + [SwitchLabel, {}, "Enable notifications"], + ], + ], + }); - assertSwitch({ state: SwitchState.Off, label: 'Enable notifications' }) - }) + assertSwitch({ state: SwitchState.Off, label: "Enable notifications" }); + }); - it('should be possible to render a Switch.Group, Switch and Switch.Label (before the Switch)', () => { + it("should be possible to render a Switch.Group, Switch and Switch.Label (before the Switch)", () => { render(TestRenderer, { allProps: [ SwitchGroup, {}, [ - [SwitchLabel, - {}, - "Label B"], - [Switch, - { checked: false, onChange: console.log }, - "Label A"] - ] - ] - }) + [SwitchLabel, {}, "Label B"], + [Switch, { checked: false, onChange: console.log }, "Label A"], + ], + ], + }); // Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive // technologies. // // Thus: Label A should not be part of the "label" in this case - assertSwitch({ state: SwitchState.Off, label: 'Label B' }) - }) + assertSwitch({ state: SwitchState.Off, label: "Label B" }); + }); - it('should be possible to render a Switch.Group, Switch and Switch.Label (after the Switch)', () => { + it("should be possible to render a Switch.Group, Switch and Switch.Label (after the Switch)", () => { render(TestRenderer, { allProps: [ SwitchGroup, {}, [ - [Switch, - { checked: false, onChange: console.log }, - "Label A"], - [SwitchLabel, - {}, - "Label B"] - ] - ] - }) + [Switch, { checked: false, onChange: console.log }, "Label A"], + [SwitchLabel, {}, "Label B"], + ], + ], + }); // Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive // technologies. // // Thus: Label A should not be part of the "label" in this case - assertSwitch({ state: SwitchState.Off, label: 'Label B' }) - }) + assertSwitch({ state: SwitchState.Off, label: "Label B" }); + }); - it('should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)', async () => { + it("should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)", async () => { render(TestRenderer, { allProps: [ SwitchGroup, {}, [ - [SwitchDescription, - {}, - "This is an important feature"], - [Switch, - { checked: false, onChange: console.log }], - ] - ] - }) - - assertSwitch({ state: SwitchState.Off, description: 'This is an important feature' }) - }) - - it('should be possible to render a Switch.Group, Switch and Switch.Description (after the Switch)', () => { - render(TestRenderer, { - allProps: [ - SwitchGroup, - {}, - [ - [Switch, - { checked: false, onChange: console.log }], - [SwitchDescription, - {}, - "This is an important feature"], - ] - ] - }) - - assertSwitch({ state: SwitchState.Off, description: 'This is an important feature' }) - }) - - it('should be possible to render a Switch.Group, Switch, Switch.Label and Switch.Description', () => { - render(TestRenderer, { - allProps: [ - SwitchGroup, - {}, - [ - [SwitchLabel, - {}, - "Label A"], - [Switch, - { checked: false, onChange: console.log }], - [SwitchDescription, - {}, - "This is an important feature"], - ] - ] - }) + [SwitchDescription, {}, "This is an important feature"], + [Switch, { checked: false, onChange: console.log }], + ], + ], + }); assertSwitch({ state: SwitchState.Off, - label: 'Label A', - description: 'This is an important feature', - }) - }) -}) + description: "This is an important feature", + }); + }); -describe('Keyboard interactions', () => { - describe('`Space` key', () => { - it('should be possible to toggle the Switch with Space', async () => { + it("should be possible to render a Switch.Group, Switch and Switch.Description (after the Switch)", () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [Switch, { checked: false, onChange: console.log }], + [SwitchDescription, {}, "This is an important feature"], + ], + ], + }); + + assertSwitch({ + state: SwitchState.Off, + description: "This is an important feature", + }); + }); + + it("should be possible to render a Switch.Group, Switch, Switch.Label and Switch.Description", () => { + render(TestRenderer, { + allProps: [ + SwitchGroup, + {}, + [ + [SwitchLabel, {}, "Label A"], + [Switch, { checked: false, onChange: console.log }], + [SwitchDescription, {}, "This is an important feature"], + ], + ], + }); + + assertSwitch({ + state: SwitchState.Off, + label: "Label A", + description: "This is an important feature", + }); + }); +}); + +describe("Keyboard interactions", () => { + describe("`Space` key", () => { + it("should be possible to toggle the Switch with Space", async () => { render(TestRenderer, { - allProps: [ - ManagedSwitch, - {}, - ] - }) + allProps: [ManagedSwitch, {}], + }); // Ensure checkbox is off - assertSwitch({ state: SwitchState.Off }) + assertSwitch({ state: SwitchState.Off }); // Focus the switch - getSwitch()?.focus() + getSwitch()?.focus(); // Toggle - await press(Keys.Space) + await press(Keys.Space); // Ensure state is on - assertSwitch({ state: SwitchState.On }) + assertSwitch({ state: SwitchState.On }); // Toggle - await press(Keys.Space) + await press(Keys.Space); // Ensure state is off - assertSwitch({ state: SwitchState.Off }) - }) - }) + assertSwitch({ state: SwitchState.Off }); + }); + }); - describe('`Enter` key', () => { - it('should not be possible to use Enter to toggle the Switch', async () => { - let handleChange = jest.fn() + describe("`Enter` key", () => { + it("should not be possible to use Enter to toggle the Switch", async () => { + let handleChange = jest.fn(); render(TestRenderer, { - allProps: [ - ManagedSwitch, - { onChange: handleChange }, - ] - }) - + allProps: [ManagedSwitch, { onChange: handleChange }], + }); // Ensure checkbox is off - assertSwitch({ state: SwitchState.Off }) + assertSwitch({ state: SwitchState.Off }); // Focus the switch - getSwitch()?.focus() + getSwitch()?.focus(); // Try to toggle - await press(Keys.Enter) + await press(Keys.Enter); - expect(handleChange).not.toHaveBeenCalled() - }) - }) + expect(handleChange).not.toHaveBeenCalled(); + }); + }); - describe('`Tab` key', () => { - it('should be possible to tab away from the Switch', async () => { + describe("`Tab` key", () => { + it("should be possible to tab away from the Switch", async () => { render(TestRenderer, { allProps: [ Div, {}, [ - [Switch, - { checked: false, onChange: console.log }], - [Button, { id: "btn" }, "Other element"] - ] - ] - }) + [Switch, { checked: false, onChange: console.log }], + [Button, { id: "btn" }, "Other element"], + ], + ], + }); // Ensure checkbox is off - assertSwitch({ state: SwitchState.Off }) + assertSwitch({ state: SwitchState.Off }); // Focus the switch - getSwitch()?.focus() + getSwitch()?.focus(); // Expect the switch to be active - assertActiveElement(getSwitch()) + assertActiveElement(getSwitch()); // Toggle - await press(Keys.Tab) + await press(Keys.Tab); // Expect the button to be active - assertActiveElement(document.getElementById('btn')) - }) - }) -}) + assertActiveElement(document.getElementById("btn")); + }); + }); +}); -describe('Mouse interactions', () => { - it('should be possible to toggle the Switch with a click', async () => { +describe("Mouse interactions", () => { + it("should be possible to toggle the Switch with a click", async () => { render(TestRenderer, { - allProps: [ - ManagedSwitch, - {}, - ] - }) + allProps: [ManagedSwitch, {}], + }); // Ensure checkbox is off - assertSwitch({ state: SwitchState.Off }) + assertSwitch({ state: SwitchState.Off }); // Toggle - await click(getSwitch()) + await click(getSwitch()); // Ensure state is on - assertSwitch({ state: SwitchState.On }) + assertSwitch({ state: SwitchState.On }); // Toggle - await click(getSwitch()) + await click(getSwitch()); // Ensure state is off - assertSwitch({ state: SwitchState.Off }) - }) + assertSwitch({ state: SwitchState.Off }); + }); - it('should be possible to toggle the Switch with a click on the Label', async () => { + it("should be possible to toggle the Switch with a click on the Label", async () => { render(TestRenderer, { allProps: [ SwitchGroup, {}, [ - [ManagedSwitch, - {}, - ], - [SwitchLabel, - {}, - "The label"] - ] - ] - }) - - + [ManagedSwitch, {}], + [SwitchLabel, {}, "The label"], + ], + ], + }); // Ensure checkbox is off - assertSwitch({ state: SwitchState.Off }) + assertSwitch({ state: SwitchState.Off }); // Toggle - await click(getSwitchLabel()) + await click(getSwitchLabel()); // Ensure the switch is focused - assertActiveElement(getSwitch()) + assertActiveElement(getSwitch()); // Ensure state is on - assertSwitch({ state: SwitchState.On }) + assertSwitch({ state: SwitchState.On }); // Toggle - await click(getSwitchLabel()) + await click(getSwitchLabel()); // Ensure the switch is focused - assertActiveElement(getSwitch()) + assertActiveElement(getSwitch()); // Ensure state is off - assertSwitch({ state: SwitchState.Off }) - }) + assertSwitch({ state: SwitchState.Off }); + }); - it('should not be possible to toggle the Switch with a click on the Label (passive)', async () => { + it("should not be possible to toggle the Switch with a click on the Label (passive)", async () => { render(TestRenderer, { allProps: [ SwitchGroup, {}, [ - [ManagedSwitch, - {}, - ], - [SwitchLabel, - { passive: true }, - "The label"] - ] - ] - }) + [ManagedSwitch, {}], + [SwitchLabel, { passive: true }, "The label"], + ], + ], + }); // Ensure checkbox is off - assertSwitch({ state: SwitchState.Off }) + assertSwitch({ state: SwitchState.Off }); // Toggle - await click(getSwitchLabel()) + await click(getSwitchLabel()); // Ensure state is still off - assertSwitch({ state: SwitchState.Off }) - }) -}) + assertSwitch({ state: SwitchState.Off }); + }); +}); diff --git a/src/lib/hooks/use-actions.ts b/src/lib/hooks/use-actions.ts index 4f131e2..d1a5297 100644 --- a/src/lib/hooks/use-actions.ts +++ b/src/lib/hooks/use-actions.ts @@ -52,7 +52,7 @@ export function useActions( return { update(actions: ActionArray) { if (((actions && actions.length) || 0) != actionReturns.length) { - throw new Error('You must not change the length of an actions array.'); + throw new Error("You must not change the length of an actions array."); } if (actions) { diff --git a/src/lib/hooks/use-portal.ts b/src/lib/hooks/use-portal.ts index 1e07b22..ebc32aa 100644 --- a/src/lib/hooks/use-portal.ts +++ b/src/lib/hooks/use-portal.ts @@ -1,4 +1,7 @@ -export function portal(element: HTMLElement, target: HTMLElement | null | undefined) { +export function portal( + element: HTMLElement, + target: HTMLElement | null | undefined +) { if (target) { target.append(element); } diff --git a/src/lib/internal/elements/index.ts b/src/lib/internal/elements/index.ts index 6eae864..6dab624 100644 --- a/src/lib/internal/elements/index.ts +++ b/src/lib/internal/elements/index.ts @@ -40,46 +40,46 @@ import Strong from "./Strong.svelte"; import Ul from "./Ul.svelte"; const components = { - "a": A, - "address": Address, - "article": Article, - "aside": Aside, - "b": B, - "bdi": Bdi, - "bdo": Bdo, - "blockquote": Blockquote, - "button": Button, - "cite": Cite, - "code": Code, - "data": Data, - "datalist": Datalist, - "dd": Dd, - "dl": Dl, - "dt": Dt, - "div": Div, - "em": Em, - "footer": Footer, - "form": Form, - "h1": H1, - "h2": H2, - "h3": H3, - "h4": H4, - "h5": H5, - "h6": H6, - "header": Header, - "i": I, - "input": Input, - "label": Label, - "li": Li, - "main": Main, - "nav": Nav, - "ol": Ol, - "p": P, - "section": Section, - "span": Span, - "strong": Strong, - "ul": Ul, -} + a: A, + address: Address, + article: Article, + aside: Aside, + b: B, + bdi: Bdi, + bdo: Bdo, + blockquote: Blockquote, + button: Button, + cite: Cite, + code: Code, + data: Data, + datalist: Datalist, + dd: Dd, + dl: Dl, + dt: Dt, + div: Div, + em: Em, + footer: Footer, + form: Form, + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + h6: H6, + header: Header, + i: I, + input: Input, + label: Label, + li: Li, + main: Main, + nav: Nav, + ol: Ol, + p: P, + section: Section, + span: Span, + strong: Strong, + ul: Ul, +}; export type SupportedElement = keyof typeof components; export type SupportedAs = SupportedElement | SvelteComponent; diff --git a/src/lib/test-utils/accessibility-assertions.ts b/src/lib/test-utils/accessibility-assertions.ts index ae9404b..9e8d2e2 100644 --- a/src/lib/test-utils/accessibility-assertions.ts +++ b/src/lib/test-utils/accessibility-assertions.ts @@ -1,29 +1,31 @@ -import { isFocusableElement, FocusableMode } from '../utils/focus-management' +import { isFocusableElement, FocusableMode } from "../utils/focus-management"; function assertNever(x: never): never { - throw new Error('Unexpected object: ' + x) + throw new Error("Unexpected object: " + x); } // --- export function getMenuButton(): HTMLElement | null { - return document.querySelector('button,[role="button"],[id^="headlessui-menu-button-"]') + return document.querySelector( + 'button,[role="button"],[id^="headlessui-menu-button-"]' + ); } export function getMenuButtons(): HTMLElement[] { - return Array.from(document.querySelectorAll('button,[role="button"]')) + return Array.from(document.querySelectorAll('button,[role="button"]')); } export function getMenu(): HTMLElement | null { - return document.querySelector('[role="menu"]') + return document.querySelector('[role="menu"]'); } export function getMenus(): HTMLElement[] { - return Array.from(document.querySelectorAll('[role="menu"]')) + return Array.from(document.querySelectorAll('[role="menu"]')); } export function getMenuItems(): HTMLElement[] { - return Array.from(document.querySelectorAll('[role="menuitem"]')) + return Array.from(document.querySelectorAll('[role="menuitem"]')); } // --- @@ -41,150 +43,170 @@ export enum MenuState { export function assertMenuButton( options: { - attributes?: Record - textContent?: string - state: MenuState + attributes?: Record; + textContent?: string; + state: MenuState; }, button = getMenuButton() ) { try { - if (button === null) return expect(button).not.toBe(null) + if (button === null) return expect(button).not.toBe(null); // Ensure menu button have these properties - expect(button).toHaveAttribute('id') - expect(button).toHaveAttribute('aria-haspopup') + 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 + 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') + expect(button).toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; case MenuState.InvisibleUnmounted: - expect(button).not.toHaveAttribute('aria-controls') - if (button.hasAttribute('disabled')) { - expect(button).not.toHaveAttribute('aria-expanded') + expect(button).not.toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; default: - assertNever(options.state) + assertNever(options.state); } if (options.textContent) { - expect(button).toHaveTextContent(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]) + expect(button).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } } catch (err: any) { - Error.captureStackTrace(err, assertMenuButton) - throw err + Error.captureStackTrace(err, assertMenuButton); + throw err; } } -export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = getMenu()) { +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) + 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')) + 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 + Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu); + throw err; } } -export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = getMenu()) { +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) + 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')) + expect(menu).toHaveAttribute( + "aria-activedescendant", + item.getAttribute("id") + ); } catch (err: any) { - Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) - throw err + Error.captureStackTrace(err, assertMenuLinkedWithMenuItem); + throw err; } } export function assertNoActiveMenuItem(menu = getMenu()) { try { - if (menu === null) return expect(menu).not.toBe(null) + if (menu === null) return expect(menu).not.toBe(null); // Ensure we don't have an active menu - expect(menu).not.toHaveAttribute('aria-activedescendant') + expect(menu).not.toHaveAttribute("aria-activedescendant"); } catch (err: any) { - Error.captureStackTrace(err, assertNoActiveMenuItem) - throw err + Error.captureStackTrace(err, assertNoActiveMenuItem); + throw err; } } export function assertMenu( options: { - attributes?: Record - textContent?: string - state: MenuState + attributes?: Record; + textContent?: string; + state: MenuState; }, menu = getMenu() ) { try { switch (options.state) { case MenuState.InvisibleHidden: - if (menu === null) return expect(menu).not.toBe(null) + if (menu === null) return expect(menu).not.toBe(null); - assertHidden(menu) + assertHidden(menu); - expect(menu).toHaveAttribute('aria-labelledby') - expect(menu).toHaveAttribute('role', 'menu') + expect(menu).toHaveAttribute("aria-labelledby"); + expect(menu).toHaveAttribute("role", "menu"); - if (options.textContent) expect(menu).toHaveTextContent(options.textContent) + if (options.textContent) + expect(menu).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(menu).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case MenuState.Visible: - if (menu === null) return expect(menu).not.toBe(null) + if (menu === null) return expect(menu).not.toBe(null); - assertVisible(menu) + assertVisible(menu); - expect(menu).toHaveAttribute('aria-labelledby') - expect(menu).toHaveAttribute('role', 'menu') + expect(menu).toHaveAttribute("aria-labelledby"); + expect(menu).toHaveAttribute("role", "menu"); - if (options.textContent) expect(menu).toHaveTextContent(options.textContent) + if (options.textContent) + expect(menu).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(menu).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case MenuState.InvisibleUnmounted: - expect(menu).toBe(null) - break + expect(menu).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertMenu) - throw err + Error.captureStackTrace(err, assertMenu); + throw err; } } @@ -193,56 +215,62 @@ export function assertMenuItem( options?: { tag?: string; attributes?: Record } ) { try { - if (item === null) return expect(item).not.toBe(null) + 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') + 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') + 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]) + expect(item).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } if (options.tag) { - expect(item.tagName.toLowerCase()).toBe(options.tag) + expect(item.tagName.toLowerCase()).toBe(options.tag); } } } catch (err: any) { - Error.captureStackTrace(err, assertMenuItem) - throw err + Error.captureStackTrace(err, assertMenuItem); + throw err; } } // --- export function getListboxLabel(): HTMLElement | null { - return document.querySelector('label,[id^="headlessui-listbox-label"]') + return document.querySelector('label,[id^="headlessui-listbox-label"]'); } export function getListboxButton(): HTMLElement | null { - return document.querySelector('button,[role="button"],[id^="headlessui-listbox-button-"]') + return document.querySelector( + 'button,[role="button"],[id^="headlessui-listbox-button-"]' + ); } export function getListboxButtons(): HTMLElement[] { - return Array.from(document.querySelectorAll('button,[role="button"]')) + return Array.from(document.querySelectorAll('button,[role="button"]')); } export function getListbox(): HTMLElement | null { - return document.querySelector('[role="listbox"]') + return document.querySelector('[role="listbox"]'); } export function getListboxes(): HTMLElement[] { - return Array.from(document.querySelectorAll('[role="listbox"]')) + return Array.from(document.querySelectorAll('[role="listbox"]')); } export function getListboxOptions(): HTMLElement[] { - return Array.from(document.querySelectorAll('[role="option"]')) + return Array.from(document.querySelectorAll('[role="option"]')); } // --- @@ -260,148 +288,162 @@ export enum ListboxState { export function assertListbox( options: { - attributes?: Record - textContent?: string - state: ListboxState - orientation?: 'horizontal' | 'vertical' + attributes?: Record; + textContent?: string; + state: ListboxState; + orientation?: "horizontal" | "vertical"; }, listbox = getListbox() ) { - let { orientation = 'vertical' } = options + let { orientation = "vertical" } = options; try { switch (options.state) { case ListboxState.InvisibleHidden: - if (listbox === null) return expect(listbox).not.toBe(null) + if (listbox === null) return expect(listbox).not.toBe(null); - assertHidden(listbox) + assertHidden(listbox); - expect(listbox).toHaveAttribute('aria-labelledby') - expect(listbox).toHaveAttribute('aria-orientation', orientation) - expect(listbox).toHaveAttribute('role', '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) + if (options.textContent) + expect(listbox).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(listbox).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case ListboxState.Visible: - if (listbox === null) return expect(listbox).not.toBe(null) + if (listbox === null) return expect(listbox).not.toBe(null); - assertVisible(listbox) + assertVisible(listbox); - expect(listbox).toHaveAttribute('aria-labelledby') - expect(listbox).toHaveAttribute('aria-orientation', orientation) - expect(listbox).toHaveAttribute('role', '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) + if (options.textContent) + expect(listbox).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(listbox).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case ListboxState.InvisibleUnmounted: - expect(listbox).toBe(null) - break + expect(listbox).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertListbox) - throw err + Error.captureStackTrace(err, assertListbox); + throw err; } } export function assertListboxButton( options: { - attributes?: Record - textContent?: string - state: ListboxState + attributes?: Record; + textContent?: string; + state: ListboxState; }, button = getListboxButton() ) { try { - if (button === null) return expect(button).not.toBe(null) + if (button === null) return expect(button).not.toBe(null); // Ensure menu button have these properties - expect(button).toHaveAttribute('id') - expect(button).toHaveAttribute('aria-haspopup') + 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 + 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') + expect(button).toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; case ListboxState.InvisibleUnmounted: - expect(button).not.toHaveAttribute('aria-controls') - if (button.hasAttribute('disabled')) { - expect(button).not.toHaveAttribute('aria-expanded') + expect(button).not.toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; default: - assertNever(options.state) + assertNever(options.state); } if (options.textContent) { - expect(button).toHaveTextContent(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]) + expect(button).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } } catch (err: any) { - Error.captureStackTrace(err, assertListboxButton) - throw err + Error.captureStackTrace(err, assertListboxButton); + throw err; } } export function assertListboxLabel( options: { - attributes?: Record - tag?: string - textContent?: string + attributes?: Record; + tag?: string; + textContent?: string; }, label = getListboxLabel() ) { try { - if (label === null) return expect(label).not.toBe(null) + if (label === null) return expect(label).not.toBe(null); // Ensure menu button have these properties - expect(label).toHaveAttribute('id') + expect(label).toHaveAttribute("id"); if (options.textContent) { - expect(label).toHaveTextContent(options.textContent) + expect(label).toHaveTextContent(options.textContent); } if (options.tag) { - expect(label.tagName.toLowerCase()).toBe(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]) + expect(label).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } } catch (err: any) { - Error.captureStackTrace(err, assertListboxLabel) - throw err + Error.captureStackTrace(err, assertListboxLabel); + throw err; } } @@ -410,15 +452,18 @@ export function assertListboxButtonLinkedWithListbox( listbox = getListbox() ) { try { - if (button === null) return expect(button).not.toBe(null) - if (listbox === null) return expect(listbox).not.toBe(null) + 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')) + 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 + Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox); + throw err; } } @@ -427,13 +472,16 @@ export function assertListboxLabelLinkedWithListbox( listbox = getListbox() ) { try { - if (label === null) return expect(label).not.toBe(null) - if (listbox === null) return expect(listbox).not.toBe(null) + 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')) + expect(listbox).toHaveAttribute( + "aria-labelledby", + label.getAttribute("id") + ); } catch (err: any) { - Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) - throw err + Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox); + throw err; } } @@ -442,107 +490,120 @@ export function assertListboxButtonLinkedWithListboxLabel( label = getListboxLabel() ) { try { - if (button === null) return expect(button).not.toBe(null) - if (label === null) return expect(label).not.toBe(null) + 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}`) + expect(button).toHaveAttribute( + "aria-labelledby", + `${label.id} ${button.id}` + ); } catch (err: any) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) - throw err + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel); + throw err; } } -export function assertActiveListboxOption(item: HTMLElement | null, listbox = getListbox()) { +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) + 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')) + expect(listbox).toHaveAttribute( + "aria-activedescendant", + item.getAttribute("id") + ); } catch (err: any) { - Error.captureStackTrace(err, assertActiveListboxOption) - throw err + Error.captureStackTrace(err, assertActiveListboxOption); + throw err; } } export function assertNoActiveListboxOption(listbox = getListbox()) { try { - if (listbox === null) return expect(listbox).not.toBe(null) + if (listbox === null) return expect(listbox).not.toBe(null); // Ensure we don't have an active listbox - expect(listbox).not.toHaveAttribute('aria-activedescendant') + expect(listbox).not.toHaveAttribute("aria-activedescendant"); } catch (err: any) { - Error.captureStackTrace(err, assertNoActiveListboxOption) - throw err + Error.captureStackTrace(err, assertNoActiveListboxOption); + throw err; } } export function assertNoSelectedListboxOption(items = getListboxOptions()) { try { - for (let item of items) expect(item).not.toHaveAttribute('aria-selected') + for (let item of items) expect(item).not.toHaveAttribute("aria-selected"); } catch (err: any) { - Error.captureStackTrace(err, assertNoSelectedListboxOption) - throw err + Error.captureStackTrace(err, assertNoSelectedListboxOption); + throw err; } } export function assertListboxOption( item: HTMLElement | null, options?: { - tag?: string - attributes?: Record - selected?: boolean + tag?: string; + attributes?: Record; + selected?: boolean; } ) { try { - if (item === null) return expect(item).not.toBe(null) + 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') + 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') + 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 + if (!options) return; for (let attributeName in options.attributes) { - expect(item).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(item).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } if (options.tag) { - expect(item.tagName.toLowerCase()).toBe(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') + return expect(item).toHaveAttribute("aria-selected", "true"); case false: - return expect(item).not.toHaveAttribute('aria-selected') + return expect(item).not.toHaveAttribute("aria-selected"); default: - assertNever(options.selected) + assertNever(options.selected); } } } catch (err: any) { - Error.captureStackTrace(err, assertListboxOption) - throw err + Error.captureStackTrace(err, assertListboxOption); + throw err; } } // --- export function getSwitch(): HTMLElement | null { - return document.querySelector('[role="switch"]') + return document.querySelector('[role="switch"]'); } export function getSwitchLabel(): HTMLElement | null { - return document.querySelector('label,[id^="headlessui-switch-label"]') + return document.querySelector('label,[id^="headlessui-switch-label"]'); } // --- @@ -554,62 +615,62 @@ export enum SwitchState { export function assertSwitch( options: { - state: SwitchState - tag?: string - textContent?: string - label?: string - description?: string + state: SwitchState; + tag?: string; + textContent?: string; + label?: string; + description?: string; }, switchElement = getSwitch() ) { try { - if (switchElement === null) return expect(switchElement).not.toBe(null) + if (switchElement === null) return expect(switchElement).not.toBe(null); - expect(switchElement).toHaveAttribute('role', 'switch') - expect(switchElement).toHaveAttribute('tabindex', '0') + expect(switchElement).toHaveAttribute("role", "switch"); + expect(switchElement).toHaveAttribute("tabindex", "0"); if (options.textContent) { - expect(switchElement).toHaveTextContent(options.textContent) + expect(switchElement).toHaveTextContent(options.textContent); } if (options.tag) { - expect(switchElement.tagName.toLowerCase()).toBe(options.tag) + expect(switchElement.tagName.toLowerCase()).toBe(options.tag); } if (options.label) { - assertLabelValue(switchElement, options.label) + assertLabelValue(switchElement, options.label); } if (options.description) { - assertDescriptionValue(switchElement, options.description) + assertDescriptionValue(switchElement, options.description); } switch (options.state) { case SwitchState.On: - expect(switchElement).toHaveAttribute('aria-checked', 'true') - break + expect(switchElement).toHaveAttribute("aria-checked", "true"); + break; case SwitchState.Off: - expect(switchElement).toHaveAttribute('aria-checked', 'false') - break + expect(switchElement).toHaveAttribute("aria-checked", "false"); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertSwitch) - throw err + Error.captureStackTrace(err, assertSwitch); + throw err; } } // --- export function getDisclosureButton(): HTMLElement | null { - return document.querySelector('[id^="headlessui-disclosure-button-"]') + return document.querySelector('[id^="headlessui-disclosure-button-"]'); } export function getDisclosurePanel(): HTMLElement | null { - return document.querySelector('[id^="headlessui-disclosure-panel-"]') + return document.querySelector('[id^="headlessui-disclosure-panel-"]'); } // --- @@ -629,119 +690,130 @@ export enum DisclosureState { export function assertDisclosureButton( options: { - attributes?: Record - textContent?: string - state: DisclosureState + attributes?: Record; + textContent?: string; + state: DisclosureState; }, button = getDisclosureButton() ) { try { - if (button === null) return expect(button).not.toBe(null) + if (button === null) return expect(button).not.toBe(null); // Ensure disclosure button have these properties - expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute("id"); switch (options.state) { case DisclosureState.Visible: - expect(button).toHaveAttribute('aria-controls') - expect(button).toHaveAttribute('aria-expanded', 'true') - break + 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') + expect(button).toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; case DisclosureState.InvisibleUnmounted: - expect(button).not.toHaveAttribute('aria-controls') - if (button.hasAttribute('disabled')) { - expect(button).not.toHaveAttribute('aria-expanded') + expect(button).not.toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; default: - assertNever(options.state) + assertNever(options.state); } if (options.textContent) { - expect(button).toHaveTextContent(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]) + expect(button).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } } catch (err: any) { - Error.captureStackTrace(err, assertDisclosureButton) - throw err + Error.captureStackTrace(err, assertDisclosureButton); + throw err; } } export function assertDisclosurePanel( options: { - attributes?: Record - textContent?: string - state: DisclosureState + attributes?: Record; + textContent?: string; + state: DisclosureState; }, panel = getDisclosurePanel() ) { try { switch (options.state) { case DisclosureState.InvisibleHidden: - if (panel === null) return expect(panel).not.toBe(null) + if (panel === null) return expect(panel).not.toBe(null); - assertHidden(panel) + assertHidden(panel); - if (options.textContent) expect(panel).toHaveTextContent(options.textContent) + if (options.textContent) + expect(panel).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(panel).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DisclosureState.Visible: - if (panel === null) return expect(panel).not.toBe(null) + if (panel === null) return expect(panel).not.toBe(null); - assertVisible(panel) + assertVisible(panel); - if (options.textContent) expect(panel).toHaveTextContent(options.textContent) + if (options.textContent) + expect(panel).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(panel).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DisclosureState.InvisibleUnmounted: - expect(panel).toBe(null) - break + expect(panel).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertDisclosurePanel) - throw err + Error.captureStackTrace(err, assertDisclosurePanel); + throw err; } } // --- export function getPopoverButton(): HTMLElement | null { - return document.querySelector('[id^="headlessui-popover-button-"]') + return document.querySelector('[id^="headlessui-popover-button-"]'); } export function getPopoverPanel(): HTMLElement | null { - return document.querySelector('[id^="headlessui-popover-panel-"]') + return document.querySelector('[id^="headlessui-popover-panel-"]'); } export function getPopoverOverlay(): HTMLElement | null { - return document.querySelector('[id^="headlessui-popover-overlay-"]') + return document.querySelector('[id^="headlessui-popover-overlay-"]'); } // --- @@ -761,164 +833,187 @@ export enum PopoverState { export function assertPopoverButton( options: { - attributes?: Record - textContent?: string - state: PopoverState + attributes?: Record; + textContent?: string; + state: PopoverState; }, button = getPopoverButton() ) { try { - if (button === null) return expect(button).not.toBe(null) + if (button === null) return expect(button).not.toBe(null); // Ensure popover button have these properties - expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute("id"); switch (options.state) { case PopoverState.Visible: - expect(button).toHaveAttribute('aria-controls') - expect(button).toHaveAttribute('aria-expanded', 'true') - break + 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') + expect(button).toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; case PopoverState.InvisibleUnmounted: - expect(button).not.toHaveAttribute('aria-controls') - if (button.hasAttribute('disabled')) { - expect(button).not.toHaveAttribute('aria-expanded') + expect(button).not.toHaveAttribute("aria-controls"); + if (button.hasAttribute("disabled")) { + expect(button).not.toHaveAttribute("aria-expanded"); } else { - expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute("aria-expanded", "false"); } - break + break; default: - assertNever(options.state) + assertNever(options.state); } if (options.textContent) { - expect(button).toHaveTextContent(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]) + expect(button).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } } catch (err: any) { - Error.captureStackTrace(err, assertPopoverButton) - throw err + Error.captureStackTrace(err, assertPopoverButton); + throw err; } } export function assertPopoverPanel( options: { - attributes?: Record - textContent?: string - state: PopoverState + attributes?: Record; + textContent?: string; + state: PopoverState; }, panel = getPopoverPanel() ) { try { switch (options.state) { case PopoverState.InvisibleHidden: - if (panel === null) return expect(panel).not.toBe(null) + if (panel === null) return expect(panel).not.toBe(null); - assertHidden(panel) + assertHidden(panel); - if (options.textContent) expect(panel).toHaveTextContent(options.textContent) + if (options.textContent) + expect(panel).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(panel).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case PopoverState.Visible: - if (panel === null) return expect(panel).not.toBe(null) + if (panel === null) return expect(panel).not.toBe(null); - assertVisible(panel) + assertVisible(panel); - if (options.textContent) expect(panel).toHaveTextContent(options.textContent) + if (options.textContent) + expect(panel).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(panel).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case PopoverState.InvisibleUnmounted: - expect(panel).toBe(null) - break + expect(panel).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertPopoverPanel) - throw 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 === 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-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("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 + if ( + element.hasAttribute("id") && + document.querySelectorAll(`[for="${element.id}"]`).length > 0 + ) { + expect(document.querySelector(`[for="${element.id}"]`)).toHaveTextContent( + value + ); + return; } - expect(element).toHaveTextContent(value) + expect(element).toHaveTextContent(value); } // --- -export function assertDescriptionValue(element: HTMLElement | null, value: string) { - if (element === null) return expect(element).not.toBe(null) +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) + let id = element.getAttribute("aria-describedby")!; + expect(document.getElementById(id)?.textContent).toEqual(value); } // --- export function getDialog(): HTMLElement | null { - return document.querySelector('[role="dialog"]') + return document.querySelector('[role="dialog"]'); } export function getDialogs(): HTMLElement[] { - return Array.from(document.querySelectorAll('[role="dialog"]')) + return Array.from(document.querySelectorAll('[role="dialog"]')); } export function getDialogTitle(): HTMLElement | null { - return document.querySelector('[id^="headlessui-dialog-title-"]') + return document.querySelector('[id^="headlessui-dialog-title-"]'); } export function getDialogDescription(): HTMLElement | null { - return document.querySelector('[id^="headlessui-description-"]') + return document.querySelector('[id^="headlessui-description-"]'); } export function getDialogOverlay(): HTMLElement | null { - return document.querySelector('[id^="headlessui-dialog-overlay-"]') + return document.querySelector('[id^="headlessui-dialog-overlay-"]'); } export function getDialogOverlays(): HTMLElement[] { - return Array.from(document.querySelectorAll('[id^="headlessui-dialog-overlay-"]')) + return Array.from( + document.querySelectorAll('[id^="headlessui-dialog-overlay-"]') + ); } // --- @@ -938,62 +1033,70 @@ export enum DialogState { export function assertDialog( options: { - attributes?: Record - textContent?: string - state: DialogState + attributes?: Record; + textContent?: string; + state: DialogState; }, dialog = getDialog() ) { try { switch (options.state) { case DialogState.InvisibleHidden: - if (dialog === null) return expect(dialog).not.toBe(null) + if (dialog === null) return expect(dialog).not.toBe(null); - assertHidden(dialog) + assertHidden(dialog); - expect(dialog).toHaveAttribute('role', 'dialog') - expect(dialog).not.toHaveAttribute('aria-modal', 'true') + expect(dialog).toHaveAttribute("role", "dialog"); + expect(dialog).not.toHaveAttribute("aria-modal", "true"); - if (options.textContent) expect(dialog).toHaveTextContent(options.textContent) + if (options.textContent) + expect(dialog).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(dialog).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(dialog).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.Visible: - if (dialog === null) return expect(dialog).not.toBe(null) + if (dialog === null) return expect(dialog).not.toBe(null); - assertVisible(dialog) + assertVisible(dialog); - expect(dialog).toHaveAttribute('role', 'dialog') - expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(dialog).toHaveAttribute("role", "dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); - if (options.textContent) expect(dialog).toHaveTextContent(options.textContent) + if (options.textContent) + expect(dialog).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(dialog).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(dialog).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.InvisibleUnmounted: - expect(dialog).toBe(null) - break + expect(dialog).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertDialog) - throw err + Error.captureStackTrace(err, assertDialog); + throw err; } } export function assertDialogTitle( options: { - attributes?: Record - textContent?: string - state: DialogState + attributes?: Record; + textContent?: string; + state: DialogState; }, title = getDialogTitle(), dialog = getDialog() @@ -1001,55 +1104,63 @@ export function assertDialogTitle( 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) + if (title === null) return expect(title).not.toBe(null); + if (dialog === null) return expect(dialog).not.toBe(null); - assertHidden(title) + assertHidden(title); - expect(title).toHaveAttribute('id') - expect(dialog).toHaveAttribute('aria-labelledby', title.id) + expect(title).toHaveAttribute("id"); + expect(dialog).toHaveAttribute("aria-labelledby", title.id); - if (options.textContent) expect(title).toHaveTextContent(options.textContent) + if (options.textContent) + expect(title).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(title).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(title).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.Visible: - if (title === null) return expect(title).not.toBe(null) - if (dialog === null) return expect(dialog).not.toBe(null) + if (title === null) return expect(title).not.toBe(null); + if (dialog === null) return expect(dialog).not.toBe(null); - assertVisible(title) + assertVisible(title); - expect(title).toHaveAttribute('id') - expect(dialog).toHaveAttribute('aria-labelledby', title.id) + expect(title).toHaveAttribute("id"); + expect(dialog).toHaveAttribute("aria-labelledby", title.id); - if (options.textContent) expect(title).toHaveTextContent(options.textContent) + if (options.textContent) + expect(title).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(title).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(title).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.InvisibleUnmounted: - expect(title).toBe(null) - break + expect(title).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertDialogTitle) - throw err + Error.captureStackTrace(err, assertDialogTitle); + throw err; } } export function assertDialogDescription( options: { - attributes?: Record - textContent?: string - state: DialogState + attributes?: Record; + textContent?: string; + state: DialogState; }, description = getDialogDescription(), dialog = getDialog() @@ -1057,151 +1168,175 @@ export function assertDialogDescription( 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) + if (description === null) return expect(description).not.toBe(null); + if (dialog === null) return expect(dialog).not.toBe(null); - assertHidden(description) + assertHidden(description); - expect(description).toHaveAttribute('id') - expect(dialog).toHaveAttribute('aria-describedby', description.id) + expect(description).toHaveAttribute("id"); + expect(dialog).toHaveAttribute("aria-describedby", description.id); - if (options.textContent) expect(description).toHaveTextContent(options.textContent) + if (options.textContent) + expect(description).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(description).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(description).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.Visible: - if (description === null) return expect(description).not.toBe(null) - if (dialog === null) return expect(dialog).not.toBe(null) + if (description === null) return expect(description).not.toBe(null); + if (dialog === null) return expect(dialog).not.toBe(null); - assertVisible(description) + assertVisible(description); - expect(description).toHaveAttribute('id') - expect(dialog).toHaveAttribute('aria-describedby', description.id) + expect(description).toHaveAttribute("id"); + expect(dialog).toHaveAttribute("aria-describedby", description.id); - if (options.textContent) expect(description).toHaveTextContent(options.textContent) + if (options.textContent) + expect(description).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(description).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(description).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.InvisibleUnmounted: - expect(description).toBe(null) - break + expect(description).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertDialogDescription) - throw err + Error.captureStackTrace(err, assertDialogDescription); + throw err; } } export function assertDialogOverlay( options: { - attributes?: Record - textContent?: string - state: DialogState + attributes?: Record; + textContent?: string; + state: DialogState; }, overlay = getDialogOverlay() ) { try { switch (options.state) { case DialogState.InvisibleHidden: - if (overlay === null) return expect(overlay).not.toBe(null) + if (overlay === null) return expect(overlay).not.toBe(null); - assertHidden(overlay) + assertHidden(overlay); - if (options.textContent) expect(overlay).toHaveTextContent(options.textContent) + if (options.textContent) + expect(overlay).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(overlay).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(overlay).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.Visible: - if (overlay === null) return expect(overlay).not.toBe(null) + if (overlay === null) return expect(overlay).not.toBe(null); - assertVisible(overlay) + assertVisible(overlay); - if (options.textContent) expect(overlay).toHaveTextContent(options.textContent) + if (options.textContent) + expect(overlay).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(overlay).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(overlay).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } - break + break; case DialogState.InvisibleUnmounted: - expect(overlay).toBe(null) - break + expect(overlay).toBe(null); + break; default: - assertNever(options.state) + assertNever(options.state); } } catch (err: any) { - Error.captureStackTrace(err, assertDialogOverlay) - throw err + Error.captureStackTrace(err, assertDialogOverlay); + throw err; } } // --- export function getRadioGroup(): HTMLElement | null { - return document.querySelector('[role="radiogroup"]') + return document.querySelector('[role="radiogroup"]'); } export function getRadioGroupLabel(): HTMLElement | null { - return document.querySelector('[id^="headlessui-label-"]') + return document.querySelector('[id^="headlessui-label-"]'); } export function getRadioGroupOptions(): HTMLElement[] { - return Array.from(document.querySelectorAll('[id^="headlessui-radiogroup-option-"]')) + return Array.from( + document.querySelectorAll('[id^="headlessui-radiogroup-option-"]') + ); } // --- export function assertRadioGroupLabel( options: { - attributes?: Record - textContent?: string + 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) + 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) + expect(label).toHaveAttribute("id"); + expect(radioGroup).toHaveAttribute("aria-labelledby", label.id); - if (options.textContent) expect(label).toHaveTextContent(options.textContent) + if (options.textContent) + expect(label).toHaveTextContent(options.textContent); for (let attributeName in options.attributes) { - expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) + expect(label).toHaveAttribute( + attributeName, + options.attributes[attributeName] + ); } } catch (err: any) { - Error.captureStackTrace(err, assertRadioGroupLabel) - throw err + Error.captureStackTrace(err, assertRadioGroupLabel); + throw err; } } // --- export function getTabList(): HTMLElement | null { - return document.querySelector('[role="tablist"]') + return document.querySelector('[role="tablist"]'); } export function getTabs(): HTMLElement[] { - return Array.from(document.querySelectorAll('[id^="headlessui-tabs-tab-"]')) + return Array.from(document.querySelectorAll('[id^="headlessui-tabs-tab-"]')); } export function getPanels(): HTMLElement[] { - return Array.from(document.querySelectorAll('[id^="headlessui-tabs-panel-"]')) + return Array.from( + document.querySelectorAll('[id^="headlessui-tabs-panel-"]') + ); } // --- @@ -1209,66 +1344,70 @@ export function getPanels(): HTMLElement[] { export function assertTabs( { active, - orientation = 'horizontal', + orientation = "horizontal", }: { - active: number - orientation?: 'vertical' | 'horizontal' + active: number; + orientation?: "vertical" | "horizontal"; }, list = getTabList(), tabs = getTabs(), panels = getPanels() ) { try { - if (list === null) return expect(list).not.toBe(null) + if (list === null) return expect(list).not.toBe(null); - expect(list).toHaveAttribute('role', 'tablist') - expect(list).toHaveAttribute('aria-orientation', orientation) + 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) + 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') + 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') + expect(tab).toHaveAttribute("aria-selected", "true"); + expect(tab).toHaveAttribute("tabindex", "0"); } else { - expect(tab).toHaveAttribute('aria-selected', 'false') - expect(tab).toHaveAttribute('tabindex', '-1') + 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) + 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) + 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') + expect(panel).toHaveAttribute("id"); + expect(panel).toHaveAttribute("role", "tabpanel"); - let controlledById = panel.getAttribute('aria-labelledby')! - let tab = document.getElementById(controlledById) + let controlledById = panel.getAttribute("aria-labelledby")!; + let tab = document.getElementById(controlledById); - expect(tabs).toContain(tab) - expect(tab).toHaveAttribute('aria-controls', panel.id) + expect(tabs).toContain(tab); + expect(tab).toHaveAttribute("aria-controls", panel.id); if (panel === activePanel) { - expect(panel).toHaveAttribute('tabindex', '0') + expect(panel).toHaveAttribute("tabindex", "0"); } else { - expect(panel).toHaveAttribute('tabindex', '-1') + expect(panel).toHaveAttribute("tabindex", "-1"); } } } catch (err: any) { - Error.captureStackTrace(err, assertTabs) - throw err + Error.captureStackTrace(err, assertTabs); + throw err; } } @@ -1276,29 +1415,29 @@ export function assertTabs( export function assertActiveElement(element: HTMLElement | null) { try { - if (element === null) return expect(element).not.toBe(null) + 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) + expect(document.activeElement).toBe(element); } catch (err: any) { - expect(document.activeElement?.outerHTML).toBe(element.outerHTML) + expect(document.activeElement?.outerHTML).toBe(element.outerHTML); } } catch (err: any) { - Error.captureStackTrace(err, assertActiveElement) - throw 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) + 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 + Error.captureStackTrace(err, assertContainsActiveElement); + throw err; } } @@ -1306,25 +1445,25 @@ export function assertContainsActiveElement(element: HTMLElement | null) { export function assertHidden(element: HTMLElement | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - expect(element).toHaveAttribute('hidden') - expect(element).toHaveStyle({ display: 'none' }) + expect(element).toHaveAttribute("hidden"); + expect(element).toHaveStyle({ display: "none" }); } catch (err: any) { - Error.captureStackTrace(err, assertHidden) - throw err + Error.captureStackTrace(err, assertHidden); + throw err; } } export function assertVisible(element: HTMLElement | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - expect(element).not.toHaveAttribute('hidden') - expect(element).not.toHaveStyle({ display: 'none' }) + expect(element).not.toHaveAttribute("hidden"); + expect(element).not.toHaveStyle({ display: "none" }); } catch (err: any) { - Error.captureStackTrace(err, assertVisible) - throw err + Error.captureStackTrace(err, assertVisible); + throw err; } } @@ -1332,39 +1471,44 @@ export function assertVisible(element: HTMLElement | null) { export function assertFocusable(element: HTMLElement | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true) + expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true); } catch (err: any) { - Error.captureStackTrace(err, assertFocusable) - throw err + Error.captureStackTrace(err, assertFocusable); + throw err; } } export function assertNotFocusable(element: HTMLElement | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false) + expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false); } catch (err: any) { - Error.captureStackTrace(err, assertNotFocusable) - throw err + 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 - }, - }) + 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 + if (walker.currentNode.textContent === text) + return walker.currentNode as HTMLElement; } - return null + return null; } diff --git a/src/lib/test-utils/interactions.ts b/src/lib/test-utils/interactions.ts index 72b5f1b..b2ad9e1 100644 --- a/src/lib/test-utils/interactions.ts +++ b/src/lib/test-utils/interactions.ts @@ -1,59 +1,62 @@ import { tick } from "svelte"; -import { fireEvent } from '@testing-library/svelte' +import { fireEvent } from "@testing-library/svelte"; export let Keys: Record> = { - Space: { key: ' ', keyCode: 32, charCode: 32 }, - Enter: { key: 'Enter', keyCode: 13, charCode: 13 }, - Escape: { key: 'Escape', keyCode: 27, charCode: 27 }, - Backspace: { key: 'Backspace', keyCode: 8 }, + Space: { key: " ", keyCode: 32, charCode: 32 }, + Enter: { key: "Enter", keyCode: 13, charCode: 13 }, + Escape: { key: "Escape", keyCode: 27, charCode: 27 }, + Backspace: { key: "Backspace", keyCode: 8 }, - ArrowLeft: { key: 'ArrowLeft', keyCode: 37 }, - ArrowUp: { key: 'ArrowUp', keyCode: 38 }, - ArrowRight: { key: 'ArrowRight', keyCode: 39 }, - ArrowDown: { key: 'ArrowDown', keyCode: 40 }, + ArrowLeft: { key: "ArrowLeft", keyCode: 37 }, + ArrowUp: { key: "ArrowUp", keyCode: 38 }, + ArrowRight: { key: "ArrowRight", keyCode: 39 }, + ArrowDown: { key: "ArrowDown", keyCode: 40 }, - Home: { key: 'Home', keyCode: 36 }, - End: { key: 'End', keyCode: 35 }, + Home: { key: "Home", keyCode: 36 }, + End: { key: "End", keyCode: 35 }, - PageUp: { key: 'PageUp', keyCode: 33 }, - PageDown: { key: 'PageDown', keyCode: 34 }, + PageUp: { key: "PageUp", keyCode: 33 }, + PageDown: { key: "PageDown", keyCode: 34 }, - Tab: { key: 'Tab', keyCode: 9, charCode: 9 }, -} + Tab: { key: "Tab", keyCode: 9, charCode: 9 }, +}; export function shift(event: Partial) { - return { ...event, shiftKey: true } + return { ...event, shiftKey: true }; } export function word(input: string): Partial[] { - return input.split('').map(key => ({ key })) + return input.split("").map((key) => ({ key })); } -let Default = Symbol() -let Ignore = Symbol() +let Default = Symbol(); +let Ignore = Symbol(); -let cancellations: Record>> = { +let cancellations: Record< + string | typeof Default, + Record> +> = { [Default]: { - keydown: new Set(['keypress']), + keydown: new Set(["keypress"]), keypress: new Set([]), keyup: new Set([]), }, [Keys.Enter.key!]: { - keydown: new Set(['keypress', 'click']), - keypress: new Set(['click']), + keydown: new Set(["keypress", "click"]), + keypress: new Set(["click"]), keyup: new Set([]), }, [Keys.Space.key!]: { - keydown: new Set(['keypress', 'click']), + keydown: new Set(["keypress", "click"]), keypress: new Set([]), - keyup: new Set(['click']), + keyup: new Set(["click"]), }, [Keys.Tab.key!]: { - keydown: new Set(['keypress', 'blur', 'focus']), + keydown: new Set(["keypress", "blur", "focus"]), keypress: new Set([]), keyup: new Set([]), }, -} +}; let order: Record< string | typeof Default, @@ -64,105 +67,115 @@ let order: Record< > = { [Default]: [ async function keydown(element, event) { - return await fireEvent.keyDown(element, event) + return await fireEvent.keyDown(element, event); }, async function keypress(element, event) { - return await fireEvent.keyPress(element, event) + return await fireEvent.keyPress(element, event); }, async function keyup(element, event) { - return await fireEvent.keyUp(element, event) + return await fireEvent.keyUp(element, event); }, ], [Keys.Enter.key!]: [ async function keydown(element, event) { - return await fireEvent.keyDown(element, event) + return await fireEvent.keyDown(element, event); }, async function keypress(element, event) { - return await fireEvent.keyPress(element, event) + return await fireEvent.keyPress(element, event); }, async function click(element, event) { - if (element instanceof HTMLButtonElement) return await fireEvent.click(element, event) - return Ignore + if (element instanceof HTMLButtonElement) + return await fireEvent.click(element, event); + return Ignore; }, async function keyup(element, event) { - return await fireEvent.keyUp(element, event) + return await fireEvent.keyUp(element, event); }, ], [Keys.Space.key!]: [ async function keydown(element, event) { - return await fireEvent.keyDown(element, event) + return await fireEvent.keyDown(element, event); }, async function keypress(element, event) { - return await fireEvent.keyPress(element, event) + return await fireEvent.keyPress(element, event); }, async function keyup(element, event) { - return await fireEvent.keyUp(element, event) + return await fireEvent.keyUp(element, event); }, async function click(element, event) { - if (element instanceof HTMLButtonElement) return await fireEvent.click(element, event) - return Ignore + if (element instanceof HTMLButtonElement) + return await fireEvent.click(element, event); + return Ignore; }, ], [Keys.Tab.key!]: [ async function keydown(element, event) { - return await fireEvent.keyDown(element, event) + return await fireEvent.keyDown(element, event); }, async function blurAndfocus(_element, event) { - return focusNext(event) + return focusNext(event); }, async function keyup(element, event) { - return await fireEvent.keyUp(element, event) + return await fireEvent.keyUp(element, event); }, ], -} +}; -export async function type(events: Partial[], element = document.activeElement) { - jest.useFakeTimers() +export async function type( + events: Partial[], + element = document.activeElement +) { + jest.useFakeTimers(); try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); for (let event of events) { - let skip = new Set() - let actions = order[event.key!] ?? order[Default as any] + let skip = new Set(); + let actions = order[event.key!] ?? order[Default as any]; for (let action of actions) { - let checks = action.name.split('And') - if (checks.some(check => skip.has(check))) continue + let checks = action.name.split("And"); + if (checks.some((check) => skip.has(check))) continue; let result: boolean | typeof Ignore | Element = await action(element, { type: action.name, - charCode: event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined, + charCode: + event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined, ...event, - }) - if (result === Ignore) continue + }); + if (result === Ignore) continue; if (result instanceof Element) { - element = result + element = result; } - let cancelled = !result + let cancelled = !result; if (cancelled) { - let skippablesForKey = cancellations[event.key!] ?? cancellations[Default as any] - let skippables = skippablesForKey?.[action.name] ?? new Set() + let skippablesForKey = + cancellations[event.key!] ?? cancellations[Default as any]; + let skippables = skippablesForKey?.[action.name] ?? new Set(); - for (let skippable of skippables) skip.add(skippable) + for (let skippable of skippables) skip.add(skippable); } } } // We don't want to actually wait in our tests, so let's advance - jest.runAllTimers() + jest.runAllTimers(); - await tick() + await tick(); } catch (err: any) { - Error.captureStackTrace(err, type) - throw err + Error.captureStackTrace(err, type); + throw err; } finally { - jest.useRealTimers() + jest.useRealTimers(); } } -export async function press(event: Partial, element = document.activeElement) { - return type([event], element) +export async function press( + event: Partial, + element = document.activeElement +) { + return type([event], element); } export enum MouseButton { @@ -175,153 +188,158 @@ export async function click( button = MouseButton.Left ) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - let options = { button } + let options = { button }; if (button === MouseButton.Left) { // Cancel in pointerDown cancels mouseDown, mouseUp - let cancelled = !(await fireEvent.pointerDown(element, options)) + let cancelled = !(await fireEvent.pointerDown(element, options)); if (!cancelled) { - await fireEvent.mouseDown(element, options) + await fireEvent.mouseDown(element, options); } // Ensure to trigger a `focus` event if the element is focusable, or within a focusable element - let next: HTMLElement | null = element as HTMLElement | null + let next: HTMLElement | null = element as HTMLElement | null; while (next !== null) { if (next.matches(focusableSelector)) { - next.focus() - break + next.focus(); + break; } - next = next.parentElement + next = next.parentElement; } - await fireEvent.pointerUp(element, options) + await fireEvent.pointerUp(element, options); if (!cancelled) { - await fireEvent.mouseUp(element, options) + await fireEvent.mouseUp(element, options); } - await fireEvent.click(element, options) + await fireEvent.click(element, options); } else if (button === MouseButton.Right) { // Cancel in pointerDown cancels mouseDown, mouseUp - let cancelled = !(await fireEvent.pointerDown(element, options)) + let cancelled = !(await fireEvent.pointerDown(element, options)); if (!cancelled) { - await fireEvent.mouseDown(element, options) + await fireEvent.mouseDown(element, options); } // Only in Firefox: - await fireEvent.pointerUp(element, options) + await fireEvent.pointerUp(element, options); if (!cancelled) { - await fireEvent.mouseUp(element, options) + await fireEvent.mouseUp(element, options); } } - } catch (err: any) { - Error.captureStackTrace(err, click) - throw err + Error.captureStackTrace(err, click); + throw err; } } export async function focus(element: Document | Element | Window | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - await fireEvent.focus(element) + await fireEvent.focus(element); - await tick() + await tick(); } catch (err: any) { - Error.captureStackTrace(err, focus) - throw err + Error.captureStackTrace(err, focus); + throw err; } } export async function mouseEnter(element: Document | Element | Window | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - await fireEvent.pointerOver(element) - await fireEvent.pointerEnter(element) - await fireEvent.mouseOver(element) + await fireEvent.pointerOver(element); + await fireEvent.pointerEnter(element); + await fireEvent.mouseOver(element); - await tick() + await tick(); } catch (err: any) { - Error.captureStackTrace(err, mouseEnter) - throw err + Error.captureStackTrace(err, mouseEnter); + throw err; } } export async function mouseMove(element: Document | Element | Window | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - await fireEvent.pointerMove(element) - await fireEvent.mouseMove(element) + await fireEvent.pointerMove(element); + await fireEvent.mouseMove(element); - await tick() + await tick(); } catch (err: any) { - Error.captureStackTrace(err, mouseMove) - throw err + Error.captureStackTrace(err, mouseMove); + throw err; } } export async function mouseLeave(element: Document | Element | Window | null) { try { - if (element === null) return expect(element).not.toBe(null) + if (element === null) return expect(element).not.toBe(null); - await fireEvent.pointerOut(element) - await fireEvent.pointerLeave(element) - await fireEvent.mouseOut(element) - await fireEvent.mouseLeave(element) + await fireEvent.pointerOut(element); + await fireEvent.pointerLeave(element); + await fireEvent.mouseOut(element); + await fireEvent.mouseLeave(element); - await tick() + await tick(); } catch (err: any) { - Error.captureStackTrace(err, mouseLeave) - throw err + Error.captureStackTrace(err, mouseLeave); + throw err; } } // --- function focusNext(event: Partial) { - let direction = event.shiftKey ? -1 : +1 - let focusableElements = getFocusableElements() - let total = focusableElements.length + let direction = event.shiftKey ? -1 : +1; + let focusableElements = getFocusableElements(); + let total = focusableElements.length; function innerFocusNext(offset = 0): Element { - let currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement) - let next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement + let currentIdx = focusableElements.indexOf( + document.activeElement as HTMLElement + ); + let next = focusableElements[ + (currentIdx + total + direction + offset) % total + ] as HTMLElement; - if (next) next?.focus({ preventScroll: true }) + if (next) next?.focus({ preventScroll: true }); - if (next !== document.activeElement) return innerFocusNext(offset + direction) - return next + if (next !== document.activeElement) + return innerFocusNext(offset + direction); + return next; } - return innerFocusNext() + return innerFocusNext(); } // Credit: // - https://stackoverflow.com/a/30753870 let focusableSelector = [ - '[contentEditable=true]', - '[tabindex]', - 'a[href]', - 'area[href]', - 'button:not([disabled])', - 'iframe', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', + "[contentEditable=true]", + "[tabindex]", + "a[href]", + "area[href]", + "button:not([disabled])", + "iframe", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", ] .map( - process.env.NODE_ENV === 'test' + process.env.NODE_ENV === "test" ? // TODO: Remove this once JSDOM fixes the issue where an element that is - // "hidden" can be the document.activeElement, because this is not possible - // in real browsers. - selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` - : selector => `${selector}:not([tabindex='-1'])` + // "hidden" can be the document.activeElement, because this is not possible + // in real browsers. + (selector) => + `${selector}:not([tabindex='-1']):not([style*='display: none'])` + : (selector) => `${selector}:not([tabindex='-1'])` ) - .join(',') + .join(","); function getFocusableElements(container = document.body) { - if (!container) return [] - return Array.from(container.querySelectorAll(focusableSelector)) + if (!container) return []; + return Array.from(container.querySelectorAll(focusableSelector)); } diff --git a/src/lib/test-utils/suppress-console-logs.ts b/src/lib/test-utils/suppress-console-logs.ts index da8e6d1..33440f3 100644 --- a/src/lib/test-utils/suppress-console-logs.ts +++ b/src/lib/test-utils/suppress-console-logs.ts @@ -1,17 +1,17 @@ type FunctionPropertyNames = { - [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; }[keyof T] & - string + string; export function suppressConsoleLogs( cb: (...args: T) => unknown, - type: FunctionPropertyNames = 'error' + type: FunctionPropertyNames = "error" ) { return (...args: T) => { - let spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) + let spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()); return new Promise((resolve, reject) => { - Promise.resolve(cb(...args)).then(resolve, reject) - }).finally(() => spy.mockRestore()) - } + Promise.resolve(cb(...args)).then(resolve, reject); + }).finally(() => spy.mockRestore()); + }; } diff --git a/src/lib/utils/resolve-button-type.ts b/src/lib/utils/resolve-button-type.ts index 51d3c5d..a1037ee 100644 --- a/src/lib/utils/resolve-button-type.ts +++ b/src/lib/utils/resolve-button-type.ts @@ -1,8 +1,12 @@ import type { SupportedAs } from "$lib/internal/elements"; -export function resolveButtonType(props: { type?: string, as?: SupportedAs }, ref: HTMLElement | null | undefined): string | undefined { +export function resolveButtonType( + props: { type?: string; as?: SupportedAs }, + ref: HTMLElement | null | undefined +): string | undefined { if (props.type) return props.type; let tag = props.as ?? "button"; - if (typeof tag === "string" && tag.toLowerCase() === "button") return "button"; + if (typeof tag === "string" && tag.toLowerCase() === "button") + return "button"; if (ref instanceof HTMLButtonElement) return "button"; return undefined; } diff --git a/src/lib/utils/transition.ts b/src/lib/utils/transition.ts index fc51bbd..4559e33 100644 --- a/src/lib/utils/transition.ts +++ b/src/lib/utils/transition.ts @@ -66,7 +66,7 @@ export function transition( done?: (reason: Reason) => void ) { let d = disposables(); - let _done = done !== undefined ? once(done) : () => { }; + let _done = done !== undefined ? once(done) : () => {}; removeClasses(node, ...entered); addClasses(node, ...base, ...from); diff --git a/src/routes/popover/_Link.svelte b/src/routes/popover/_Link.svelte index 28b9fd7..89964c4 100644 --- a/src/routes/popover/_Link.svelte +++ b/src/routes/popover/_Link.svelte @@ -5,4 +5,3 @@ > - diff --git a/svelte.config.js b/svelte.config.js index 1ed1185..78457d1 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -19,7 +19,7 @@ const config = { }, files: (filepath) => { return !filepath.endsWith(".test.ts"); - } + }, }, // hydrate the
element in src/app.html