Skip to content

Commit

Permalink
listbox tests
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte committed Jul 12, 2024
1 parent 9e910f4 commit 3b1e18a
Show file tree
Hide file tree
Showing 7 changed files with 331 additions and 3 deletions.
9 changes: 9 additions & 0 deletions packages/bits-ui/src/lib/bits/listbox/listbox.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { afterTick } from "$lib/internal/afterTick.js";

const LISTBOX_ITEM_ATTR = "data-listbox-item";
const LISTBOX_CONTENT_ATTR = "data-listbox-content";
const LISTOX_LABEL_ATTR = "data-listbox-label";
const LISTBOX_GROUP_ATTR = "data-listbox-group";
const LISTBOX_GROUP_LABEL_ATTR = "data-listbox-group-label";

export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE];
export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME];
export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END];
Expand Down Expand Up @@ -138,6 +142,7 @@ export class ListboxContentState {
#handleTypeaheadSearch: ReturnType<typeof useTypeahead>["handleTypeaheadSearch"];
focusedItemId = $state("");
focusWithin = new IsFocusWithin(() => this.ref.value ?? undefined);
#labelledBy = $derived.by(() => this.root.labelNode?.id ?? undefined);

constructor(props: ListboxContentStateProps, root: ListboxRootState) {
this.id = props.id;
Expand Down Expand Up @@ -308,6 +313,7 @@ export class ListboxContentState {
({
id: this.id.value,
"data-orientation": getDataOrientation(this.root.orientation.value),
"aria-labelledby": this.#labelledBy,
role: "listbox",
[LISTBOX_CONTENT_ATTR]: "",
onkeydown: this.#onkeydown,
Expand Down Expand Up @@ -345,6 +351,7 @@ export class ListboxLabelState {
({
id: this.id.value,
"data-orientation": getDataOrientation(this.root.orientation.value),
[LISTOX_LABEL_ATTR]: "",
}) as const
);
}
Expand Down Expand Up @@ -484,6 +491,7 @@ export class ListboxGroupState {
"data-orientation": getDataOrientation(this.root.orientation.value),
role: "group",
"aria-labelledby": this.#ariaLabelledBy,
[LISTBOX_GROUP_ATTR]: "",
}) as const
);

Expand Down Expand Up @@ -519,6 +527,7 @@ export class ListboxGroupLabelState {
({
id: this.id.value,
"data-orientation": getDataOrientation(this.group.root.orientation.value),
[LISTBOX_GROUP_LABEL_ATTR]: "",
}) as const
);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/bits-ui/src/lib/bits/listbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export type ListboxRootPropsWithoutHTML =
| ListboxSingleRootPropsWithoutHTML
| ListboxMultipleRootPropsWithoutHTML;

export type ListboxSingleRootProps = ListboxSingleRootPropsWithoutHTML;

export type ListboxMultipleRootProps = ListboxMultipleRootPropsWithoutHTML;

export type ListboxRootProps = ListboxRootPropsWithoutHTML;

export type ListboxContentPropsWithoutHTML = WithChild;
Expand Down
240 changes: 240 additions & 0 deletions packages/bits-ui/src/tests/listbox/Listbox.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { render } from "@testing-library/svelte";
import { axe } from "jest-axe";
import { describe, it } from "vitest";
import { fireFocus, getTestKbd, setupUserEvents } from "../utils.js";
import ListboxTest, { type ListboxTestProps, defaultItems } from "./ListboxTest.svelte";

const kbd = getTestKbd();

function setup(props: Partial<ListboxTestProps> = {}) {
const user = setupUserEvents();
const returned = render(ListboxTest, { ...props });
const label = returned.getByTestId("label");
const content = returned.getByTestId("content");
const group = returned.getByTestId("group");
const groupLabel = returned.getByTestId("group-label");
const value = returned.getByTestId("value");
const binding = returned.getByTestId("binding");
const items: HTMLElement[] = [];

for (const item of defaultItems) [items.push(returned.getByTestId(`item-${item.value}`))];

return {
...returned,
value,
binding,
user,
label,
content,
group,
groupLabel,
items,
};
}

describe("listbox - single", () => {
it("has no accessibility violations", async () => {
const { container } = setup();
expect(await axe(container)).toHaveNoViolations();
});

it("has bits data attrs", async () => {
const { label, content, group, groupLabel, items } = setup();
expect(label).toHaveAttribute("data-listbox-label");
expect(content).toHaveAttribute("data-listbox-content");
expect(group).toHaveAttribute("data-listbox-group");
expect(groupLabel).toHaveAttribute("data-listbox-group-label");
expect(items[0]).toHaveAttribute("data-listbox-item");
});

it("selects an item when clicked and deselects when clicked again", async () => {
const { user, items, value } = setup();
const item0 = items[0]!;

await user.click(item0);
expectIsSelected(item0);
expect(item0).toHaveFocus();
expect(value.textContent).toEqual(defaultItems[0]!.value);
await user.click(item0);
expectIsNotSelected(item0);
expect(value.textContent).toEqual("");
});

it.each([kbd.ENTER, kbd.SPACE])(
"selects and deselects an item when focused with %s key",
async (key) => {
const { user, items, value } = setup();
const item0 = items[0]!;

await fireFocus(item0);
item0.focus();
await user.keyboard(key);
expectIsSelected(item0);
expect(item0).toHaveFocus();
expect(value.textContent).toEqual(defaultItems[0]!.value);
await user.keyboard(key);
expectIsNotSelected(item0);
expect(value.textContent).toEqual("");
}
);

it("navigations through the list items using the arrow keys (vertical) (no loop)", async () => {
const { user, items } = setup();

await fireFocus(items[0]!);
const totalItems = items.length;

// moving down
for (let i = 0; i < totalItems; i++) {
if (i === 0) expectIsHighlighted(items[i]!);

await user.keyboard(kbd.ARROW_DOWN);

if (i < totalItems - 1) {
expectIsNotHighlighted(items[i]!);
expectIsHighlighted(items[i + 1]!);
} else {
// for the last item, it should remain highlighted
expectIsHighlighted(items[i]!);
}
}

// pressing down again on the last item
await user.keyboard(kbd.ARROW_DOWN);
expectIsHighlighted(items[totalItems - 1]!);

// moving up
for (let i = totalItems - 1; i > 0; i--) {
await user.keyboard(kbd.ARROW_UP);
expectIsNotHighlighted(items[i]!);
expectIsHighlighted(items[i - 1]!);
}
});

it("navigations through the list items using the arrow keys (vertical) (loop)", async () => {
const { user, items } = setup({ loop: true });

await fireFocus(items[0]!);
const totalItems = items.length;
// cycling through the items twice
const iterations = 2;

for (let cycle = 0; cycle < iterations; cycle++) {
for (let i = 0; i < totalItems; i++) {
if (i === 0 && cycle === 0) expectIsHighlighted(items[i]!);

await user.keyboard(kbd.ARROW_DOWN);

expectIsNotHighlighted(items[i]!);
expectIsHighlighted(items[(i + 1) % totalItems]!);
}
}

// check we've returned to the initial state
expectIsHighlighted(items[0]!);
});

it("navigations through the list items using the arrow keys (horizontal) (no loop)", async () => {
const { user, items } = setup({ orientation: "horizontal" });

await fireFocus(items[0]!);

const totalItems = items.length;

for (let i = 0; i < totalItems; i++) {
if (i === 0) expectIsHighlighted(items[i]!);

await user.keyboard(kbd.ARROW_RIGHT);

if (i < totalItems - 1) {
expectIsNotHighlighted(items[i]!);
expectIsHighlighted(items[i + 1]!);
} else {
// for the last item, it should remain highlighted
expectIsHighlighted(items[i]!);
}
}

// now go back
for (let i = totalItems - 1; i > 0; i--) {
await user.keyboard(kbd.ARROW_LEFT);
expectIsNotHighlighted(items[i]!);
expectIsHighlighted(items[i - 1]!);
}
});

it("navigations through the list items using the arrow keys (horizontal) (loop)", async () => {
const { user, items } = setup({ loop: true, orientation: "horizontal" });

await fireFocus(items[0]!);
const totalItems = items.length;
// cycling through the items twice
const iterations = 2;

for (let cycle = 0; cycle < iterations; cycle++) {
for (let i = 0; i < totalItems; i++) {
if (i === 0 && cycle === 0) expectIsHighlighted(items[i]!);

await user.keyboard(kbd.ARROW_RIGHT);

expectIsNotHighlighted(items[i]!);
expectIsHighlighted(items[(i + 1) % totalItems]!);
}
}

// check we've returned to the initial state
expectIsHighlighted(items[0]!);
});

it("focuses the first item when no items are selected", async () => {
const { user, items } = setup();

await user.keyboard(kbd.TAB);
expect(items[0]!).toHaveFocus();
});

it("focuses the selected item when a selected item is present", async () => {
const { user, items } = setup({
value: defaultItems[1]!.value,
});

await user.keyboard(kbd.TAB);
expect(items[1]!).toHaveFocus();
});

it("focuses the first item when the `HOME` key is pressed and focus is within the listbox", async () => {
const { user, items } = setup();

await fireFocus(items[2]!);
expect(items[2]!).toHaveFocus();
expect(items[0]!).not.toHaveFocus();
await user.keyboard(kbd.HOME);
expect(items[0]!).toHaveFocus();
});

it("focuses the last item when the `END` key is pressed and focus is within the listbox", async () => {
const { user, items } = setup();

await fireFocus(items[2]!);
expect(items[2]!).toHaveFocus();
await user.keyboard(kbd.END);
expect(items[3]!).toHaveFocus();
});
});

function expectIsHighlighted(item: HTMLElement) {
expect(item).toHaveAttribute("data-highlighted");
}

function expectIsNotHighlighted(item: HTMLElement) {
expect(item).not.toHaveAttribute("data-highlighted");
}

function expectIsSelected(item: HTMLElement) {
expect(item).toHaveAttribute("data-selected");
expect(item).toHaveAttribute("aria-selected", "true");
}
function expectIsNotSelected(item: HTMLElement) {
expect(item).not.toHaveAttribute("data-selected");
expect(item).not.toHaveAttribute("aria-selected", "true");
}
Empty file.
70 changes: 70 additions & 0 deletions packages/bits-ui/src/tests/listbox/ListboxTest.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script lang="ts" context="module">
import type { ListboxSingleRootProps } from "$lib/bits/listbox/types.js";
import { Listbox } from "$lib/index.js";
export type ListboxItemObj = {
value: string;
label: string;
disabled: boolean;
};
export const defaultItems: ListboxItemObj[] = [
{
value: "1",
label: "A",
disabled: false,
},
{
value: "2",
label: "B",
disabled: false,
},
{
value: "3",
label: "C",
disabled: false,
},
{
value: "4",
label: "D",
disabled: false,
},
];
export type ListboxTestProps = Omit<ListboxSingleRootProps, "type"> & {
items?: ListboxItemObj[];
};
</script>

<script lang="ts">
let { items = defaultItems, value: defaultValue, ...restProps }: ListboxTestProps = $props();
let value = $state(defaultValue);
</script>

<div data-testid="value">
{value}
</div>

<button tabindex="-1" data-testid="binding" onclick={() => (value = items[0]?.value ?? "")}>
Binding
</button>

<Listbox.Root bind:value type="single" {...restProps}>
<Listbox.Label data-testid="label">Label</Listbox.Label>
<Listbox.Content data-testid="content">
<Listbox.Group data-testid="group">
<Listbox.GroupLabel data-testid="group-label">Options</Listbox.GroupLabel>
{#each items as item}
<Listbox.Item
value={item.value}
label={item.label}
disabled={item.disabled}
data-testid="item-{item.value}"
>
{item.label}
</Listbox.Item>
{/each}
</Listbox.Group>
</Listbox.Content>
</Listbox.Root>
2 changes: 1 addition & 1 deletion packages/bits-ui/src/tests/select/Select.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, waitFor } from "@testing-library/svelte/svelte5";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { axe } from "jest-axe";
import { describe, it, vi } from "vitest";
import { getTestKbd, setupUserEvents } from "../utils.js";
Expand Down
9 changes: 7 additions & 2 deletions packages/bits-ui/src/tests/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fireEvent, type Matcher, type MatcherOptions } from "@testing-library/svelte";
import { getKbd, sleep } from "$lib/internal/index.js";
import { type Matcher, type MatcherOptions, fireEvent } from "@testing-library/svelte";
import { userEvent } from "@testing-library/user-event";
import { getKbd, sleep } from "$lib/internal/index.js";

/**
* A wrapper around the internal kbd object to make it easier to use in tests
Expand Down Expand Up @@ -67,3 +67,8 @@ export function setupUserEvents(): CustomUserEvents {

return user as unknown as CustomUserEvents;
}

export async function fireFocus(node: HTMLElement) {
node.focus();
await sleep(20);
}

0 comments on commit 3b1e18a

Please sign in to comment.