Compare commits

...
5 Commits
3 changed files with 46 additions and 26 deletions

No files matched your search

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, fireEvent, fn } from "storybook/test";
import { expect, fireEvent, fn, within } from "storybook/test";
import type { ListboxOptionProps } from "./option/ListboxOption";
import { Listbox } from "./root/ListboxRoot";
@@ -212,10 +212,14 @@ export const EnterKey: StoryObj<{
},
};
const ListboxStoryWithSelectedOption = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
const ListboxStoryWithFilterInput = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] =
useState("second-item");
return (
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionId}>
<input type="text" aria-label="Filter" />
</Listbox.InputSlot>
<Listbox.Options>
<Listbox.Option
id="first-item"
@@ -226,12 +230,12 @@ const ListboxStoryWithSelectedOption = () => {
First item
</Listbox.Option>
<Listbox.Option
id="selected-item"
id="second-item"
onClick={() => {}}
hasVirtualFocus={virtuallyFocusedOptionId === "selected-item"}
aria-selected
hasVirtualFocus={virtuallyFocusedOptionId === "second-item"}
aria-selected={false}
>
Selected item
Second item
</Listbox.Option>
<Listbox.Option
id="last-item"
@@ -246,19 +250,30 @@ const ListboxStoryWithSelectedOption = () => {
);
};
// When there's a selected option, but no option has virtual focus,
// pressing ArrowUp or ArrowDown should focus the selected option.
export const ArrowDownFocusesSelectedOption: StoryObj = {
render: ListboxStoryWithSelectedOption,
// When a filter input has real focus, ArrowUp on first option and
// ArrowDown on last option should remove virtual focus so that
// perceived focus is returned to the input.
export const ArrowUpOnFirstOptionFocusInput: StoryObj = {
render: ListboxStoryWithFilterInput,
play: async ({ canvasElement }) => {
pressKey(canvasElement, "ArrowDown");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Filter");
input.focus();
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(getVirtuallyFocusedValue(canvasElement)).toBe("first-item");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(getVirtuallyFocusedValue(canvasElement)).toBeUndefined();
},
};
export const ArrowUpFocusesSelectedOption: StoryObj = {
render: ListboxStoryWithSelectedOption,
export const ArrowDownOnLastOptionFocusInput: StoryObj = {
render: ListboxStoryWithFilterInput,
play: async ({ canvasElement }) => {
pressKey(canvasElement, "ArrowUp");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Filter");
input.focus();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(getVirtuallyFocusedValue(canvasElement)).toBe("last-item");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(getVirtuallyFocusedValue(canvasElement)).toBeUndefined();
},
};
@@ -24,6 +24,7 @@ const ListboxInputSlot = ({
// @ts-expect-error - You are meant to use an <input>, but Slot doesn't know that.
autoComplete="off"
role="combobox"
data-aksel-listbox-input
>
{children}
</Slot>
@@ -72,30 +72,34 @@ const ListboxComponent = forwardRef<HTMLDivElement, ListboxProps>(
listbox.focus();
}
const virtuallyFocusWithFallback = (
getNextElement: (currentOption: HTMLElement) => HTMLElement | null,
const handleArrowUpOrDown = (
getNextOption: (currentOption: HTMLElement) => HTMLElement | null,
getFallback: () => HTMLElement | null,
) => {
event.preventDefault();
if (!focusedOptionElm) {
const fallback =
listbox.querySelector<HTMLElement>('[aria-selected="true"]') ||
getFallback();
virtuallyFocusOption(fallback);
virtuallyFocusOption(getFallback());
return;
}
const nextOption = getNextElement(focusedOptionElm);
const nextOption = getNextOption(focusedOptionElm);
if (nextOption) {
virtuallyFocusOption(nextOption);
return;
}
// No nextOption: Assume you pressed up on first option or down on last option
const activeElement = document.activeElement as HTMLElement;
if (activeElement.dataset?.akselListboxInput) {
setVirtuallyFocusedOptionId("");
return;
}
};
switch (event.key) {
case "ArrowUp":
virtuallyFocusWithFallback(findPrevOption, getLastOption);
handleArrowUpOrDown(findPrevOption, getLastOption);
break;
case "ArrowDown":
virtuallyFocusWithFallback(findNextOption, getFirstOption);
handleArrowUpOrDown(findNextOption, getFirstOption);
break;
case "PageUp":
event.preventDefault();