import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { type ReactNode, useState } from "react"; import { Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { Story, neutralAndAccents } from "../story-components/Story"; import { StoryGrid } from "../story-components/StoryGrid"; import { InputTextAutocomplete } from "./InputTextAutocomplete"; import type { InputTextAutocompleteProps } from "./InputTextAutocomplete.shared"; type ThisStory = StoryObj; function FruitAutocomplete( props: Omit, ): ReactNode { return ( ); } function ControlledFruitAutocomplete(): ReactNode { const [value, setValue] = useState("banana"); return ( value: {value} ); } export default { title: "alouette/Inputs/InputTextAutocomplete", component: InputTextAutocomplete, parameters: { componentSubtitle: "Text input with a filtered listbox, built on downshift.", }, argTypes: { placeholder: { control: "text" }, disabled: { control: "boolean" }, emptyLabel: { control: "text" }, }, } satisfies Meta; export const PreviewInputTextAutocompleteStory: ThisStory = { parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, args: { placeholder: "Search a fruit..." }, render: (args) => , }; export const Variants: ThisStory = { render: () => ( {neutralAndAccents.map((accent) => ( ))} option.label.toLowerCase().startsWith(inputValue.toLowerCase()) } onValueChange={fn()} /> ), }; export const Tests: StoryObj = { name: "InputTextAutocomplete Tests", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // The menu is portaled out of the canvas, so it is queried from `screen`. const input = canvas.getByLabelText("Fruit"); // A controlled `value` starts the field on the selected label, not empty. await expect(canvas.getByLabelText("Controlled fruit")).toHaveValue( "Banana", ); await expect(input).toHaveAttribute("role", "combobox"); await expect(input).toHaveAttribute("aria-expanded", "false"); await userEvent.type(input, "ap"); await expect(input).toHaveAttribute("aria-expanded", "true"); const listbox = screen.getByRole("listbox"); await expect(within(listbox).getAllByRole("option")).toHaveLength(2); await expect(input).toHaveAttribute( "aria-controls", listbox.getAttribute("id"), ); await userEvent.keyboard("{ArrowDown}"); await expect(input).toHaveAttribute( "aria-activedescendant", within(listbox).getByRole("option", { name: "Apple" }).getAttribute("id"), ); await userEvent.keyboard("{Enter}"); await expect(input).toHaveValue("Apple"); await expect(input).toHaveAttribute("aria-expanded", "false"); await expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); // The press path, not the keyboard one. await userEvent.clear(input); await userEvent.type(input, "cher"); await userEvent.click( within(screen.getByRole("listbox")).getByRole("option", { name: "Cherry", }), ); await expect(input).toHaveValue("Cherry"); await expect(input).toHaveAttribute("aria-expanded", "false"); // Clicking the input reopens the menu, unfiltered by the selected label. await userEvent.click(input); const reopened = screen.getByRole("listbox"); await expect(within(reopened).getAllByRole("option")).toHaveLength(6); // `aria-selected` reports the selection, not downshift's keyboard cursor — // that one is carried by `aria-activedescendant`. await userEvent.keyboard("{ArrowDown}"); await expect( within(reopened).getByRole("option", { name: "Cherry" }), ).toHaveAttribute("aria-selected", "true"); await expect( within(reopened).getByRole("option", { name: "Apple" }), ).toHaveAttribute("aria-selected", "false"); // The pointer moves that cursor as well, so the hovered row is the only // highlighted one: downshift's `onMouseMove` is a DOM handler a // react-native-web Pressable drops, hence the row's `onHoverIn`. const banana = within(reopened).getByRole("option", { name: "Banana" }); await userEvent.hover(banana); await waitFor(async () => { await expect(input).toHaveAttribute( "aria-activedescendant", banana.getAttribute("id"), ); }); await userEvent.clear(input); await userEvent.type(input, "durian"); await expect( within(screen.getByRole("listbox")).getByRole("option", { name: "Durian (sold out)", }), ).toHaveAttribute("aria-disabled", "true"); await userEvent.clear(input); await userEvent.type(input, "zzz"); await expect(screen.getByText("No fruit matches")).toBeInTheDocument(); await expect( within(screen.getByRole("listbox")).queryAllByRole("option"), ).toHaveLength(0); // The message is a sibling of the listbox, never a child of it. await expect( within(screen.getByRole("listbox")).queryByText("No fruit matches"), ).not.toBeInTheDocument(); await userEvent.keyboard("{Escape}"); await expect(input).toHaveAttribute("aria-expanded", "false"); await expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }, };