Compare commits

..
5 Commits
4 changed files with 51 additions and 31 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();
+5 -5
View File
@@ -16745,12 +16745,12 @@ __metadata:
linkType: hard
"launch-editor@npm:^2.14.1":
version: 2.14.2
resolution: "launch-editor@npm:2.14.2"
version: 2.14.1
resolution: "launch-editor@npm:2.14.1"
dependencies:
picocolors: "npm:^1.1.1"
shell-quote: "npm:^1.10.0"
checksum: 10/8a3a82314ed6705d8f6795c409ea31ed2a106d0f0bc0528a60f8b53ec972cce1db9088bb4647987b2ef4bb16b804fcc85e54529acaa26aae22f39eaba704401b
shell-quote: "npm:^1.8.4"
checksum: 10/335d12ca437280e77070657531c251b6c91c62bc653f70ab66ddd2a6e50131b1b043480411c5b93d54955a0a6eb8ec01e9a5b5cfe2d887341d878d19394a126b
languageName: node
linkType: hard
@@ -22139,7 +22139,7 @@ __metadata:
languageName: node
linkType: hard
"shell-quote@npm:1.12.0, shell-quote@npm:^1.10.0":
"shell-quote@npm:1.12.0, shell-quote@npm:^1.8.4":
version: 1.12.0
resolution: "shell-quote@npm:1.12.0"
checksum: 10/18b3e382272804e79fd3187c18877cfd4e736b5afb576bf1ab66a561b0a0e26eada4b7c8eb45473d1f17268ffcf12d0aaa90b9235132123eb673df5d0a5df943