diff --git a/.changeset/nice-cougars-trade.md b/.changeset/nice-cougars-trade.md new file mode 100644 index 000000000..0c89d5b61 --- /dev/null +++ b/.changeset/nice-cougars-trade.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +fix: `Checkbox.Group` not firing `onValueChange` diff --git a/packages/bits-ui/src/lib/bits/accordion/types.ts b/packages/bits-ui/src/lib/bits/accordion/types.ts index 6f62030a8..09a103b8a 100644 --- a/packages/bits-ui/src/lib/bits/accordion/types.ts +++ b/packages/bits-ui/src/lib/bits/accordion/types.ts @@ -89,6 +89,12 @@ export type AccordionRootPropsWithoutHTML = export type AccordionRootProps = AccordionRootPropsWithoutHTML & Without; +export type AccordionRootSingleProps = AccordionRootSinglePropsWithoutHTML & + Without; + +export type AccordionMultipleProps = AccordionRootMultiplePropsWithoutHTML & + Without; + export type AccordionTriggerPropsWithoutHTML = WithChild; export type AccordionTriggerProps = AccordionTriggerPropsWithoutHTML & diff --git a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts index 5ba020cce..d8d552776 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts +++ b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts @@ -2,7 +2,12 @@ import { srOnlyStyles, styleToString, useRefById } from "svelte-toolbelt"; import type { HTMLButtonAttributes } from "svelte/elements"; import { Context, watch } from "runed"; import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; -import type { BitsKeyboardEvent, BitsMouseEvent, WithRefProps } from "$lib/internal/types.js"; +import type { + BitsKeyboardEvent, + BitsMouseEvent, + OnChangeFn, + WithRefProps, +} from "$lib/internal/types.js"; import { getAriaChecked, getAriaRequired, getDataDisabled } from "$lib/internal/attrs.js"; import { kbd } from "$lib/internal/kbd.js"; @@ -15,6 +20,7 @@ type CheckboxGroupStateProps = WithRefProps< name: string | undefined; disabled: boolean; required: boolean; + onValueChange: OnChangeFn; }> & WritableBoxedValues<{ value: string[]; @@ -32,6 +38,7 @@ class CheckboxGroupState { if (!checkboxValue) return; if (!this.opts.value.current.includes(checkboxValue)) { this.opts.value.current.push(checkboxValue); + this.opts.onValueChange.current(this.opts.value.current); } } @@ -40,6 +47,7 @@ class CheckboxGroupState { const index = this.opts.value.current.indexOf(checkboxValue); if (index === -1) return; this.opts.value.current.splice(index, 1); + this.opts.onValueChange.current(this.opts.value.current); } props = $derived.by( diff --git a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group.svelte b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group.svelte index ff9596dc0..b946ad1dd 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group.svelte +++ b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group.svelte @@ -29,8 +29,12 @@ name: box.with(() => name), value: box.with( () => value, - (v) => onValueChange(v) + (v) => { + value = v; + onValueChange(v); + } ), + onValueChange: box.with(() => onValueChange), }); const mergedProps = $derived(mergeProps(restProps, groupState.props)); diff --git a/packages/tests/src/tests/accordion/accordion-multi-test-controlled.svelte b/packages/tests/src/tests/accordion/accordion-multi-test-controlled.svelte index 2684569c1..78bbaa7b3 100644 --- a/packages/tests/src/tests/accordion/accordion-multi-test-controlled.svelte +++ b/packages/tests/src/tests/accordion/accordion-multi-test-controlled.svelte @@ -10,10 +10,10 @@ }; type Props = { - disabled: boolean; - items: Item[]; - value: string[]; - onValueChange: (v: string[]) => void; + disabled?: boolean; + items?: Item[]; + value?: string[]; + onValueChange?: (v: string[]) => void; } & Omit; let { disabled = false, items = [], value: valueProp = [], ...restProps }: Props = $props(); diff --git a/packages/tests/src/tests/accordion/accordion-multi-test.svelte b/packages/tests/src/tests/accordion/accordion-multi-test.svelte index 3ff24fa59..76fa0bef7 100644 --- a/packages/tests/src/tests/accordion/accordion-multi-test.svelte +++ b/packages/tests/src/tests/accordion/accordion-multi-test.svelte @@ -10,10 +10,10 @@ }; type Props = { - disabled: boolean; - items: Item[]; - value: string[]; - onValueChange: (v: string[]) => void; + disabled?: boolean; + items?: Item[]; + value?: string[]; + onValueChange?: (v: string[]) => void; } & Omit; let { disabled = false, items = [], value = [], ...restProps }: Props = $props(); diff --git a/packages/tests/src/tests/accordion/accordion-single-force-mount-test.svelte b/packages/tests/src/tests/accordion/accordion-single-force-mount-test.svelte index 04128f54c..5c110c202 100644 --- a/packages/tests/src/tests/accordion/accordion-single-force-mount-test.svelte +++ b/packages/tests/src/tests/accordion/accordion-single-force-mount-test.svelte @@ -1,5 +1,5 @@ diff --git a/packages/tests/src/tests/accordion/accordion-single-test-controlled.svelte b/packages/tests/src/tests/accordion/accordion-single-test-controlled.svelte index 506a8141b..fd9d2fbf5 100644 --- a/packages/tests/src/tests/accordion/accordion-single-test-controlled.svelte +++ b/packages/tests/src/tests/accordion/accordion-single-test-controlled.svelte @@ -1,5 +1,5 @@ diff --git a/packages/tests/src/tests/accordion/accordion-single-test.svelte b/packages/tests/src/tests/accordion/accordion-single-test.svelte index 2a8b7f525..75f08195f 100644 --- a/packages/tests/src/tests/accordion/accordion-single-test.svelte +++ b/packages/tests/src/tests/accordion/accordion-single-test.svelte @@ -1,5 +1,5 @@ - + {#each items as { value, title, disabled, content, level }} diff --git a/packages/tests/src/tests/accordion/accordion.test.ts b/packages/tests/src/tests/accordion/accordion.test.ts index 67fc41f56..f45685e78 100644 --- a/packages/tests/src/tests/accordion/accordion.test.ts +++ b/packages/tests/src/tests/accordion/accordion.test.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { render } from "@testing-library/svelte/svelte5"; import { axe } from "jest-axe"; -import { describe, it } from "vitest"; +import { describe, it, vi } from "vitest"; import { type ComponentProps, tick } from "svelte"; import { getTestKbd, setupUserEvents, sleep } from "../utils.js"; import AccordionSingleTest from "./accordion-single-test.svelte"; @@ -59,9 +58,10 @@ const itemsWithDisabled = items.map((item) => { return item; }); -function setupSingle(props: ComponentProps = { items }) { +function setupSingle(props: Partial> = { items }) { const user = setupUserEvents(); - const returned = render(AccordionSingleTest, { ...props }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const returned = render(AccordionSingleTest, { ...(props as any) }); const itemEls = items.map((item) => returned.getByTestId(`${item.value}-item`)); const triggerEls = items.map((item) => returned.getByTestId(`${item.value}-trigger`)); return { @@ -96,137 +96,138 @@ function expectNotDisabled(...triggerEls: HTMLElement[]) { } } -describe("accordion - single", () => { - it("should have no accessibility violations", async () => { - const { container } = setupSingle(); - expect(await axe(container)).toHaveNoViolations(); - }); +it("should have no accessibility violations", async () => { + const { container } = setupSingle(); + expect(await axe(container)).toHaveNoViolations(); +}); - it("should have bits data attrs", async () => { - const { getByTestId } = render(AccordionTestIsolated); - const root = getByTestId("root"); - const trigger = getByTestId("trigger"); - const item = getByTestId("item"); - const header = getByTestId("header"); - const content = getByTestId("content"); - expect(root).toHaveAttribute("data-accordion-root"); - expect(item).toHaveAttribute("data-accordion-item"); - expect(header).toHaveAttribute("data-accordion-header"); - expect(content).toHaveAttribute("data-accordion-content"); - expect(trigger).toHaveAttribute("data-accordion-trigger"); - }); +it("should have bits data attrs", async () => { + const { getByTestId } = render(AccordionTestIsolated); + const root = getByTestId("root"); + const trigger = getByTestId("trigger"); + const item = getByTestId("item"); + const header = getByTestId("header"); + const content = getByTestId("content"); + expect(root).toHaveAttribute("data-accordion-root"); + expect(item).toHaveAttribute("data-accordion-item"); + expect(header).toHaveAttribute("data-accordion-header"); + expect(content).toHaveAttribute("data-accordion-content"); + expect(trigger).toHaveAttribute("data-accordion-trigger"); +}); - it("should have expected data attributes", async () => { - const user = setupUserEvents(); - const { itemEls, triggerEls } = setupSingle({ items: itemsWithDisabled }); +it("should have expected data attributes", async () => { + const user = setupUserEvents(); + const { itemEls, triggerEls } = setupSingle({ items: itemsWithDisabled }); - expectClosed(itemEls[0], triggerEls[0]); - expectNotDisabled(itemEls[0], triggerEls[0]); + expectClosed(itemEls[0], triggerEls[0]); + expectNotDisabled(itemEls[0], triggerEls[0]); - await user.click(triggerEls[0] as HTMLElement); - await tick(); - expectOpen(itemEls[0], triggerEls[0]); - expectDisabled(itemEls[1], triggerEls[1]); + await user.click(triggerEls[0] as HTMLElement); + await tick(); + expectOpen(itemEls[0], triggerEls[0]); + expectDisabled(itemEls[1], triggerEls[1]); +}); + +it("should forceMount the content when `forceMount` is true", async () => { + const { getByTestId } = render(AccordionSingleForceMountTest, { + items: itemsWithDisabled, }); + const contentEls = items.map((item) => getByTestId(`${item.value}-content`)); - it("should forceMount the content when `forceMount` is true", async () => { - const { getByTestId } = render(AccordionSingleForceMountTest as any, { - items: itemsWithDisabled, - }); - const contentEls = items.map((item) => getByTestId(`${item.value}-content`)); + for (const content of contentEls) { + expect(content).toBeVisible(); + } +}); - for (const content of contentEls) { - expect(content).toBeVisible(); - } +it("works properly when `forceMount` is true and the `open` snippet prop is used to conditionally render the content", async () => { + const user = setupUserEvents(); + const { getByTestId, queryByTestId } = render(AccordionSingleForceMountTest, { + items: itemsWithDisabled, + withOpenCheck: true, }); + const initContentEls = items.map((item) => queryByTestId(`${item.value}-content`)); - it("work properly when `forceMount` is true and the `open` snippet prop is used to conditionally render the content", async () => { - const user = setupUserEvents(); - const { getByTestId, queryByTestId } = render(AccordionSingleForceMountTest as any, { - items: itemsWithDisabled, - withOpenCheck: true, - }); - const initContentEls = items.map((item) => queryByTestId(`${item.value}-content`)); + for (const content of initContentEls) { + expect(content).toBeNull(); + } - for (const content of initContentEls) { - expect(content).toBeNull(); - } + const triggerEls = items.map((item) => getByTestId(`${item.value}-trigger`)); - const triggerEls = items.map((item) => getByTestId(`${item.value}-trigger`)); + // open the first item + await user.click(triggerEls[0] as HTMLElement); - // open the first item - await user.click(triggerEls[0] as HTMLElement); + const firstContentEl = getByTestId(`${items[0]!.value}-content`); + expect(firstContentEl).toBeVisible(); - const firstContentEl = getByTestId(`${items[0]!.value}-content`); - expect(firstContentEl).toBeVisible(); + const secondContentEl = queryByTestId(`${items[1]!.value}-content`); + expect(secondContentEl).toBeNull(); +}); - const secondContentEl = queryByTestId(`${items[1]!.value}-content`); - expect(secondContentEl).toBeNull(); +it("should disable everything when true on root", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { + items, + disabled: true, }); - it("should disable everything when the `disabled` prop is true", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { - items, - disabled: true, - }); + const triggerEls = items.map((item) => getByTestId(`${item.value}-trigger`)); + await user.click(triggerEls[0] as HTMLElement); + expectClosed(triggerEls[0]); + expectDisabled(triggerEls[0]); - const triggerEls = items.map((item) => getByTestId(`${item.value}-trigger`)); - await user.click(triggerEls[0] as HTMLElement); - expectClosed(triggerEls[0]); - expectDisabled(triggerEls[0]); + await user.click(triggerEls[1] as HTMLElement); + expectClosed(triggerEls[1]); + expectDisabled(triggerEls[1]); - await user.click(triggerEls[1] as HTMLElement); - expectClosed(triggerEls[1]); - expectDisabled(triggerEls[1]); - - await user.click(triggerEls[2] as HTMLElement); - expectClosed(triggerEls[2]); - expectDisabled(triggerEls[2]); - }); - - it("should display content when an item is expanded", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { items }); - - for (const item of items) { - const trigger = getByTestId(`${item.value}-trigger`); - const content = getByTestId(`${item.value}-content`); - const itemEl = getByTestId(`${item.value}-item`); - expectClosed(itemEl, trigger); - expect(content).not.toBeVisible(); - await user.click(trigger); - const contentAfter = getByTestId(`${item.value}-content`); - expect(contentAfter).toHaveTextContent(item.content); - expectOpen(itemEl, trigger); - expect(itemEl).toHaveAttribute("data-state", "open"); - } - }); + await user.click(triggerEls[2] as HTMLElement); + expectClosed(triggerEls[2]); + expectDisabled(triggerEls[2]); +}); - it("should expand only one item at a time when type is `'single'`", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { items }); +it("should display content when an item is expanded", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items }); + + for (const item of items) { + const trigger = getByTestId(`${item.value}-trigger`); + const content = getByTestId(`${item.value}-content`); + const itemEl = getByTestId(`${item.value}-item`); + expectClosed(itemEl, trigger); + expect(content).not.toBeVisible(); + await user.click(trigger); + const contentAfter = getByTestId(`${item.value}-content`); + expect(contentAfter).toHaveTextContent(item.content); + expectOpen(itemEl, trigger); + expect(itemEl).toHaveAttribute("data-state", "open"); + } +}); - for (const item of items) { - const trigger = getByTestId(`${item.value}-trigger`); - const content = getByTestId(`${item.value}-content`); - const itemEl = getByTestId(`${item.value}-item`); - expectClosed(itemEl, trigger); - expect(content).not.toBeVisible(); - await user.click(trigger); - const contentAfter = getByTestId(`${item.value}-content`); - expect(contentAfter).toHaveTextContent(item.content); - expectOpen(itemEl, trigger); - } - const openItems = Array.from( - document.querySelectorAll("[data-state='open'][data-accordion-item]") - ); - expect(openItems.length).toBe(1); - }); +it("should expand only one item at a time when type is `'single'`", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items }); + + for (const item of items) { + const trigger = getByTestId(`${item.value}-trigger`); + const content = getByTestId(`${item.value}-content`); + const itemEl = getByTestId(`${item.value}-item`); + expectClosed(itemEl, trigger); + expect(content).not.toBeVisible(); + await user.click(trigger); + const contentAfter = getByTestId(`${item.value}-content`); + expect(contentAfter).toHaveTextContent(item.content); + expectOpen(itemEl, trigger); + } + const openItems = Array.from( + document.querySelectorAll("[data-state='open'][data-accordion-item]") + ); + expect(openItems.length).toBe(1); +}); - it("should expand when the trigger is focused and `Enter` key is pressed", async () => { +it.each([kbd.ENTER, kbd.SPACE])( + `should expand when the trigger is focused and "%s" key is pressed`, + async (key) => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { + const { getByTestId } = render(AccordionSingleTest, { items, }); @@ -237,163 +238,240 @@ describe("accordion - single", () => { expectClosed(itemEl, trigger); expect(content).not.toBeVisible(); trigger.focus(); - await user.keyboard(kbd.ENTER); + await user.keyboard(key); const contentAfter = getByTestId(`${item.value}-content`); expect(contentAfter).toHaveTextContent(item.content); expectOpen(itemEl, trigger); } - }); + } +); - it("should expand when the trigger is focused and `Space` key is pressed", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { - items, - }); +it("should focus the next item when `ArrowDown` key is pressed", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[1]).toHaveFocus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[2]).toHaveFocus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[3]).toHaveFocus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[0]).toHaveFocus(); +}); - for (const item of items) { - const trigger = getByTestId(`${item.value}-trigger`); - const content = getByTestId(`${item.value}-content`); - const itemEl = getByTestId(`${item.value}-item`); - expectClosed(itemEl, trigger); - expect(content).not.toBeVisible(); - trigger.focus(); - await user.keyboard(kbd.SPACE); - const contentAfter = getByTestId(`${item.value}-content`); - expect(contentAfter).toHaveTextContent(item.content); - expectOpen(itemEl, trigger); - } - }); +it("should focus the previous item when the `ArrowUp` key is pressed", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_UP); + expect(triggers[3]).toHaveFocus(); + await user.keyboard(kbd.ARROW_UP); + expect(triggers[2]).toHaveFocus(); + await user.keyboard(kbd.ARROW_UP); + expect(triggers[1]).toHaveFocus(); + await user.keyboard(kbd.ARROW_UP); + expect(triggers[0]).toHaveFocus(); +}); - it("should focus the next item when `ArrowDown` key is pressed", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { items }); +it("should focus the first item when the `Home` key is pressed", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items }); - const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); - triggers[0]?.focus(); - await user.keyboard(kbd.ARROW_DOWN); - expect(triggers[1]).toHaveFocus(); - await user.keyboard(kbd.ARROW_DOWN); - expect(triggers[2]).toHaveFocus(); - await user.keyboard(kbd.ARROW_DOWN); - expect(triggers[3]).toHaveFocus(); - await user.keyboard(kbd.ARROW_DOWN); + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + + for (const trigger of triggers) { + trigger.focus(); + await user.keyboard(kbd.HOME); expect(triggers[0]).toHaveFocus(); - }); + } +}); - it("should focus the previous item when the `ArrowUp` key is pressed", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { items }); +it("should focus the last item when the `End` key is pressed", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items }); - const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); - triggers[0]?.focus(); - await user.keyboard(kbd.ARROW_UP); + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + + for (const trigger of triggers) { + trigger.focus(); + await user.keyboard(kbd.END); expect(triggers[3]).toHaveFocus(); - await user.keyboard(kbd.ARROW_UP); - expect(triggers[2]).toHaveFocus(); - await user.keyboard(kbd.ARROW_UP); - expect(triggers[1]).toHaveFocus(); - await user.keyboard(kbd.ARROW_UP); - expect(triggers[0]).toHaveFocus(); - }); + } +}); - it("should focus the first item when the `Home` key is pressed", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { items }); +it("should respect the `disabled` prop for items", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items: itemsWithDisabled }); - const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + await user.click(triggers[0] as HTMLElement); + expect(triggers[0]).toHaveFocus(); - for (const trigger of triggers) { - trigger.focus(); - await user.keyboard(kbd.HOME); - expect(triggers[0]).toHaveFocus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[1]).not.toHaveFocus(); + expect(triggers[2]).toHaveFocus(); +}); + +it("should respect the `level` prop for headers", async () => { + const itemsWithLevel = items.map((item, i) => { + if (i === 0) { + return { ...item, level: 1 } as const; } + return item; }); + const { getByTestId } = render(AccordionSingleTest, { items: itemsWithLevel }); - it("should focus the last item when the `End` key is pressed", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { items }); + const headers = items.map((item) => getByTestId(`${item.value}-header`)); + expect(headers[0]).toHaveAttribute("data-heading-level", "1"); + expect(headers[0]).toHaveAttribute("aria-level", "1"); + expect(headers[1]).toHaveAttribute("data-heading-level", "3"); + expect(headers[1]).toHaveAttribute("aria-level", "3"); +}); - const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); +it("should update the `bind:value` prop when the value changes", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTestControlledSvelte, { items }); + const trigger = getByTestId("item-0-trigger"); - for (const trigger of triggers) { - trigger.focus(); - await user.keyboard(kbd.END); - expect(triggers[3]).toHaveFocus(); - } - }); + const value = getByTestId("value"); - it("should respect the `disabled` prop for items", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTest as any, { items: itemsWithDisabled }); + expect(value).toHaveTextContent(""); - const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); - await user.click(triggers[0] as HTMLElement); - expect(triggers[0]).toHaveFocus(); + await user.click(trigger); + expect(value).toHaveTextContent("item-0"); +}); - await user.keyboard(kbd.ARROW_DOWN); - expect(triggers[1]).not.toHaveFocus(); - expect(triggers[2]).toHaveFocus(); - }); +it('should handle programmatic changes to the "value" prop', async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTestControlledSvelte, { items }); + const updateButton = getByTestId("update-value"); + const value = getByTestId("value"); - it("should respect the `level` prop for headers", async () => { - const itemsWithLevel = items.map((item, i) => { - if (i === 0) { - return { ...item, level: 1 } as const; - } - return item; - }); - const { getByTestId } = render(AccordionSingleTest as any, { items: itemsWithLevel }); + expect(value).toHaveTextContent(""); - const headers = items.map((item) => getByTestId(`${item.value}-header`)); - expect(headers[0]).toHaveAttribute("data-heading-level", "1"); - expect(headers[0]).toHaveAttribute("aria-level", "1"); - expect(headers[1]).toHaveAttribute("data-heading-level", "3"); - expect(headers[1]).toHaveAttribute("aria-level", "3"); - }); + const itemOneItem = getByTestId("item-1-item"); + expectClosed(itemOneItem); - it("should update the `bind:value` prop when the value changes", async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTestControlledSvelte as any, { items }); - const trigger = getByTestId("item-0-trigger"); + await user.click(updateButton); + expect(value).toHaveTextContent("item-1"); + expectOpen(itemOneItem); +}); - const value = getByTestId("value"); +it("should loop through the items when the `loop` prop is true", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items, loop: true }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_UP); + expect(triggers[3]).toHaveFocus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[0]).toHaveFocus(); +}); - expect(value).toHaveTextContent(""); +it("should not loop through the items when the `loop` prop is false", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items, loop: false }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_UP); + expect(triggers[3]).not.toHaveFocus(); + expect(triggers[0]).toHaveFocus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[1]).toHaveFocus(); +}); - await user.click(trigger); - expect(value).toHaveTextContent("item-0"); - }); +it("should navigate using ArrowLeft/Right when `orientation` is `horizontal`", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items, orientation: "horizontal" }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_LEFT); + expect(triggers[3]).toHaveFocus(); + await user.keyboard(kbd.ARROW_RIGHT); + expect(triggers[0]).toHaveFocus(); +}); - it('should handle programmatic changes to the "value" prop', async () => { - const user = setupUserEvents(); - const { getByTestId } = render(AccordionSingleTestControlledSvelte as any, { items }); - const updateButton = getByTestId("update-value"); - const value = getByTestId("value"); +it("should loop using ArrowLeft/Right when `orientation` is `horizontal` and `loop` is true", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { + items, + orientation: "horizontal", + loop: true, + }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_LEFT); + expect(triggers[3]).toHaveFocus(); + await user.keyboard(kbd.ARROW_RIGHT); + expect(triggers[0]).toHaveFocus(); +}); - expect(value).toHaveTextContent(""); +it("should not loop using ArrowLeft/Right when `orientation` is `horizontal` and `loop` is false", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { + items, + orientation: "horizontal", + loop: false, + }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_LEFT); + expect(triggers[3]).not.toHaveFocus(); + expect(triggers[0]).toHaveFocus(); + await user.keyboard(kbd.ARROW_RIGHT); + expect(triggers[1]).toHaveFocus(); +}); - const itemOneItem = getByTestId("item-1-item"); - expectClosed(itemOneItem); +it("should skip over disabled items when navigation with Arrow Keys", async () => { + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items: itemsWithDisabled }); - await user.click(updateButton); - expect(value).toHaveTextContent("item-1"); - expectOpen(itemOneItem); - }); + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ARROW_DOWN); + expect(triggers[1]).not.toHaveFocus(); + expect(triggers[2]).toHaveFocus(); +}); + +it("should call `onValueChange` with the new value when an item is expanded", async () => { + const mock = vi.fn(); + const user = setupUserEvents(); + const { getByTestId } = render(AccordionSingleTest, { items, onValueChange: mock }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ENTER); + expect(mock).toHaveBeenCalledWith(items[0].value); + await user.keyboard(kbd.ARROW_DOWN); + await user.keyboard(kbd.ENTER); + expect(mock).toHaveBeenCalledWith(items[1].value); + await user.keyboard(kbd.ENTER); + expect(mock).toHaveBeenCalledWith(""); }); // // MULTIPLE ACCORDION // -describe("accordion - multiple", () => { +describe("type='multiple'", () => { it("should have no accessibility violations", async () => { - const { container } = render(AccordionMultiTest as any, { items }); + const { container } = render(AccordionMultiTest, { items }); expect(await axe(container)).toHaveNoViolations(); }); it("should have expected data attributes", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { items: itemsWithDisabled }); + const { getByTestId } = render(AccordionMultiTest, { items: itemsWithDisabled }); const itemEls = items.map((item) => getByTestId(`${item.value}-item`)); const triggerEls = items.map((item) => getByTestId(`${item.value}-trigger`)); @@ -407,7 +485,7 @@ describe("accordion - multiple", () => { it("should disable everything when the `disabled` prop is true", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { + const { getByTestId } = render(AccordionMultiTest, { items, disabled: true, }); @@ -428,7 +506,7 @@ describe("accordion - multiple", () => { it("should display content when an item is expanded", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { items }); + const { getByTestId } = render(AccordionMultiTest, { items }); for (const item of items) { const trigger = getByTestId(`${item.value}-trigger`); @@ -445,7 +523,7 @@ describe("accordion - multiple", () => { it("should allow expanding multiple items", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { + const { getByTestId } = render(AccordionMultiTest, { items, }); @@ -468,7 +546,7 @@ describe("accordion - multiple", () => { it("should expand when the trigger is focused and `Enter` key is pressed", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { + const { getByTestId } = render(AccordionMultiTest, { items, }); @@ -488,7 +566,7 @@ describe("accordion - multiple", () => { it("should expand when the trigger is focused and `Space` key is pressed", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { + const { getByTestId } = render(AccordionMultiTest, { items, }); @@ -509,7 +587,7 @@ describe("accordion - multiple", () => { it("should focus the next item when `ArrowDown` key is pressed", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { items }); + const { getByTestId } = render(AccordionMultiTest, { items }); const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); triggers[0]?.focus(); @@ -525,7 +603,7 @@ describe("accordion - multiple", () => { it("should focus the previous item when the `ArrowUp` key is pressed", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { items }); + const { getByTestId } = render(AccordionMultiTest, { items }); const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); triggers[0]?.focus(); @@ -541,7 +619,7 @@ describe("accordion - multiple", () => { it("should focus the first item when the `Home` key is pressed", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { items }); + const { getByTestId } = render(AccordionMultiTest, { items }); const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); @@ -554,7 +632,7 @@ describe("accordion - multiple", () => { it("should focus the last item when the `End` key is pressed", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { items }); + const { getByTestId } = render(AccordionMultiTest, { items }); const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); @@ -567,7 +645,7 @@ describe("accordion - multiple", () => { it("should respect the `disabled` prop for items", async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTest as any, { items: itemsWithDisabled }); + const { getByTestId } = render(AccordionMultiTest, { items: itemsWithDisabled }); const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); await user.click(triggers[0] as HTMLElement); @@ -584,7 +662,7 @@ describe("accordion - multiple", () => { } return item; }); - const { getByTestId } = render(AccordionMultiTest as any, { items: itemsWithLevel }); + const { getByTestId } = render(AccordionMultiTest, { items: itemsWithLevel }); const headers = items.map((item) => getByTestId(`${item.value}-header`)); expect(headers[0]).toHaveAttribute("data-heading-level", "1"); @@ -595,7 +673,7 @@ describe("accordion - multiple", () => { it("should update the `bind:value` prop when the value changes", async () => { const user = setupUserEvents(); - const { getByTestId, queryByTestId } = render(AccordionMultiTestControlled as any, { + const { getByTestId, queryByTestId } = render(AccordionMultiTestControlled, { items, }); const trigger = getByTestId("item-0-trigger"); @@ -610,7 +688,7 @@ describe("accordion - multiple", () => { it('should handle programmatic changes to the "value" prop', async () => { const user = setupUserEvents(); - const { getByTestId } = render(AccordionMultiTestControlled as any, { + const { getByTestId } = render(AccordionMultiTestControlled, { items, }); const updateButton = getByTestId("update-value"); @@ -623,4 +701,23 @@ describe("accordion - multiple", () => { await user.click(updateButton); expectOpen(itemOneItem); }); + + it("should call `onValueChange` with the new value when an item is expanded/collapsed", async () => { + const mock = vi.fn(); + const user = setupUserEvents(); + const { getByTestId } = render(AccordionMultiTest, { items, onValueChange: mock }); + + const triggers = items.map((item) => getByTestId(`${item.value}-trigger`)); + triggers[0]?.focus(); + await user.keyboard(kbd.ENTER); + expect(mock).toHaveBeenCalledWith([items[0].value]); + await user.keyboard(kbd.ARROW_DOWN); + await user.keyboard(kbd.ENTER); + expect(mock).toHaveBeenCalledWith([items[0].value, items[1].value]); + await user.keyboard(kbd.ENTER); + expect(mock).toHaveBeenCalledWith([items[0].value]); + await user.keyboard(kbd.ARROW_UP); + await user.keyboard(kbd.ENTER); + expect(mock).toHaveBeenCalledWith([]); + }); }); diff --git a/packages/tests/src/tests/alert-dialog/alert-dialog-force-mount-test.svelte b/packages/tests/src/tests/alert-dialog/alert-dialog-force-mount-test.svelte index 66f3620df..60dc3f68b 100644 --- a/packages/tests/src/tests/alert-dialog/alert-dialog-force-mount-test.svelte +++ b/packages/tests/src/tests/alert-dialog/alert-dialog-force-mount-test.svelte @@ -1,15 +1,7 @@ - - diff --git a/packages/tests/src/tests/alert-dialog/alert-dialog.test.ts b/packages/tests/src/tests/alert-dialog/alert-dialog.test.ts index a6f6d1740..01369f01d 100644 --- a/packages/tests/src/tests/alert-dialog/alert-dialog.test.ts +++ b/packages/tests/src/tests/alert-dialog/alert-dialog.test.ts @@ -2,13 +2,12 @@ import { type Matcher, type MatcherOptions, render, - screen, waitFor, } from "@testing-library/svelte/svelte5"; import { axe } from "jest-axe"; -import { describe, it } from "vitest"; +import { it } from "vitest"; import type { Component } from "svelte"; -import { getTestKbd, setupUserEvents, sleep } from "../utils.js"; +import { getTestKbd, setupUserEvents } from "../utils.js"; import AlertDialogTest, { type AlertDialogTestProps } from "./alert-dialog-test.svelte"; import AlertDialogForceMountTest from "./alert-dialog-force-mount-test.svelte"; @@ -52,199 +51,176 @@ async function open(props: AlertDialogTestProps = {}) { return { getByTestId, queryByTestId, user, action, cancel }; } -describe("alert dialog", () => { - it("should have no accessibility violations", async () => { - const { container } = render(AlertDialogTest); - expect(await axe(container)).toHaveNoViolations(); - }); +it("should have no accessibility violations", async () => { + const { container } = render(AlertDialogTest); + expect(await axe(container)).toHaveNoViolations(); +}); - it("should have bits data attrs", async () => { - const { getByTestId } = await open(); - const parts = ["trigger", "overlay", "cancel", "title", "description", "content"]; +it("should have bits data attrs", async () => { + const { getByTestId } = await open(); + const parts = ["trigger", "overlay", "cancel", "title", "description", "content"]; - for (const part of parts) { - const el = getByTestId(part); - expect(el).toHaveAttribute(`data-alert-dialog-${part}`); - } - }); + for (const part of parts) { + const el = getByTestId(part); + expect(el).toHaveAttribute(`data-alert-dialog-${part}`); + } +}); - it("should have expected data attributes", async () => { - const { getByTestId } = await open(); +it("should have expected data attributes", async () => { + const { getByTestId } = await open(); - const overlay = getByTestId("overlay"); - expect(overlay).toHaveAttribute("data-state", "open"); - const content = getByTestId("content"); - expect(content).toHaveAttribute("data-state", "open"); - }); + const overlay = getByTestId("overlay"); + expect(overlay).toHaveAttribute("data-state", "open"); + const content = getByTestId("content"); + expect(content).toHaveAttribute("data-state", "open"); +}); - it("should open when the trigger is clicked", async () => { - await open(); - }); +it("should open when the trigger is clicked", async () => { + await open(); +}); - it("should forceMount the content and overlay when their `forceMount` prop is true", async () => { - const { getByTestId } = setup({}, AlertDialogForceMountTest); +it("should forceMount the content and overlay when their `forceMount` prop is true", async () => { + const { getByTestId } = setup({}, AlertDialogForceMountTest); - expect(getByTestId("overlay")).toBeInTheDocument(); - expect(getByTestId("content")).toBeInTheDocument(); - }); + expect(getByTestId("overlay")).toBeInTheDocument(); + expect(getByTestId("content")).toBeInTheDocument(); +}); - it("should forceMount the content and overlay when their `forceMount` prop is true and the `open` snippet prop is used to conditionally render the content", async () => { - const { getByTestId, queryByTestId, user } = setup( - { - // @ts-expect-error - testing lib needs to update their generic types - withOpenCheck: true, - }, - AlertDialogForceMountTest - ); - const initOverlay = queryByTestId("overlay"); - const initContent = queryByTestId("content"); +it("should forceMount the content and overlay when their `forceMount` prop is true and the `open` snippet prop is used to conditionally render the content", async () => { + const { getByTestId, queryByTestId, user } = setup( + { + withOpenCheck: true, + }, + AlertDialogForceMountTest + ); - expect(initOverlay).toBeNull(); - expect(initContent).toBeNull(); + expect(queryByTestId("overlay")).toBeNull(); + expect(queryByTestId("content")).toBeNull(); - const trigger = getByTestId("trigger"); - await user.pointerDownUp(trigger); + await user.pointerDownUp(getByTestId("trigger")); - const overlay = getByTestId("overlay"); - expect(overlay).toBeInTheDocument(); + expect(getByTestId("overlay")).toBeInTheDocument(); - const content = getByTestId("content"); - expect(content).toBeInTheDocument(); - }); + expect(getByTestId("content")).toBeInTheDocument(); +}); - it("should focus the cancel button by default when opened", async () => { - const { cancel } = await open(); - expect(cancel).toHaveFocus(); - }); +it("should focus the cancel button by default when opened", async () => { + const { cancel } = await open(); + expect(cancel).toHaveFocus(); +}); - it("should close when the cancel button is clicked", async () => { - const { getByTestId, queryByTestId, user } = await open(); - const cancel = getByTestId("cancel"); - await user.pointerDownUp(cancel); - expectIsClosed(queryByTestId); - }); +it("should close when the cancel button is clicked", async () => { + const { getByTestId, queryByTestId, user } = await open(); + const cancel = getByTestId("cancel"); + await user.pointerDownUp(cancel); + expectIsClosed(queryByTestId); +}); - it("should close when the `Escape` key is pressed", async () => { - const { queryByTestId, user, getByTestId } = await open(); +it("should close when the `Escape` key is pressed", async () => { + const { queryByTestId, user, getByTestId } = await open(); - await user.keyboard(kbd.ESCAPE); - expectIsClosed(queryByTestId); - expect(getByTestId("trigger")).toHaveFocus(); - }); + await user.keyboard(kbd.ESCAPE); + expectIsClosed(queryByTestId); + expect(getByTestId("trigger")).toHaveFocus(); +}); - it("should not close when the overlay is clicked", async () => { - const { getByTestId, queryByTestId, user } = await open(); - await sleep(100); +it("should not close when the overlay is clicked", async () => { + const { getByTestId, queryByTestId, user } = await open(); - const overlay = getByTestId("overlay"); - await user.pointerDownUp(overlay); - await sleep(25); + await user.pointerDownUp(getByTestId("overlay")); - const contentAfter2 = queryByTestId("content"); - expect(contentAfter2).not.toBeNull(); - }); + expect(queryByTestId("content")).not.toBeNull(); +}); - it("should attach to body when using portal element", async () => { - await open(); +it("should attach to body when using portal element", async () => { + const { getByTestId } = await open(); - const content = screen.getByTestId("content"); - expect(content.parentElement).toEqual(document.body); - }); + expect(getByTestId("content").parentElement).toEqual(document.body); +}); - it("should attach to body when portal is disabled", async () => { - await open({ - portalProps: { - disabled: true, - }, - }); - const content = screen.getByTestId("content"); - expect(content.parentElement).not.toEqual(document.body); +it("should attach to body when portal is disabled", async () => { + const { getByTestId } = await open({ + portalProps: { + disabled: true, + }, }); + expect(getByTestId("content").parentElement).not.toEqual(document.body); +}); - it("should portal to the target if passed as a prop", async () => { - await open({ - portalProps: { - to: "#portalTarget", - }, - }); - const portalTarget = screen.getByTestId("portalTarget"); - const content = screen.getByTestId("content"); - expect(content.parentElement).toEqual(portalTarget); +it("should portal to the target if passed as a prop", async () => { + const { getByTestId } = await open({ + portalProps: { + to: "#portalTarget", + }, }); - it("should not close when content is clicked", async () => { - const { user, getByTestId, queryByTestId } = await open(); - const content = getByTestId("content"); - await user.pointerDownUp(content); - await expectIsOpen(queryByTestId); - }); + expect(getByTestId("content").parentElement).toEqual(getByTestId("portalTarget")); +}); - it("should respect binding to the `open` prop", async () => { - const { getByTestId, queryByTestId, user } = setup(); - - const trigger = getByTestId("trigger"); - const binding = getByTestId("binding"); - expect(binding).toHaveTextContent("false"); - await user.pointerDownUp(trigger); - expect(binding).toHaveTextContent("true"); - await user.keyboard(kbd.ESCAPE); - expect(binding).toHaveTextContent("false"); - - const toggle = getByTestId("toggle"); - expectIsClosed(queryByTestId); - await user.click(toggle); - await expectIsOpen(queryByTestId); - }); +it("should not close when content is clicked", async () => { + const { user, getByTestId, queryByTestId } = await open(); + await user.pointerDownUp(getByTestId("content")); + await expectIsOpen(queryByTestId); +}); - it("should respect the `interactOutsideBehavior: 'ignore'` prop", async () => { - const { getByTestId, queryByTestId, user } = await open({ - contentProps: { - interactOutsideBehavior: "ignore", - }, - }); - await sleep(100); +it("should respect binding to the `open` prop", async () => { + const { getByTestId, queryByTestId, user } = setup(); - const overlay = getByTestId("overlay"); - await user.click(overlay); + const binding = getByTestId("binding"); + expect(binding).toHaveTextContent("false"); + await user.pointerDownUp(getByTestId("trigger")); + expect(binding).toHaveTextContent("true"); + await user.keyboard(kbd.ESCAPE); + expect(binding).toHaveTextContent("false"); - await expectIsOpen(queryByTestId); + expectIsClosed(queryByTestId); + await user.click(getByTestId("toggle")); + await expectIsOpen(queryByTestId); +}); + +it("should respect the `interactOutsideBehavior: 'ignore'` prop", async () => { + const { getByTestId, queryByTestId, user } = await open({ + contentProps: { + interactOutsideBehavior: "ignore", + }, }); - it("should respect the the `escapeKeydownBehavior: 'ignore'` prop", async () => { - const { user, getByTestId, queryByTestId } = await open({ - contentProps: { - escapeKeydownBehavior: "ignore", - }, - }); + await user.click(getByTestId("overlay")); + await expectIsOpen(queryByTestId); +}); - await user.keyboard(kbd.ESCAPE); - await expectIsOpen(queryByTestId); - expect(getByTestId("trigger")).not.toHaveFocus(); +it("should respect the the `escapeKeydownBehavior: 'ignore'` prop", async () => { + const { user, getByTestId, queryByTestId } = await open({ + contentProps: { + escapeKeydownBehavior: "ignore", + }, }); - it("should apply the correct `aria-describedby` attribute to the `Dialog.Content` element", async () => { - const { getByTestId } = await open(); + await user.keyboard(kbd.ESCAPE); + await expectIsOpen(queryByTestId); + expect(getByTestId("trigger")).not.toHaveFocus(); +}); - const content = getByTestId("content"); - const description = getByTestId("description"); - expect(content).toHaveAttribute("aria-describedby", description.id); - }); +it("should apply the correct `aria-describedby` attribute to the `Dialog.Content` element", async () => { + const { getByTestId } = await open(); - it("should apply a default `aria-level` attribute to the `AlertDialog.Title` element", async () => { - const { getByTestId } = await open(); + const content = getByTestId("content"); + const description = getByTestId("description"); + expect(content).toHaveAttribute("aria-describedby", description.id); +}); - const title = getByTestId("title"); - expect(title).toHaveAttribute("aria-level", "2"); - }); +it("should apply a default `aria-level` attribute to the `AlertDialog.Title` element", async () => { + const { getByTestId } = await open(); - it("should allow setting a custom level for the `AlertDialog.Title` element", async () => { - const { getByTestId } = await open({ - titleProps: { - level: 3, - }, - }); + expect(getByTestId("title")).toHaveAttribute("aria-level", "2"); +}); - const title = getByTestId("title"); - expect(title).toHaveAttribute("aria-level", "3"); +it("should allow setting a custom level for the `AlertDialog.Title` element", async () => { + const { getByTestId } = await open({ + titleProps: { + level: 3, + }, }); + + expect(getByTestId("title")).toHaveAttribute("aria-level", "3"); }); diff --git a/packages/tests/src/tests/avatar/avatar.test.ts b/packages/tests/src/tests/avatar/avatar.test.ts index 7b4b5b66e..5f9140ff5 100644 --- a/packages/tests/src/tests/avatar/avatar.test.ts +++ b/packages/tests/src/tests/avatar/avatar.test.ts @@ -1,7 +1,7 @@ import { render } from "@testing-library/svelte/svelte5"; import { userEvent } from "@testing-library/user-event"; import { axe } from "jest-axe"; -import { describe, it } from "vitest"; +import { it } from "vitest"; import AvatarTest from "./avatar-test.svelte"; const src = "https://github.com/huntabyte.png"; @@ -10,44 +10,42 @@ function setup(props: { src: string }) { return render(AvatarTest, { props }); } -describe("avatar", () => { - it("should have no accessibility violations", async () => { - const { container } = setup({ src }); - expect(await axe(container)).toHaveNoViolations(); - }); +it("should have no accessibility violations", async () => { + const { container } = setup({ src }); + expect(await axe(container)).toHaveNoViolations(); +}); - it("should have bits data attrs", async () => { - const { getByTestId } = setup({ src }); - const root = getByTestId("root"); - const image = getByTestId("image"); - const fallback = getByTestId("fallback"); - expect(root).toHaveAttribute("data-avatar-root"); - expect(image).toHaveAttribute("data-avatar-image"); - expect(fallback).toHaveAttribute("data-avatar-fallback"); - }); +it("should have bits data attrs", async () => { + const { getByTestId } = setup({ src }); + const root = getByTestId("root"); + const image = getByTestId("image"); + const fallback = getByTestId("fallback"); + expect(root).toHaveAttribute("data-avatar-root"); + expect(image).toHaveAttribute("data-avatar-image"); + expect(fallback).toHaveAttribute("data-avatar-fallback"); +}); - it("should render the image with the correct src", async () => { - const { getByAltText } = setup({ src }); - const avatar = getByAltText("huntabyte"); - expect(avatar).toHaveAttribute("src", "https://github.com/huntabyte.png"); - }); +it("should render the image with the correct src", async () => { + const { getByAltText } = setup({ src }); + const avatar = getByAltText("huntabyte"); + expect(avatar).toHaveAttribute("src", "https://github.com/huntabyte.png"); +}); - it("should render the fallback when an invalid image src is provided", async () => { - const { getByAltText, getByText } = setup({ src: "invalid" }); - const avatar = getByAltText("huntabyte"); - expect(avatar).not.toBeVisible(); - const fallback = getByText("HJ"); - expect(fallback).toBeVisible(); - }); +it("should render the fallback when an invalid image src is provided", async () => { + const { getByAltText, getByText } = setup({ src: "invalid" }); + const avatar = getByAltText("huntabyte"); + expect(avatar).not.toBeVisible(); + const fallback = getByText("HJ"); + expect(fallback).toBeVisible(); +}); - it("should remove the avatar when the src is removed", async () => { - const user = userEvent.setup(); - const { getByAltText, getByTestId, getByText } = setup({ src }); - const avatar = getByAltText("huntabyte"); - expect(avatar).toHaveAttribute("src", "https://github.com/huntabyte.png"); - const clearButton = getByTestId("clear-button"); - await user.click(clearButton); - expect(avatar).not.toBeVisible(); - expect(getByText("HJ")).toBeVisible(); - }); +it("should remove the avatar when the src is removed", async () => { + const user = userEvent.setup(); + const { getByAltText, getByTestId, getByText } = setup({ src }); + const avatar = getByAltText("huntabyte"); + expect(avatar).toHaveAttribute("src", "https://github.com/huntabyte.png"); + const clearButton = getByTestId("clear-button"); + await user.click(clearButton); + expect(avatar).not.toBeVisible(); + expect(getByText("HJ")).toBeVisible(); }); diff --git a/packages/tests/src/tests/checkbox/checkbox.test.ts b/packages/tests/src/tests/checkbox/checkbox.test.ts index 2f645df56..2cfdeca4d 100644 --- a/packages/tests/src/tests/checkbox/checkbox.test.ts +++ b/packages/tests/src/tests/checkbox/checkbox.test.ts @@ -1,6 +1,6 @@ import { render } from "@testing-library/svelte/svelte5"; import { axe } from "jest-axe"; -import { describe, it } from "vitest"; +import { describe, it, vi } from "vitest"; import { type ComponentProps, tick } from "svelte"; import type { Checkbox } from "bits-ui"; import { getTestKbd, setupUserEvents } from "../utils.js"; @@ -40,7 +40,7 @@ function setupGroup(props: ComponentProps = {}) { const getCheckbox = (v: string) => returned.getByTestId(`${v}-checkbox`); const getIndicator = (v: string) => returned.getByTestId(`${v}-indicator`); const checkboxes = items.map((v) => getCheckbox(v)); - const indicators = items.map((v) => returned.getByTestId(`${v}-indicator`)); + const indicators = items.map((v) => getIndicator(v)); return { ...returned, @@ -57,124 +57,128 @@ function setupGroup(props: ComponentProps = {}) { }; } -describe("checkbox", () => { - it("should have no accessibility violations", async () => { - const { container } = render(CheckboxTest); - expect(await axe(container)).toHaveNoViolations(); - }); +it("should have no accessibility violations", async () => { + const { container } = render(CheckboxTest); + expect(await axe(container)).toHaveNoViolations(); +}); - it("should have bits data attrs", async () => { - const { root } = setup(); - expect(root).toHaveAttribute("data-checkbox-root"); - }); +it("should have bits data attrs", async () => { + const { root } = setup(); + expect(root).toHaveAttribute("data-checkbox-root"); +}); - it("should not render the checkbox input if a name prop isn't passed", async () => { - const { input } = setup({ name: "" }); - expect(input).not.toBeInTheDocument(); - }); +it("should not render the checkbox input if a name prop isn't passed", async () => { + const { input } = setup({ name: "" }); + expect(input).not.toBeInTheDocument(); +}); - it("should render the checkbox input if a name prop is passed", async () => { - const { input } = setup({ name: "checkbox" }); - expect(input).toBeInTheDocument(); - }); +it("should render the checkbox input if a name prop is passed", async () => { + const { input } = setup({ name: "checkbox" }); + expect(input).toBeInTheDocument(); +}); - it('should default the value to "on", when no value prop is passed', async () => { - const { input } = setup(); - expect(input).toHaveAttribute("value", "on"); - }); +it('should default the value to "on", when no value prop is passed', async () => { + const { input } = setup(); + expect(input).toHaveAttribute("value", "on"); +}); - it("should be able to be indeterminate", async () => { - const { getByTestId, root, input } = setup({ indeterminate: true }); - const indicator = getByTestId("indicator"); - expect(root).toHaveAttribute("data-state", "indeterminate"); - expect(root).toHaveAttribute("aria-checked", "mixed"); - expect(input.checked).toBe(false); - expect(indicator).toHaveTextContent("indeterminate"); - expect(indicator).not.toHaveTextContent("true"); - expect(indicator).not.toHaveTextContent("false"); - }); +it("should be able to be indeterminate", async () => { + const { getByTestId, root, input } = setup({ indeterminate: true }); + const indicator = getByTestId("indicator"); + expect(root).toHaveAttribute("data-state", "indeterminate"); + expect(root).toHaveAttribute("aria-checked", "mixed"); + expect(input.checked).toBe(false); + expect(indicator).toHaveTextContent("indeterminate"); + expect(indicator).not.toHaveTextContent("true"); + expect(indicator).not.toHaveTextContent("false"); +}); - it("should toggle when clicked", async () => { - const { getByTestId, root, input, user } = setup(); - const indicator = getByTestId("indicator"); - expectUnchecked(root); - expect(input.checked).toBe(false); - expect(indicator).toHaveTextContent("false"); - expect(indicator).not.toHaveTextContent("true"); - expect(indicator).not.toHaveTextContent("indeterminate"); - await user.click(root); - expectChecked(root); - expect(input.checked).toBe(true); - expect(indicator).toHaveTextContent("true"); - expect(indicator).not.toHaveTextContent("false"); - expect(indicator).not.toHaveTextContent("indeterminate"); - }); +it("should toggle when clicked", async () => { + const { getByTestId, root, input, user } = setup(); + const indicator = getByTestId("indicator"); + expectUnchecked(root); + expect(input.checked).toBe(false); + expect(indicator).toHaveTextContent("false"); + expect(indicator).not.toHaveTextContent("true"); + expect(indicator).not.toHaveTextContent("indeterminate"); + await user.click(root); + expectChecked(root); + expect(input.checked).toBe(true); + expect(indicator).toHaveTextContent("true"); + expect(indicator).not.toHaveTextContent("false"); + expect(indicator).not.toHaveTextContent("indeterminate"); +}); - it("should toggle when the `Space` key is pressed", async () => { - const { root, input, user } = setup(); - expectUnchecked(root); - expect(input.checked).toBe(false); - root.focus(); - await user.keyboard(kbd.SPACE); - expectChecked(root); - expect(input.checked).toBe(true); - }); +it("should toggle when the `Space` key is pressed", async () => { + const { root, input, user } = setup(); + expectUnchecked(root); + expect(input.checked).toBe(false); + root.focus(); + await user.keyboard(kbd.SPACE); + expectChecked(root); + expect(input.checked).toBe(true); +}); - it("should not toggle when the `Enter` key is pressed", async () => { - const { getByTestId, root, input, user } = setup(); - const indicator = getByTestId("indicator"); - expectUnchecked(root); - expect(input.checked).toBe(false); - expect(indicator).toHaveTextContent("false"); - expect(indicator).not.toHaveTextContent("true"); - expect(indicator).not.toHaveTextContent("indeterminate"); - root.focus(); - await user.keyboard(kbd.ENTER); - expectUnchecked(root); - expect(indicator).toHaveTextContent("false"); - expect(indicator).not.toHaveTextContent("true"); - expect(indicator).not.toHaveTextContent("indeterminate"); - expect(input.checked).toBe(false); - }); +it("should not toggle when the `Enter` key is pressed", async () => { + const { getByTestId, root, input, user } = setup(); + const indicator = getByTestId("indicator"); + expectUnchecked(root); + expect(input.checked).toBe(false); + expect(indicator).toHaveTextContent("false"); + expect(indicator).not.toHaveTextContent("true"); + expect(indicator).not.toHaveTextContent("indeterminate"); + root.focus(); + await user.keyboard(kbd.ENTER); + expectUnchecked(root); + expect(indicator).toHaveTextContent("false"); + expect(indicator).not.toHaveTextContent("true"); + expect(indicator).not.toHaveTextContent("indeterminate"); + expect(input.checked).toBe(false); +}); - it("should be disabled when the `disabled` prop is passed", async () => { - const { root, input, user } = setup({ disabled: true }); - expectUnchecked(root); - expect(input.checked).toBe(false); - expect(input.disabled).toBe(true); - await user.click(root); - expectUnchecked(root); - expect(root).toBeDisabled(); - expect(input.checked).toBe(false); - }); +it("should be disabled when the `disabled` prop is passed", async () => { + const { root, input, user } = setup({ disabled: true }); + expectUnchecked(root); + expect(input.checked).toBe(false); + expect(input.disabled).toBe(true); + await user.click(root); + expectUnchecked(root); + expect(root).toBeDisabled(); + expect(input.checked).toBe(false); +}); - it("should be required when the `required` prop is passed", async () => { - const { root, input } = setup({ required: true }); - expect(root).toHaveAttribute("aria-required", "true"); - expect(input.required).toBe(true); - }); +it("should be required when the `required` prop is passed", async () => { + const { root, input } = setup({ required: true }); + expect(root).toHaveAttribute("aria-required", "true"); + expect(input.required).toBe(true); +}); - it('should fire the "onChange" callback when changing', async () => { - let newValue: boolean | "indeterminate" = false; - function onCheckedChange(next: boolean | "indeterminate") { - newValue = next; - } - const { root, user } = setup({ onCheckedChange }); - await user.click(root); - expect(newValue).toBe(true); - }); +it('should fire the "onCheckedChange" callback when changing', async () => { + const mock = vi.fn(); + const { root, user } = setup({ onCheckedChange: mock }); + await user.click(root); + expect(mock).toHaveBeenCalledWith(true); + await user.click(root); + expect(mock).toHaveBeenCalledWith(false); +}); - it("should respect binding the `checked` prop", async () => { - const { root, getByTestId, user } = setup(); - const binding = getByTestId("binding"); - expect(binding).toHaveTextContent("false"); - await user.click(root); - await tick(); - expect(binding).toHaveTextContent("true"); - }); +it("should fire the 'onIndeterminateChange' callback when changing from indeterminate", async () => { + const mock = vi.fn(); + const { root, user } = setup({ onIndeterminateChange: mock, indeterminate: true }); + await user.click(root); + expect(mock).toHaveBeenCalledWith(false); +}); + +it("should respect binding the `checked` prop", async () => { + const { root, getByTestId, user } = setup(); + const binding = getByTestId("binding"); + expect(binding).toHaveTextContent("false"); + await user.click(root); + await tick(); + expect(binding).toHaveTextContent("true"); }); -describe("checkbox group", () => { +describe("group", () => { it("should have no accessibility violations", async () => { const { container } = render(CheckboxGroupTest); expect(await axe(container)).toHaveNoViolations(); @@ -246,6 +250,23 @@ describe("checkbox group", () => { expectChecked(c, d); }); + it("should call the `onValueChange` callback when the value changes", async () => { + const mock = vi.fn(); + const t = setupGroup({ + onValueChange: mock, + }); + + const [a, b] = t.checkboxes; + await t.user.click(a); + expect(mock).toHaveBeenCalledWith(["a"]); + await t.user.click(b); + expect(mock).toHaveBeenCalledWith(["a", "b"]); + await t.user.click(a); + expect(mock).toHaveBeenCalledWith(["b"]); + await t.user.click(b); + expect(mock).toHaveBeenCalledWith([]); + }); + it("should propagate disabled state to children checkboxes", async () => { const t = setupGroup({ disabled: true, diff --git a/packages/tests/src/tests/collapsible/collapsible.test.ts b/packages/tests/src/tests/collapsible/collapsible.test.ts index 483a6d522..c89335c4f 100644 --- a/packages/tests/src/tests/collapsible/collapsible.test.ts +++ b/packages/tests/src/tests/collapsible/collapsible.test.ts @@ -1,6 +1,6 @@ import { render } from "@testing-library/svelte/svelte5"; import { axe } from "jest-axe"; -import { describe, it } from "vitest"; +import { it, vi } from "vitest"; import type { Component } from "svelte"; import type { Collapsible } from "bits-ui"; import { setupUserEvents } from "../utils.js"; @@ -29,65 +29,74 @@ function setup( }; } -describe("collapsible", () => { - it("should have no accessibility violations", async () => { - const { container } = render(CollapsibleTest); - expect(await axe(container)).toHaveNoViolations(); - }); +it("should have no accessibility violations", async () => { + const { container } = render(CollapsibleTest); + expect(await axe(container)).toHaveNoViolations(); +}); - it("should have bits data attrs", async () => { - const { root, trigger, content } = setup(); - expect(root).toHaveAttribute("data-collapsible-root"); - expect(trigger).toHaveAttribute("data-collapsible-trigger"); - expect(content).toHaveAttribute("data-collapsible-content"); - }); +it("should have bits data attrs", async () => { + const { root, trigger, content } = setup(); + expect(root).toHaveAttribute("data-collapsible-root"); + expect(trigger).toHaveAttribute("data-collapsible-trigger"); + expect(content).toHaveAttribute("data-collapsible-content"); +}); - it("should hide content when `open` is false", async () => { - const { root, trigger, content } = setup(); - expect(root).not.toBeNull(); - expect(trigger).not.toBeNull(); - expect(content).not.toBeVisible(); - }); +it("should hide content when `open` is false", async () => { + const { root, trigger, content } = setup(); + expect(root).not.toBeNull(); + expect(trigger).not.toBeNull(); + expect(content).not.toBeVisible(); +}); - it("should toggle the `open` state when clicked", async () => { - const { user, trigger, content } = setup(); - expect(content).not.toBeVisible(); - await user.click(trigger); - expect(content).toBeVisible(); - await user.click(trigger); - expect(content).not.toBeVisible(); - }); +it("should toggle the `open` state when clicked", async () => { + const { user, trigger, content } = setup(); + expect(content).not.toBeVisible(); + await user.click(trigger); + expect(content).toBeVisible(); + await user.click(trigger); + expect(content).not.toBeVisible(); +}); - it("should respect binds to the `open` prop", async () => { - const { getByTestId, user, trigger, binding } = setup({ open: false }); - expect(binding).toHaveTextContent("false"); - await user.click(trigger); - expect(binding).toHaveTextContent("true"); - const altTrigger = getByTestId("alt-trigger"); - await user.click(altTrigger); - expect(binding).toHaveTextContent("false"); - }); +it("should respect binds to the `open` prop", async () => { + const { getByTestId, user, trigger, binding } = setup({ open: false }); + expect(binding).toHaveTextContent("false"); + await user.click(trigger); + expect(binding).toHaveTextContent("true"); + const altTrigger = getByTestId("alt-trigger"); + await user.click(altTrigger); + expect(binding).toHaveTextContent("false"); +}); - it("should forceMount the content when `forceMount` is true", async () => { - const { getByTestId } = setup({ withOpenCheck: false }, CollapsibleForceMountTest); - const content = getByTestId("content"); - expect(content).toBeVisible(); +it("should call `onOpenChange` when the open state changes", async () => { + const mock = vi.fn(); + const t = setup({ + onOpenChange: mock, }); + await t.user.click(t.trigger); + expect(mock).toHaveBeenCalledWith(true); + await t.user.click(t.trigger); + expect(mock).toHaveBeenCalledWith(false); +}); - it("should forceMount the content when `forceMount` is true and the `open` snippet prop is used to conditionally render the content", async () => { - const { getByTestId, queryByTestId, user } = setup( - { - withOpenCheck: true, - }, - CollapsibleForceMountTest - ); - const initContent = queryByTestId("content"); - expect(initContent).toBeNull(); +it("should forceMount the content when `forceMount` is true", async () => { + const { getByTestId } = setup({ withOpenCheck: false }, CollapsibleForceMountTest); + const content = getByTestId("content"); + expect(content).toBeVisible(); +}); - const trigger = getByTestId("trigger"); - await user.click(trigger); +it("should forceMount the content when `forceMount` is true and the `open` snippet prop is used to conditionally render the content", async () => { + const { getByTestId, queryByTestId, user } = setup( + { + withOpenCheck: true, + }, + CollapsibleForceMountTest + ); + const initContent = queryByTestId("content"); + expect(initContent).toBeNull(); - const content = getByTestId("content"); - expect(content).toBeVisible(); - }); + const trigger = getByTestId("trigger"); + await user.click(trigger); + + const content = getByTestId("content"); + expect(content).toBeVisible(); }); diff --git a/packages/tests/src/tests/combobox/combobox.test.ts b/packages/tests/src/tests/combobox/combobox.test.ts index 3cdf3e896..483e63e24 100644 --- a/packages/tests/src/tests/combobox/combobox.test.ts +++ b/packages/tests/src/tests/combobox/combobox.test.ts @@ -1,6 +1,6 @@ import { render, waitFor } from "@testing-library/svelte"; import { axe } from "jest-axe"; -import { describe, it } from "vitest"; +import { describe, it, vi } from "vitest"; import { type Component, tick } from "svelte"; import { type AnyFn, @@ -264,6 +264,16 @@ describe("combobox - single", () => { expect(valueBinding).toHaveTextContent("empty"); }); + it("should call `onValueChange` when the value changes", async () => { + const mock = vi.fn(); + const t = await openSingle({ + onValueChange: mock, + }); + const [item1] = getItems(t.getByTestId); + await t.user.click(item1); + expect(mock).toHaveBeenCalledWith("1"); + }); + it("should select items when clicked", async () => { const { getByTestId, user, queryByTestId, input, getHiddenInput } = await openSingle(); const [item1] = getItems(getByTestId); @@ -585,6 +595,14 @@ describe("combobox - multiple", () => { expect(valueBinding.textContent).toEqual("empty"); }); + it("should call `onValueChange` when the value changes", async () => { + const mock = vi.fn(); + const t = await openMultiple({ value: ["1", "2"], onValueChange: mock }); + const [item1] = getItems(t.getByTestId); + await t.user.click(item1); + expect(mock).toHaveBeenCalledWith(["2"]); + }); + it("should select items when clicked", async () => { const { getByTestId, user, queryByTestId, input, getHiddenInputs } = await openMultiple(); const [item] = getItems(getByTestId); diff --git a/sites/docs/src/lib/components/demos/checkbox-demo-group.svelte b/sites/docs/src/lib/components/demos/checkbox-demo-group.svelte index c0acafb54..b1d05fe15 100644 --- a/sites/docs/src/lib/components/demos/checkbox-demo-group.svelte +++ b/sites/docs/src/lib/components/demos/checkbox-demo-group.svelte @@ -6,7 +6,12 @@ let myValue = $state(["marketing", "news"]); - + console.log(v)} +> Notifications