Didn't run this on every single file because it messes up the formatting of the some of the TestRenderer constructions
This commit is contained in:
Ryan Gossiaux
2021-12-28 09:23:42 -10:00
parent 23a98b50ed
commit c99b74c089
21 changed files with 1212 additions and 1033 deletions

View File

@@ -2,9 +2,9 @@ name: Jest
on: on:
push: push:
branches: [ master ] branches: [master]
pull_request: pull_request:
branches: [ master ] branches: [master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@@ -6,8 +6,8 @@ This is an unofficial, complete Svelte port of the Headless UI component library
This library is for you if you fall into one of two categories: 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 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 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.
@@ -22,8 +22,10 @@ npm install @rgossiaux/svelte-headlessui
## Usage ## 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: 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 // React version
<Listbox onChange={(value) => console.log(value)}> <Listbox onChange={(value) => 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. 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 // React version
<Listbox.Button> <Listbox.Button>
@@ -50,8 +53,10 @@ Note the `.detail` that is needed to get the event value in the Svelte version.
<!--- Something using open and disabled ---> <!--- Something using open and disabled --->
</ListboxButton> </ListboxButton>
``` ```
* 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 // React version
{people.map((person) => ( {people.map((person) => (
@@ -64,12 +69,15 @@ Note the `.detail` that is needed to get the event value in the Svelte version.
<ListboxOption <ListboxOption
... ...
``` ```
Similarly, React's `{value && (<Component />)}` style syntax becomes `{#if value} <Component /> {/if}` in Svelte, of course. Similarly, React's `{value && (<Component />)}` style syntax becomes `{#if value} <Component /> {/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`.
- 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 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) => ...}` - 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 ## Credits

View File

@@ -1,3 +1,3 @@
module.exports = { module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]] presets: [["@babel/preset-env", { targets: { node: "current" } }]],
} };

View File

@@ -1,15 +1,13 @@
module.exports = { module.exports = {
transform: { transform: {
'^.+\\.svelte$': ['svelte-jester', { preprocess: true }], "^.+\\.svelte$": ["svelte-jester", { preprocess: true }],
'^.+\\.js$': 'babel-jest', "^.+\\.js$": "babel-jest",
'^.+\\.ts$': 'ts-jest', "^.+\\.ts$": "ts-jest",
}, },
setupFilesAfterEnv: [ setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"],
'@testing-library/jest-dom/extend-expect',
],
testEnvironment: "jsdom", testEnvironment: "jsdom",
moduleFileExtensions: ['js', 'ts', 'svelte'], moduleFileExtensions: ["js", "ts", "svelte"],
moduleNameMapper: { moduleNameMapper: {
"\\$lib/(.+)$": "<rootDir>/src/lib/$1", "\\$lib/(.+)$": "<rootDir>/src/lib/$1",
}, },
} };

View File

@@ -1,4 +1,4 @@
import { Dialog, DialogDescription, DialogOverlay, DialogTitle } from "." import { Dialog, DialogDescription, DialogOverlay, DialogTitle } from ".";
import TestTabSentinel from "./_TestTabSentinel.svelte"; import TestTabSentinel from "./_TestTabSentinel.svelte";
import ManagedDialog from "./_ManagedDialog.svelte"; import ManagedDialog from "./_ManagedDialog.svelte";
import NestedTestComponent from "./_NestedTestComponent.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 Form from "$lib/internal/elements/Form.svelte";
import P from "$lib/internal/elements/P.svelte"; import P from "$lib/internal/elements/P.svelte";
import Input from "$lib/internal/elements/Input.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 { click, Keys, press } from "$lib/test-utils/interactions";
import Transition from "$lib/components/transitions/TransitionRoot.svelte"; import Transition from "$lib/components/transitions/TransitionRoot.svelte";
import { tick } from "svelte"; import { tick } from "svelte";
let id = 0; let id = 0;
jest.mock('../../hooks/use-id', () => { jest.mock("../../hooks/use-id", () => {
return { return {
useId: jest.fn(() => ++id), useId: jest.fn(() => ++id),
} };
}) });
// @ts-expect-error // @ts-expect-error
global.IntersectionObserver = class FakeIntersectionObserver { global.IntersectionObserver = class FakeIntersectionObserver {
observe() { } observe() {}
disconnect() { } disconnect() {}
} };
beforeEach(() => id = 0) beforeEach(() => (id = 0));
afterAll(() => jest.restoreAllMocks()) afterAll(() => jest.restoreAllMocks());
describe('Safe guards', () => { describe("Safe guards", () => {
it.each([ it.each([
['DialogOverlay', DialogOverlay], ["DialogOverlay", DialogOverlay],
['DialogTitle', DialogTitle], ["DialogTitle", DialogTitle],
])( ])(
'should error when we are using a <%s /> without a parent <Dialog />', "should error when we are using a <%s /> without a parent <Dialog />",
suppressConsoleLogs((name, Component) => { suppressConsoleLogs((name, Component) => {
expect(() => render(Component)).toThrowError( expect(() => render(Component)).toThrowError(
`<${name} /> is missing a parent <Dialog /> component.` `<${name} /> is missing a parent <Dialog /> component.`
) );
expect.hasAssertions() expect.hasAssertions();
}) })
) );
it( it(
'should be possible to render a Dialog without crashing', "should be possible to render a Dialog without crashing",
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
render( render(TestRenderer, {
TestRenderer, {
allProps: [ allProps: [
Dialog, Dialog,
{ open: false, onClose: console.log }, { open: false, onClose: console.log },
[ [
[Button, [Button, {}, "Trigger"],
{},
"Trigger"],
[DialogOverlay], [DialogOverlay],
[DialogTitle], [DialogTitle],
[P, {}, "Contents"], [P, {}, "Contents"],
[DialogDescription] [DialogDescription],
] ],
] ],
}) });
assertDialog({ state: DialogState.InvisibleUnmounted }) assertDialog({ state: DialogState.InvisibleUnmounted });
}) })
) );
}) });
describe('Rendering', () => { describe("Rendering", () => {
describe('Dialog', () => { describe("Dialog", () => {
it( it(
'should complain when the `open` and `onClose` prop are missing', "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',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
expect(() => expect(() =>
render( render(Dialog, { as: "div" })
TestRenderer, { ).toThrowErrorMatchingInlineSnapshot(
allProps: [ `"You forgot to provide an \`open\` prop to the \`Dialog\` component."`
Dialog, );
{ open: null, onClose: console.log, as: "div" }, 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( ).toThrowErrorMatchingInlineSnapshot(
`"You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: null"` `"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! // TODO: render prop tests!

View File

@@ -2,4 +2,3 @@ export { default as Dialog } from "./Dialog.svelte";
export { default as DialogTitle } from "./DialogTitle.svelte"; export { default as DialogTitle } from "./DialogTitle.svelte";
export { default as DialogOverlay } from "./DialogOverlay.svelte"; export { default as DialogOverlay } from "./DialogOverlay.svelte";
export { default as DialogDescription } from "./../description/Description.svelte"; export { default as DialogDescription } from "./../description/Description.svelte";

View File

@@ -2,70 +2,76 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from ".";
import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs"; import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import TestRenderer from "$lib/test-utils/TestRenderer.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 { click, Keys, MouseButton, press } from "$lib/test-utils/interactions";
import { Transition, TransitionChild } from "../transitions"; import { Transition, TransitionChild } from "../transitions";
import TransitionDebug from "./_TransitionDebug.svelte"; import TransitionDebug from "./_TransitionDebug.svelte";
let id = 0; let id = 0;
jest.mock('../../hooks/use-id', () => { jest.mock("../../hooks/use-id", () => {
return { return {
useId: jest.fn(() => ++id), useId: jest.fn(() => ++id),
} };
}) });
beforeEach(() => (id = 0));
beforeEach(() => id = 0) afterAll(() => jest.restoreAllMocks());
afterAll(() => jest.restoreAllMocks())
function nextFrame() { function nextFrame() {
return new Promise<void>(resolve => { return new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
resolve() resolve();
}) });
}) });
}) });
} }
describe('Safe guards', () => { describe("Safe guards", () => {
it.each([ it.each([
['DisclosureButton', DisclosureButton], ["DisclosureButton", DisclosureButton],
['DisclosurePanel', DisclosurePanel], ["DisclosurePanel", DisclosurePanel],
])( ])(
'should error when we are using a <%s /> without a parent <Disclosure />', "should error when we are using a <%s /> without a parent <Disclosure />",
suppressConsoleLogs((name, Component) => { suppressConsoleLogs((name, Component) => {
expect(() => render(Component)).toThrowError( expect(() => render(Component)).toThrowError(
`<${name} /> is missing a parent <Disclosure /> component.` `<${name} /> is missing a parent <Disclosure /> component.`
) );
}) })
) );
it( it(
'should be possible to render a Disclosure without crashing', "should be possible to render a Disclosure without crashing",
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
render( render(TestRenderer, {
TestRenderer, {
allProps: [ allProps: [
Disclosure, Disclosure,
{}, {},
[ [
[DisclosureButton, {}, "Trigger"], [DisclosureButton, {}, "Trigger"],
[DisclosurePanel, {}, "Contents"], [DisclosurePanel, {}, "Contents"],
] ],
] ],
}) });
assertDisclosureButton({ assertDisclosureButton({
state: DisclosureState.InvisibleUnmounted, state: DisclosureState.InvisibleUnmounted,
attributes: { id: 'headlessui-disclosure-button-1' }, attributes: { id: "headlessui-disclosure-button-1" },
});
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted });
}) })
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) );
}) });
)
})
describe('Rendering', () => { describe("Rendering", () => {
// describe('Disclosure', () => { // describe('Disclosure', () => {
// it( // it(
// 'should be possible to render a Disclosure using a render prop', // 'should be possible to render a Disclosure using a render prop',
@@ -242,7 +248,7 @@ describe('Rendering', () => {
// ) // )
// }) // })
describe('DisclosureButton', () => { describe("DisclosureButton", () => {
// it( // it(
// 'should be possible to render a DisclosureButton using a render prop', // 'should be possible to render a DisclosureButton using a render prop',
// suppressConsoleLogs(async () => { // suppressConsoleLogs(async () => {

View File

@@ -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 { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import TestRenderer from "$lib/test-utils/TestRenderer.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 {
import { click, focus, Keys, MouseButton, mouseLeave, mouseMove, press, shift, type, word } from "$lib/test-utils/interactions"; 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 { Transition } from "../transitions";
import TransitionDebug from "$lib/components/disclosure/_TransitionDebug.svelte"; import TransitionDebug from "$lib/components/disclosure/_TransitionDebug.svelte";
import ManagedListbox from "./_ManagedListbox.svelte"; import ManagedListbox from "./_ManagedListbox.svelte";
@@ -215,9 +250,9 @@ describe('Rendering', () => {
// assertListbox({ state: ListboxState.Visible }) // assertListbox({ state: ListboxState.Visible })
// }) // })
// ) // )
}) });
describe('ListboxButton', () => { describe("ListboxButton", () => {
// it( // it(
// 'should be possible to render a ListboxButton using a render prop', // 'should be possible to render a ListboxButton using a render prop',
// suppressConsoleLogs(async () => { // suppressConsoleLogs(async () => {

View File

@@ -2,4 +2,3 @@ export { default as RadioGroup } from "./RadioGroup.svelte";
export { default as RadioGroupOption } from "./RadioGroupOption.svelte"; export { default as RadioGroupOption } from "./RadioGroupOption.svelte";
export { default as RadioGroupLabel } from "../label/Label.svelte"; export { default as RadioGroupLabel } from "../label/Label.svelte";
export { default as RadioGroupDescription } from "../description/Description.svelte"; export { default as RadioGroupDescription } from "../description/Description.svelte";

View File

@@ -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 { render } from "@testing-library/svelte";
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from "."; import { RadioGroup, RadioGroupLabel, RadioGroupOption } from ".";
import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs"; import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs";

View File

@@ -1,26 +1,29 @@
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import TestRenderer from "../../test-utils/TestRenderer.svelte"; import TestRenderer from "../../test-utils/TestRenderer.svelte";
import { Switch, SwitchDescription, SwitchGroup, SwitchLabel } from "."; 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 Button from "$lib/internal/elements/Button.svelte";
import Div from "$lib/internal/elements/Div.svelte"; import Div from "$lib/internal/elements/Div.svelte";
import Span from "$lib/internal/elements/Span.svelte"; import Span from "$lib/internal/elements/Span.svelte";
import ManagedSwitch from "./_ManagedSwitch.svelte"; import ManagedSwitch from "./_ManagedSwitch.svelte";
import { click, Keys, press } from "$lib/test-utils/interactions"; import { click, Keys, press } from "$lib/test-utils/interactions";
jest.mock('../../hooks/use-id') jest.mock("../../hooks/use-id");
describe('Safe guards', () => { describe("Safe guards", () => {
it('should be possible to render a Switch without crashing', () => { it("should be possible to render a Switch without crashing", () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [Switch, { checked: false, onChange: console.log }],
Switch,
{ checked: false, onChange: console.log }
]
}); });
}) });
}) });
describe('Rendering', () => { describe("Rendering", () => {
// TODO: handle these render prop (slot prop) tests // TODO: handle these render prop (slot prop) tests
// it('should be possible to render an (on) Switch using a render prop', () => { // 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' }) // 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, { render(TestRenderer, {
allProps: [ allProps: [Switch, { as: "span", checked: true, onChange: console.log }],
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, { render(TestRenderer, {
allProps: [ allProps: [Switch, { as: "span", checked: false, onChange: console.log }],
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, { render(TestRenderer, {
allProps: [ allProps: [
Switch, Switch,
{ checked: false, onChange: console.log }, { 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 () => { it('should set the `type` to "button" by default', async () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [
Switch, Switch,
{ checked: false, onChange: console.log }, { 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 () => { it('should not set the `type` to "button" if it already contains a `type`', async () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [
Switch, Switch,
{ checked: false, onChange: console.log, type: "submit" }, { 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 () => { it('should not set the type if the "as" prop is not a "button"', async () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [
Switch, Switch,
{ checked: false, onChange: console.log, as: "div" }, { checked: false, onChange: console.log, as: "div" },
"Trigger" "Trigger",
] ],
}); });
expect(getSwitch()).not.toHaveAttribute('type') expect(getSwitch()).not.toHaveAttribute("type");
}) });
}) });
}) });
describe('Render composition', () => { describe("Render composition", () => {
it('should be possible to render a Switch.Group, Switch and Switch.Label', () => { it("should be possible to render a Switch.Group, Switch and Switch.Label", () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [
SwitchGroup, SwitchGroup,
{}, {},
[ [
[Switch, [Switch, { checked: false, onChange: console.log }],
{ checked: false, onChange: console.log } [SwitchLabel, {}, "Enable notifications"],
], ],
[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, { render(TestRenderer, {
allProps: [ allProps: [
SwitchGroup, SwitchGroup,
{}, {},
[ [
[SwitchLabel, [SwitchLabel, {}, "Label B"],
{}, [Switch, { checked: false, onChange: console.log }, "Label A"],
"Label B"], ],
[Switch, ],
{ checked: false, onChange: console.log }, });
"Label A"]
]
]
})
// Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive // Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive
// technologies. // technologies.
// //
// Thus: Label A should not be part of the "label" in this case // 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, { render(TestRenderer, {
allProps: [ allProps: [
SwitchGroup, SwitchGroup,
{}, {},
[ [
[Switch, [Switch, { checked: false, onChange: console.log }, "Label A"],
{ checked: false, onChange: console.log }, [SwitchLabel, {}, "Label B"],
"Label A"], ],
[SwitchLabel, ],
{}, });
"Label B"]
]
]
})
// Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive // Warning! Using aria-label or aria-labelledby will hide any descendant content from assistive
// technologies. // technologies.
// //
// Thus: Label A should not be part of the "label" in this case // 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, { render(TestRenderer, {
allProps: [ allProps: [
SwitchGroup, SwitchGroup,
{}, {},
[ [
[SwitchDescription, [SwitchDescription, {}, "This is an important feature"],
{}, [Switch, { checked: false, onChange: console.log }],
"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"],
]
]
})
assertSwitch({ assertSwitch({
state: SwitchState.Off, state: SwitchState.Off,
label: 'Label A', description: "This is an important feature",
description: 'This is an important feature', });
}) });
})
})
describe('Keyboard interactions', () => { it("should be possible to render a Switch.Group, Switch and Switch.Description (after the Switch)", () => {
describe('`Space` key', () => {
it('should be possible to toggle the Switch with Space', async () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [
ManagedSwitch, 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, {}],
});
// Ensure checkbox is off // Ensure checkbox is off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
// Focus the switch // Focus the switch
getSwitch()?.focus() getSwitch()?.focus();
// Toggle // Toggle
await press(Keys.Space) await press(Keys.Space);
// Ensure state is on // Ensure state is on
assertSwitch({ state: SwitchState.On }) assertSwitch({ state: SwitchState.On });
// Toggle // Toggle
await press(Keys.Space) await press(Keys.Space);
// Ensure state is off // Ensure state is off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
}) });
}) });
describe('`Enter` key', () => { describe("`Enter` key", () => {
it('should not be possible to use Enter to toggle the Switch', async () => { it("should not be possible to use Enter to toggle the Switch", async () => {
let handleChange = jest.fn() let handleChange = jest.fn();
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [ManagedSwitch, { onChange: handleChange }],
ManagedSwitch, });
{ onChange: handleChange },
]
})
// Ensure checkbox is off // Ensure checkbox is off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
// Focus the switch // Focus the switch
getSwitch()?.focus() getSwitch()?.focus();
// Try to toggle // Try to toggle
await press(Keys.Enter) await press(Keys.Enter);
expect(handleChange).not.toHaveBeenCalled() expect(handleChange).not.toHaveBeenCalled();
}) });
}) });
describe('`Tab` key', () => { describe("`Tab` key", () => {
it('should be possible to tab away from the Switch', async () => { it("should be possible to tab away from the Switch", async () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [
Div, Div,
{}, {},
[ [
[Switch, [Switch, { checked: false, onChange: console.log }],
{ checked: false, onChange: console.log }], [Button, { id: "btn" }, "Other element"],
[Button, { id: "btn" }, "Other element"] ],
] ],
] });
})
// Ensure checkbox is off // Ensure checkbox is off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
// Focus the switch // Focus the switch
getSwitch()?.focus() getSwitch()?.focus();
// Expect the switch to be active // Expect the switch to be active
assertActiveElement(getSwitch()) assertActiveElement(getSwitch());
// Toggle // Toggle
await press(Keys.Tab) await press(Keys.Tab);
// Expect the button to be active // Expect the button to be active
assertActiveElement(document.getElementById('btn')) assertActiveElement(document.getElementById("btn"));
}) });
}) });
}) });
describe('Mouse interactions', () => { describe("Mouse interactions", () => {
it('should be possible to toggle the Switch with a click', async () => { it("should be possible to toggle the Switch with a click", async () => {
render(TestRenderer, { render(TestRenderer, {
allProps: [ allProps: [ManagedSwitch, {}],
ManagedSwitch, });
{},
]
})
// Ensure checkbox is off // Ensure checkbox is off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
// Toggle // Toggle
await click(getSwitch()) await click(getSwitch());
// Ensure state is on // Ensure state is on
assertSwitch({ state: SwitchState.On }) assertSwitch({ state: SwitchState.On });
// Toggle // Toggle
await click(getSwitch()) await click(getSwitch());
// Ensure state is off // 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, { render(TestRenderer, {
allProps: [ allProps: [
SwitchGroup, SwitchGroup,
{}, {},
[ [
[ManagedSwitch, [ManagedSwitch, {}],
{}, [SwitchLabel, {}, "The label"],
], ],
[SwitchLabel, ],
{}, });
"The label"]
]
]
})
// Ensure checkbox is off // Ensure checkbox is off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
// Toggle // Toggle
await click(getSwitchLabel()) await click(getSwitchLabel());
// Ensure the switch is focused // Ensure the switch is focused
assertActiveElement(getSwitch()) assertActiveElement(getSwitch());
// Ensure state is on // Ensure state is on
assertSwitch({ state: SwitchState.On }) assertSwitch({ state: SwitchState.On });
// Toggle // Toggle
await click(getSwitchLabel()) await click(getSwitchLabel());
// Ensure the switch is focused // Ensure the switch is focused
assertActiveElement(getSwitch()) assertActiveElement(getSwitch());
// Ensure state is off // 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, { render(TestRenderer, {
allProps: [ allProps: [
SwitchGroup, SwitchGroup,
{}, {},
[ [
[ManagedSwitch, [ManagedSwitch, {}],
{}, [SwitchLabel, { passive: true }, "The label"],
], ],
[SwitchLabel, ],
{ passive: true }, });
"The label"]
]
]
})
// Ensure checkbox is off // Ensure checkbox is off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
// Toggle // Toggle
await click(getSwitchLabel()) await click(getSwitchLabel());
// Ensure state is still off // Ensure state is still off
assertSwitch({ state: SwitchState.Off }) assertSwitch({ state: SwitchState.Off });
}) });
}) });

View File

@@ -52,7 +52,7 @@ export function useActions(
return { return {
update(actions: ActionArray) { update(actions: ActionArray) {
if (((actions && actions.length) || 0) != actionReturns.length) { 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) { if (actions) {

View File

@@ -1,4 +1,7 @@
export function portal(element: HTMLElement, target: HTMLElement | null | undefined) { export function portal(
element: HTMLElement,
target: HTMLElement | null | undefined
) {
if (target) { if (target) {
target.append(element); target.append(element);
} }

View File

@@ -40,46 +40,46 @@ import Strong from "./Strong.svelte";
import Ul from "./Ul.svelte"; import Ul from "./Ul.svelte";
const components = { const components = {
"a": A, a: A,
"address": Address, address: Address,
"article": Article, article: Article,
"aside": Aside, aside: Aside,
"b": B, b: B,
"bdi": Bdi, bdi: Bdi,
"bdo": Bdo, bdo: Bdo,
"blockquote": Blockquote, blockquote: Blockquote,
"button": Button, button: Button,
"cite": Cite, cite: Cite,
"code": Code, code: Code,
"data": Data, data: Data,
"datalist": Datalist, datalist: Datalist,
"dd": Dd, dd: Dd,
"dl": Dl, dl: Dl,
"dt": Dt, dt: Dt,
"div": Div, div: Div,
"em": Em, em: Em,
"footer": Footer, footer: Footer,
"form": Form, form: Form,
"h1": H1, h1: H1,
"h2": H2, h2: H2,
"h3": H3, h3: H3,
"h4": H4, h4: H4,
"h5": H5, h5: H5,
"h6": H6, h6: H6,
"header": Header, header: Header,
"i": I, i: I,
"input": Input, input: Input,
"label": Label, label: Label,
"li": Li, li: Li,
"main": Main, main: Main,
"nav": Nav, nav: Nav,
"ol": Ol, ol: Ol,
"p": P, p: P,
"section": Section, section: Section,
"span": Span, span: Span,
"strong": Strong, strong: Strong,
"ul": Ul, ul: Ul,
} };
export type SupportedElement = keyof typeof components; export type SupportedElement = keyof typeof components;
export type SupportedAs = SupportedElement | SvelteComponent; export type SupportedAs = SupportedElement | SvelteComponent;

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +1,62 @@
import { tick } from "svelte"; import { tick } from "svelte";
import { fireEvent } from '@testing-library/svelte' import { fireEvent } from "@testing-library/svelte";
export let Keys: Record<string, Partial<KeyboardEvent>> = { export let Keys: Record<string, Partial<KeyboardEvent>> = {
Space: { key: ' ', keyCode: 32, charCode: 32 }, Space: { key: " ", keyCode: 32, charCode: 32 },
Enter: { key: 'Enter', keyCode: 13, charCode: 13 }, Enter: { key: "Enter", keyCode: 13, charCode: 13 },
Escape: { key: 'Escape', keyCode: 27, charCode: 27 }, Escape: { key: "Escape", keyCode: 27, charCode: 27 },
Backspace: { key: 'Backspace', keyCode: 8 }, Backspace: { key: "Backspace", keyCode: 8 },
ArrowLeft: { key: 'ArrowLeft', keyCode: 37 }, ArrowLeft: { key: "ArrowLeft", keyCode: 37 },
ArrowUp: { key: 'ArrowUp', keyCode: 38 }, ArrowUp: { key: "ArrowUp", keyCode: 38 },
ArrowRight: { key: 'ArrowRight', keyCode: 39 }, ArrowRight: { key: "ArrowRight", keyCode: 39 },
ArrowDown: { key: 'ArrowDown', keyCode: 40 }, ArrowDown: { key: "ArrowDown", keyCode: 40 },
Home: { key: 'Home', keyCode: 36 }, Home: { key: "Home", keyCode: 36 },
End: { key: 'End', keyCode: 35 }, End: { key: "End", keyCode: 35 },
PageUp: { key: 'PageUp', keyCode: 33 }, PageUp: { key: "PageUp", keyCode: 33 },
PageDown: { key: 'PageDown', keyCode: 34 }, PageDown: { key: "PageDown", keyCode: 34 },
Tab: { key: 'Tab', keyCode: 9, charCode: 9 }, Tab: { key: "Tab", keyCode: 9, charCode: 9 },
} };
export function shift(event: Partial<KeyboardEvent>) { export function shift(event: Partial<KeyboardEvent>) {
return { ...event, shiftKey: true } return { ...event, shiftKey: true };
} }
export function word(input: string): Partial<KeyboardEvent>[] { export function word(input: string): Partial<KeyboardEvent>[] {
return input.split('').map(key => ({ key })) return input.split("").map((key) => ({ key }));
} }
let Default = Symbol() let Default = Symbol();
let Ignore = Symbol() let Ignore = Symbol();
let cancellations: Record<string | typeof Default, Record<string, Set<string>>> = { let cancellations: Record<
string | typeof Default,
Record<string, Set<string>>
> = {
[Default]: { [Default]: {
keydown: new Set(['keypress']), keydown: new Set(["keypress"]),
keypress: new Set([]), keypress: new Set([]),
keyup: new Set([]), keyup: new Set([]),
}, },
[Keys.Enter.key!]: { [Keys.Enter.key!]: {
keydown: new Set(['keypress', 'click']), keydown: new Set(["keypress", "click"]),
keypress: new Set(['click']), keypress: new Set(["click"]),
keyup: new Set([]), keyup: new Set([]),
}, },
[Keys.Space.key!]: { [Keys.Space.key!]: {
keydown: new Set(['keypress', 'click']), keydown: new Set(["keypress", "click"]),
keypress: new Set([]), keypress: new Set([]),
keyup: new Set(['click']), keyup: new Set(["click"]),
}, },
[Keys.Tab.key!]: { [Keys.Tab.key!]: {
keydown: new Set(['keypress', 'blur', 'focus']), keydown: new Set(["keypress", "blur", "focus"]),
keypress: new Set([]), keypress: new Set([]),
keyup: new Set([]), keyup: new Set([]),
}, },
} };
let order: Record< let order: Record<
string | typeof Default, string | typeof Default,
@@ -64,105 +67,115 @@ let order: Record<
> = { > = {
[Default]: [ [Default]: [
async function keydown(element, event) { async function keydown(element, event) {
return await fireEvent.keyDown(element, event) return await fireEvent.keyDown(element, event);
}, },
async function keypress(element, event) { async function keypress(element, event) {
return await fireEvent.keyPress(element, event) return await fireEvent.keyPress(element, event);
}, },
async function keyup(element, event) { async function keyup(element, event) {
return await fireEvent.keyUp(element, event) return await fireEvent.keyUp(element, event);
}, },
], ],
[Keys.Enter.key!]: [ [Keys.Enter.key!]: [
async function keydown(element, event) { async function keydown(element, event) {
return await fireEvent.keyDown(element, event) return await fireEvent.keyDown(element, event);
}, },
async function keypress(element, event) { async function keypress(element, event) {
return await fireEvent.keyPress(element, event) return await fireEvent.keyPress(element, event);
}, },
async function click(element, event) { async function click(element, event) {
if (element instanceof HTMLButtonElement) return await fireEvent.click(element, event) if (element instanceof HTMLButtonElement)
return Ignore return await fireEvent.click(element, event);
return Ignore;
}, },
async function keyup(element, event) { async function keyup(element, event) {
return await fireEvent.keyUp(element, event) return await fireEvent.keyUp(element, event);
}, },
], ],
[Keys.Space.key!]: [ [Keys.Space.key!]: [
async function keydown(element, event) { async function keydown(element, event) {
return await fireEvent.keyDown(element, event) return await fireEvent.keyDown(element, event);
}, },
async function keypress(element, event) { async function keypress(element, event) {
return await fireEvent.keyPress(element, event) return await fireEvent.keyPress(element, event);
}, },
async function keyup(element, event) { async function keyup(element, event) {
return await fireEvent.keyUp(element, event) return await fireEvent.keyUp(element, event);
}, },
async function click(element, event) { async function click(element, event) {
if (element instanceof HTMLButtonElement) return await fireEvent.click(element, event) if (element instanceof HTMLButtonElement)
return Ignore return await fireEvent.click(element, event);
return Ignore;
}, },
], ],
[Keys.Tab.key!]: [ [Keys.Tab.key!]: [
async function keydown(element, event) { async function keydown(element, event) {
return await fireEvent.keyDown(element, event) return await fireEvent.keyDown(element, event);
}, },
async function blurAndfocus(_element, event) { async function blurAndfocus(_element, event) {
return focusNext(event) return focusNext(event);
}, },
async function keyup(element, event) { async function keyup(element, event) {
return await fireEvent.keyUp(element, event) return await fireEvent.keyUp(element, event);
}, },
], ],
} };
export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) { export async function type(
jest.useFakeTimers() events: Partial<KeyboardEvent>[],
element = document.activeElement
) {
jest.useFakeTimers();
try { try {
if (element === null) return expect(element).not.toBe(null) if (element === null) return expect(element).not.toBe(null);
for (let event of events) { for (let event of events) {
let skip = new Set() let skip = new Set();
let actions = order[event.key!] ?? order[Default as any] let actions = order[event.key!] ?? order[Default as any];
for (let action of actions) { for (let action of actions) {
let checks = action.name.split('And') let checks = action.name.split("And");
if (checks.some(check => skip.has(check))) continue if (checks.some((check) => skip.has(check))) continue;
let result: boolean | typeof Ignore | Element = await action(element, { let result: boolean | typeof Ignore | Element = await action(element, {
type: action.name, type: action.name,
charCode: event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined, charCode:
event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined,
...event, ...event,
}) });
if (result === Ignore) continue if (result === Ignore) continue;
if (result instanceof Element) { if (result instanceof Element) {
element = result element = result;
} }
let cancelled = !result let cancelled = !result;
if (cancelled) { if (cancelled) {
let skippablesForKey = cancellations[event.key!] ?? cancellations[Default as any] let skippablesForKey =
let skippables = skippablesForKey?.[action.name] ?? new Set() 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 // 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) { } catch (err: any) {
Error.captureStackTrace(err, type) Error.captureStackTrace(err, type);
throw err throw err;
} finally { } finally {
jest.useRealTimers() jest.useRealTimers();
} }
} }
export async function press(event: Partial<KeyboardEvent>, element = document.activeElement) { export async function press(
return type([event], element) event: Partial<KeyboardEvent>,
element = document.activeElement
) {
return type([event], element);
} }
export enum MouseButton { export enum MouseButton {
@@ -175,153 +188,158 @@ export async function click(
button = MouseButton.Left button = MouseButton.Left
) { ) {
try { 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) { if (button === MouseButton.Left) {
// Cancel in pointerDown cancels mouseDown, mouseUp // Cancel in pointerDown cancels mouseDown, mouseUp
let cancelled = !(await fireEvent.pointerDown(element, options)) let cancelled = !(await fireEvent.pointerDown(element, options));
if (!cancelled) { 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 // 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) { while (next !== null) {
if (next.matches(focusableSelector)) { if (next.matches(focusableSelector)) {
next.focus() next.focus();
break break;
} }
next = next.parentElement next = next.parentElement;
} }
await fireEvent.pointerUp(element, options) await fireEvent.pointerUp(element, options);
if (!cancelled) { 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) { } else if (button === MouseButton.Right) {
// Cancel in pointerDown cancels mouseDown, mouseUp // Cancel in pointerDown cancels mouseDown, mouseUp
let cancelled = !(await fireEvent.pointerDown(element, options)) let cancelled = !(await fireEvent.pointerDown(element, options));
if (!cancelled) { if (!cancelled) {
await fireEvent.mouseDown(element, options) await fireEvent.mouseDown(element, options);
} }
// Only in Firefox: // Only in Firefox:
await fireEvent.pointerUp(element, options) await fireEvent.pointerUp(element, options);
if (!cancelled) { if (!cancelled) {
await fireEvent.mouseUp(element, options) await fireEvent.mouseUp(element, options);
} }
} }
} catch (err: any) { } catch (err: any) {
Error.captureStackTrace(err, click) Error.captureStackTrace(err, click);
throw err throw err;
} }
} }
export async function focus(element: Document | Element | Window | null) { export async function focus(element: Document | Element | Window | null) {
try { 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) { } catch (err: any) {
Error.captureStackTrace(err, focus) Error.captureStackTrace(err, focus);
throw err throw err;
} }
} }
export async function mouseEnter(element: Document | Element | Window | null) { export async function mouseEnter(element: Document | Element | Window | null) {
try { 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.pointerOver(element);
await fireEvent.pointerEnter(element) await fireEvent.pointerEnter(element);
await fireEvent.mouseOver(element) await fireEvent.mouseOver(element);
await tick() await tick();
} catch (err: any) { } catch (err: any) {
Error.captureStackTrace(err, mouseEnter) Error.captureStackTrace(err, mouseEnter);
throw err throw err;
} }
} }
export async function mouseMove(element: Document | Element | Window | null) { export async function mouseMove(element: Document | Element | Window | null) {
try { 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.pointerMove(element);
await fireEvent.mouseMove(element) await fireEvent.mouseMove(element);
await tick() await tick();
} catch (err: any) { } catch (err: any) {
Error.captureStackTrace(err, mouseMove) Error.captureStackTrace(err, mouseMove);
throw err throw err;
} }
} }
export async function mouseLeave(element: Document | Element | Window | null) { export async function mouseLeave(element: Document | Element | Window | null) {
try { 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.pointerOut(element);
await fireEvent.pointerLeave(element) await fireEvent.pointerLeave(element);
await fireEvent.mouseOut(element) await fireEvent.mouseOut(element);
await fireEvent.mouseLeave(element) await fireEvent.mouseLeave(element);
await tick() await tick();
} catch (err: any) { } catch (err: any) {
Error.captureStackTrace(err, mouseLeave) Error.captureStackTrace(err, mouseLeave);
throw err throw err;
} }
} }
// --- // ---
function focusNext(event: Partial<KeyboardEvent>) { function focusNext(event: Partial<KeyboardEvent>) {
let direction = event.shiftKey ? -1 : +1 let direction = event.shiftKey ? -1 : +1;
let focusableElements = getFocusableElements() let focusableElements = getFocusableElements();
let total = focusableElements.length let total = focusableElements.length;
function innerFocusNext(offset = 0): Element { function innerFocusNext(offset = 0): Element {
let currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement) let currentIdx = focusableElements.indexOf(
let next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement 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) if (next !== document.activeElement)
return next return innerFocusNext(offset + direction);
return next;
} }
return innerFocusNext() return innerFocusNext();
} }
// Credit: // Credit:
// - https://stackoverflow.com/a/30753870 // - https://stackoverflow.com/a/30753870
let focusableSelector = [ let focusableSelector = [
'[contentEditable=true]', "[contentEditable=true]",
'[tabindex]', "[tabindex]",
'a[href]', "a[href]",
'area[href]', "area[href]",
'button:not([disabled])', "button:not([disabled])",
'iframe', "iframe",
'input:not([disabled])', "input:not([disabled])",
'select:not([disabled])', "select:not([disabled])",
'textarea:not([disabled])', "textarea:not([disabled])",
] ]
.map( .map(
process.env.NODE_ENV === 'test' process.env.NODE_ENV === "test"
? // TODO: Remove this once JSDOM fixes the issue where an element that is ? // TODO: Remove this once JSDOM fixes the issue where an element that is
// "hidden" can be the document.activeElement, because this is not possible // "hidden" can be the document.activeElement, because this is not possible
// in real browsers. // in real browsers.
selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` (selector) =>
: selector => `${selector}:not([tabindex='-1'])` `${selector}:not([tabindex='-1']):not([style*='display: none'])`
: (selector) => `${selector}:not([tabindex='-1'])`
) )
.join(',') .join(",");
function getFocusableElements(container = document.body) { function getFocusableElements(container = document.body) {
if (!container) return [] if (!container) return [];
return Array.from(container.querySelectorAll(focusableSelector)) return Array.from(container.querySelectorAll(focusableSelector));
} }

View File

@@ -1,17 +1,17 @@
type FunctionPropertyNames<T> = { type FunctionPropertyNames<T> = {
[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] & }[keyof T] &
string string;
export function suppressConsoleLogs<T extends unknown[]>( export function suppressConsoleLogs<T extends unknown[]>(
cb: (...args: T) => unknown, cb: (...args: T) => unknown,
type: FunctionPropertyNames<typeof global.console> = 'error' type: FunctionPropertyNames<typeof global.console> = "error"
) { ) {
return (...args: T) => { 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<unknown>((resolve, reject) => { return new Promise<unknown>((resolve, reject) => {
Promise.resolve(cb(...args)).then(resolve, reject) Promise.resolve(cb(...args)).then(resolve, reject);
}).finally(() => spy.mockRestore()) }).finally(() => spy.mockRestore());
} };
} }

View File

@@ -1,8 +1,12 @@
import type { SupportedAs } from "$lib/internal/elements"; 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; if (props.type) return props.type;
let tag = props.as ?? "button"; 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"; if (ref instanceof HTMLButtonElement) return "button";
return undefined; return undefined;
} }

View File

@@ -66,7 +66,7 @@ export function transition(
done?: (reason: Reason) => void done?: (reason: Reason) => void
) { ) {
let d = disposables(); let d = disposables();
let _done = done !== undefined ? once(done) : () => { }; let _done = done !== undefined ? once(done) : () => {};
removeClasses(node, ...entered); removeClasses(node, ...entered);
addClasses(node, ...base, ...from); addClasses(node, ...base, ...from);

View File

@@ -5,4 +5,3 @@
> >
<slot /> <slot />
</a> </a>

View File

@@ -19,7 +19,7 @@ const config = {
}, },
files: (filepath) => { files: (filepath) => {
return !filepath.endsWith(".test.ts"); return !filepath.endsWith(".test.ts");
} },
}, },
// hydrate the <div id="svelte"> element in src/app.html // hydrate the <div id="svelte"> element in src/app.html