Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

next: Listbox #603

Merged
merged 16 commits into from
Jul 12, 2024
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);
}
Loading
Loading