diff --git a/.changeset/calm-comboboxes-listen.md b/.changeset/calm-comboboxes-listen.md new file mode 100644 index 00000000000..d82c5d35c08 --- /dev/null +++ b/.changeset/calm-comboboxes-listen.md @@ -0,0 +1,5 @@ +--- +"@evo-web/react": patch +--- + +Add EvoCombobox component. diff --git a/.claude/skills/evo-app-migrate-react/SKILL.md b/.claude/skills/evo-app-migrate-react/SKILL.md index 45d87254716..9e59f4b4606 100644 --- a/.claude/skills/evo-app-migrate-react/SKILL.md +++ b/.claude/skills/evo-app-migrate-react/SKILL.md @@ -70,6 +70,7 @@ When migrating a listed component, read the linked file completely and apply the - `ebay-character-count`: [evo-character-count.md](components/evo-character-count.md) - `ebay-checkbox`: [evo-checkbox.md](components/evo-checkbox.md) - `ebay-chip`: [evo-chip.md](components/evo-chip.md) +- `ebay-combobox`: [evo-combobox.md](components/evo-combobox.md) - `ebay-confirm-dialog`: [evo-confirm-dialog.md](components/evo-confirm-dialog.md) - `ebay-cta-button`: [evo-cta-button.md](components/evo-cta-button.md) - `ebay-details`: [evo-details.md](components/evo-details.md) diff --git a/.claude/skills/evo-app-migrate-react/components/evo-combobox.md b/.claude/skills/evo-app-migrate-react/components/evo-combobox.md new file mode 100644 index 00000000000..e52b3dd2081 --- /dev/null +++ b/.claude/skills/evo-app-migrate-react/components/evo-combobox.md @@ -0,0 +1,68 @@ +# ebay-combobox → evo-combobox + +EvoCombobox is a textbox with text suggestions. It renders its input and listbox internally. Pass `EvoComboboxOption` components directly as children and replace `EbayComboboxButton` with the root `postfix` prop. + +## Composition + +**Before:** + +```tsx + + + + + + +``` + +**After:** + +```tsx +, + buttonProps: { a11yText: "Clear", onClick: clear }, + }} +> + + +``` + +Options render their `text` and do not accept `children`. The new `sticky` prop keeps an option visible when `autocomplete="list"` filters other options. + +## Value changes + +Replace `onInputChange` with `onValueChange`. It receives the displayed string after typing or committed option selection. + +```tsx + + + +``` + +The legacy blur-based `onChange` behavior is removed; use native `onBlur` when needed. Native `onBlur` runs after Evo's internal focus-leave processing, so controlled consumers may restore a value in their blur handler without the wrapper overwriting it afterward. + +EvoCombobox represents textbox text, not selected option identity. Text suggestions and free-form autocomplete can migrate to the text-only API. Legacy uses of option `value`, root `onSelect`, or separate ID and label state are entity selectors; entity selectors have no direct Evo replacement yet and require manual engineering review. Agents must not remove identity handling automatically. + +## Open state + +Rename open-state props and callbacks: + +```diff +- expanded={expanded} +- onExpand={() => setExpanded(true)} +- onCollapse={() => setExpanded(false)} ++ open={open} ++ onOpenChange={setOpen} +``` + +Use `defaultOpen` for an initially open uncontrolled combobox. The new `strategy` prop accepts `"absolute"` or `"fixed"` and defaults to `"absolute"`. + +## Removed props + +- `dropdownRef`: use the native React 19 input `ref`; listbox refs are internal. +- `opaqueLabel`: not supported by Evo Marko or Evo React input conventions. +- `onFloatingLabelInit`: the floating label no longer has an imperative initialization phase. +- `onSelect`: use `onValueChange` for committed textbox value changes. +- Option `value`: Text suggestion migrations should use the option's `text`; legacy machine-readable values identify an entity selector and require manual engineering review. +- Option `selected`: EvoCombobox does not manage committed option identity; do not remove legacy identity handling automatically. diff --git a/packages/evo-react/src/combobox/README.md b/packages/evo-react/src/combobox/README.md new file mode 100644 index 00000000000..b7658854916 --- /dev/null +++ b/packages/evo-react/src/combobox/README.md @@ -0,0 +1,5 @@ +# EvoCombobox + +## Documentation + +[Storybook](https://opensource.ebay.com/evo-web/react/?path=/docs/form-input-evo-combobox--documentation) diff --git a/packages/evo-react/src/combobox/combobox-option.tsx b/packages/evo-react/src/combobox/combobox-option.tsx new file mode 100644 index 00000000000..43d0b4e5199 --- /dev/null +++ b/packages/evo-react/src/combobox/combobox-option.tsx @@ -0,0 +1,100 @@ +import { useId } from "react"; +import type { Ref, RefObject } from "react"; +import classNames from "classnames"; +import { useActiveDescendantItem } from "../utils/use-active-descendant"; +import { useRefTee } from "../utils/use-ref-tee"; +import { useComboboxContext } from "./context"; +import type { EvoComboboxOptionProps } from "./types"; + +function matchesFilter(text: string, filterValue: string) { + return text.toLowerCase().includes(filterValue.trim().toLowerCase()); +} + +export function EvoComboboxOption({ + className, + onClick, + onKeyDown, + onMouseDown, + ref, + sticky = false, + text, + ...rest +}: EvoComboboxOptionProps) { + const generatedId = useId(); + const { + activeDescendant, + autocomplete, + displayedValue, + filterValue, + selectOption, + disabled, + } = useComboboxContext(); + const [optionRef, internalRef] = useRefTee( + ref as Ref, + null, + ); + const hidden = + autocomplete === "list" && !sticky && !matchesFilter(text, filterValue); + const { isActive } = useActiveDescendantItem({ + activeDescendant, + enabled: !hidden, + item: { + key: generatedId, + id: generatedId, + ref: internalRef as RefObject, + data: text, + }, + }); + + if (hidden) { + return null; + } + + return ( +
{ + event.preventDefault(); + if (!disabled) { + onMouseDown?.(event); + } + }} + onClick={(event) => { + if (disabled) { + return; + } + + onClick?.(event); + if (!event.defaultPrevented) { + selectOption(text); + } + }} + onKeyDown={(event) => { + if (disabled) { + return; + } + + onKeyDown?.(event); + if ( + !event.defaultPrevented && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + selectOption(text); + } + }} + > + {text} +
+ ); +} diff --git a/packages/evo-react/src/combobox/combobox.stories.tsx b/packages/evo-react/src/combobox/combobox.stories.tsx new file mode 100644 index 00000000000..26cb27a7d22 --- /dev/null +++ b/packages/evo-react/src/combobox/combobox.stories.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EvoIconClear16 } from "../icon/icons/clear-16"; +import { EvoCombobox } from "./combobox"; +import { EvoComboboxOption } from "./combobox-option"; + +const meta: Meta = { + title: "form input/evo-combobox", + component: EvoCombobox, + subcomponents: { EvoComboboxOption }, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: ` +A text input with a filtered listbox of selectable options. + +## Usage + +\`\`\`tsx +import { + EvoCombobox, + EvoComboboxOption, +} from "@evo-web/react/combobox"; +\`\`\` + `, + }, + }, + }, + argTypes: { + autocomplete: { + control: "select", + options: ["none", "list"], + }, + listSelection: { + control: "select", + options: ["automatic", "manual"], + }, + strategy: { + control: "select", + options: ["absolute", "fixed"], + }, + borderless: { + control: "boolean", + }, + defaultOpen: { + control: "boolean", + }, + disabled: { + control: "boolean", + }, + floatingLabel: { + control: "text", + }, + fluid: { + control: "boolean", + }, + open: { + control: "boolean", + }, + postfix: { + control: false, + }, + onValueChange: { + action: "valueChange", + table: { category: "Events" }, + }, + onOpenChange: { + action: "openChange", + table: { category: "Events" }, + }, + }, + args: { + floatingLabel: "Campaign", + autocomplete: "none", + listSelection: "automatic", + placeholder: "Choose a campaign", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + + ), +}; + +export const Controlled: Story = { + args: { + value: "August Campaign", + }, + render: (args) => ( + + + + + + + ), +}; + +export const Postfix: Story = { + render: (args) => { + const [value, setValue] = useState("August Campaign"); + + return ( + , + buttonProps: { + a11yText: "Clear", + onClick: () => setValue(""), + }, + }} + > + + + + + + ); + }, +}; diff --git a/packages/evo-react/src/combobox/combobox.tsx b/packages/evo-react/src/combobox/combobox.tsx new file mode 100644 index 00000000000..1da46e8777d --- /dev/null +++ b/packages/evo-react/src/combobox/combobox.tsx @@ -0,0 +1,399 @@ +import { useCallback, useEffect, useId, useState } from "react"; +import type { + ChangeEvent, + FocusEvent, + KeyboardEvent, + MouseEvent, + RefObject, +} from "react"; +import classNames from "classnames"; +import { EvoIconButton } from "../icon-button"; +import { useActiveDescendant } from "../utils/use-active-descendant"; +import { useExpander } from "../utils/use-expander"; +import { useFloatingLabel } from "../utils/use-floating-label"; +import { useRefTee } from "../utils/use-ref-tee"; +import { ComboboxProvider } from "./context"; +import type { EvoComboboxProps } from "./types"; +import "@ebay/skin/combobox.mjs"; + +type TemporaryValue = { + key: string; + origin: string; + value: string; +}; + +const activeDescendantScrollIntoView: ScrollIntoViewOptions = { + block: "nearest", +}; + +export function EvoCombobox({ + autocomplete = "none", + borderless = false, + children, + className, + defaultOpen = false, + defaultValue = "", + disabled, + floatingLabel: floatingLabelText, + fluid = false, + id, + listSelection = "automatic", + onBlur, + onChange, + onClick, + onFocus, + onKeyDown, + onOpenChange, + onValueChange, + open, + placeholder, + postfix, + ref, + strategy = "absolute", + style, + value, + ...inputProps +}: EvoComboboxProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const listboxId = `${inputId}-listbox`; + const isControlled = value !== undefined; + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); + const [temporaryValue, setTemporaryValue] = useState( + null, + ); + const [focused, setFocused] = useState(false); + const currentValue = isControlled ? value : uncontrolledValue; + const expander = useExpander({ + defaultOpen, + offset: 4, + onOpenChange, + open, + placement: "bottom-start", + resetOnDisabled: Boolean(disabled), + strategy, + }); + const effectiveOpen = !disabled && expander.open; + const [setListboxElement, listboxRef] = useRefTee( + expander.refs.setFloating, + null, + ); + const [setForwardedInputElement] = useRefTee( + ref, + null, + ); + const activeDescendant = useActiveDescendant({ + containerRef: listboxRef as RefObject, + scrollIntoView: activeDescendantScrollIntoView, + shouldWrap: true, + }); + const activeOption = effectiveOpen + ? activeDescendant.getActiveItem() + : undefined; + const hasValidTemporaryValue = + temporaryValue !== null && + listSelection === "automatic" && + temporaryValue.origin === currentValue && + temporaryValue.key === activeOption?.key; + + if (temporaryValue !== null && !hasValidTemporaryValue) { + setTemporaryValue(null); + } + + const displayedValue = hasValidTemporaryValue + ? temporaryValue.value + : currentValue; + const previewValue = hasValidTemporaryValue ? temporaryValue.value : null; + + useEffect(() => { + if (disabled) { + activeDescendant.reset(); + } + }, [activeDescendant.reset, disabled]); + + const floatingLabel = useFloatingLabel({ + containerTagName: fluid ? "div" : "span", + disabled, + focused: focused || effectiveOpen, + text: floatingLabelText, + value: displayedValue, + }); + + const setInputElement = useCallback( + (node: HTMLInputElement | null) => { + expander.refs.setReference(node); + setForwardedInputElement(node); + }, + [expander.refs.setReference, setForwardedInputElement], + ); + + const requestOpen = useCallback( + (nextOpen: boolean) => { + if (disabled && nextOpen) { + return; + } + + if (nextOpen !== expander.open) { + expander.setOpen(nextOpen); + } + }, + [disabled, expander.open, expander.setOpen], + ); + + const updateValue = useCallback( + (nextValue: string) => { + setTemporaryValue(null); + if (!isControlled) { + setUncontrolledValue(nextValue); + } + if (nextValue !== currentValue) { + onValueChange?.(nextValue); + } + }, + [currentValue, isControlled, onValueChange], + ); + + const selectOption = useCallback( + (text: string) => { + if (disabled) { + return; + } + + updateValue(text); + activeDescendant.reset(); + requestOpen(false); + }, + [activeDescendant.reset, disabled, requestOpen, updateValue], + ); + + const handleFocusOut = useCallback( + (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return; + } + + setFocused(false); + requestOpen(false); + activeDescendant.reset(); + + if (previewValue !== null) { + updateValue(previewValue); + } + }, + [activeDescendant.reset, previewValue, requestOpen, updateValue], + ); + + const handleChange = useCallback( + (event: ChangeEvent) => { + if (disabled) { + return; + } + + activeDescendant.reset(); + updateValue(event.currentTarget.value); + requestOpen(true); + onChange?.(event); + }, + [activeDescendant.reset, disabled, onChange, requestOpen, updateValue], + ); + + const handleFocus = useCallback( + (event: FocusEvent) => { + if (disabled) { + return; + } + + setFocused(true); + requestOpen(true); + onFocus?.(event); + }, + [disabled, onFocus, requestOpen], + ); + + const handleBlur = useCallback( + (event: FocusEvent) => { + onBlur?.(event); + }, + [onBlur], + ); + + const handleClick = useCallback( + (event: MouseEvent) => { + if (disabled) { + return; + } + + requestOpen(true); + onClick?.(event); + }, + [disabled, onClick, requestOpen], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (disabled) { + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!effectiveOpen) { + requestOpen(true); + } else { + const option = + event.key === "ArrowDown" + ? activeDescendant.activateNext() + : activeDescendant.activatePrevious(); + if (listSelection === "automatic") { + setTemporaryValue( + option + ? { + key: option.key, + origin: currentValue, + value: option.data, + } + : null, + ); + } + } + } else if (event.key === "Enter" && effectiveOpen) { + const option = activeDescendant.getActiveItem(); + if (option) { + event.preventDefault(); + selectOption(option.data); + } else { + requestOpen(false); + } + } else if (event.key === "Escape") { + setTemporaryValue(null); + activeDescendant.reset(); + requestOpen(false); + } + + onKeyDown?.(event); + }, + [ + activeDescendant.activateNext, + activeDescendant.activatePrevious, + activeDescendant.getActiveItem, + activeDescendant.reset, + currentValue, + disabled, + effectiveOpen, + listSelection, + onKeyDown, + requestOpen, + selectOption, + ], + ); + + const Wrapper = fluid ? "div" : "span"; + const postfixButtonProps = postfix?.buttonProps; + const postfixMouseDown = postfixButtonProps?.onMouseDown; + const postfixClick = postfixButtonProps?.onClick; + const handlePostfixClick = useCallback( + (event: MouseEvent) => { + if (disabled) { + return; + } + + setTemporaryValue(null); + activeDescendant.reset(); + postfixClick?.(event); + }, + [activeDescendant.reset, disabled, postfixClick], + ); + const handlePostfixMouseDown = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + if (!disabled) { + postfixMouseDown?.(event); + } + }, + [disabled, postfixMouseDown], + ); + + return ( + + + + + + + {postfix && + (postfixButtonProps ? ( + + {postfix.icon} + + ) : ( + postfix.icon + ))} + +
+ {children} +
+
+
+
+ ); +} diff --git a/packages/evo-react/src/combobox/context.tsx b/packages/evo-react/src/combobox/context.tsx new file mode 100644 index 00000000000..94a07ecf14a --- /dev/null +++ b/packages/evo-react/src/combobox/context.tsx @@ -0,0 +1,62 @@ +import { createContext, use, useMemo } from "react"; +import type { ReactNode } from "react"; +import type { ActiveDescendant } from "../utils/use-active-descendant"; +import type { ComboboxAutocomplete } from "./types"; + +export type ComboboxContextValue = { + activeDescendant: ActiveDescendant; + autocomplete: ComboboxAutocomplete; + displayedValue: string; + filterValue: string; + disabled?: boolean; + selectOption: (text: string) => void; +}; + +const ComboboxContext = createContext( + undefined, +); + +export function useComboboxContext() { + const context = use(ComboboxContext); + if (!context) { + throw new Error( + "EvoComboboxOption must be used within an EvoCombobox component", + ); + } + return context; +} + +type ComboboxProviderProps = ComboboxContextValue & { + children?: ReactNode; +}; + +export function ComboboxProvider({ + activeDescendant, + autocomplete, + displayedValue, + filterValue, + disabled, + selectOption, + children, +}: ComboboxProviderProps) { + const value = useMemo( + () => ({ + activeDescendant, + autocomplete, + displayedValue, + filterValue, + disabled, + selectOption, + }), + [ + activeDescendant, + autocomplete, + disabled, + displayedValue, + filterValue, + selectOption, + ], + ); + + return {children}; +} diff --git a/packages/evo-react/src/combobox/index.ts b/packages/evo-react/src/combobox/index.ts new file mode 100644 index 00000000000..5430e70ee07 --- /dev/null +++ b/packages/evo-react/src/combobox/index.ts @@ -0,0 +1,10 @@ +export { EvoCombobox } from "./combobox"; +export { EvoComboboxOption } from "./combobox-option"; +export type { + ComboboxAutocomplete, + ComboboxListSelection, + ComboboxStrategy, + EvoComboboxOptionProps, + EvoComboboxPostfixProps, + EvoComboboxProps, +} from "./types"; diff --git a/packages/evo-react/src/combobox/test/__snapshots__/test.server.tsx.snap b/packages/evo-react/src/combobox/test/__snapshots__/test.server.tsx.snap new file mode 100644 index 00000000000..192ad2859f2 --- /dev/null +++ b/packages/evo-react/src/combobox/test/__snapshots__/test.server.tsx.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EvoCombobox SSR > renders a disabled combobox as closed 1`] = `""`; + +exports[`EvoCombobox SSR > renders a fixed-strategy listbox 1`] = `""`; + +exports[`EvoCombobox SSR > renders a fluid combobox with a floating label 1`] = `"
August Campaign
Basic Offer
"`; + +exports[`EvoCombobox SSR > renders an actionable postfix 1`] = `"
August Campaign
Basic Offer
"`; + +exports[`EvoCombobox SSR > renders an open borderless combobox 1`] = `""`; + +exports[`EvoCombobox SSR > renders the default combobox 1`] = `"
August Campaign
Basic Offer
"`; diff --git a/packages/evo-react/src/combobox/test/test.browser.tsx b/packages/evo-react/src/combobox/test/test.browser.tsx new file mode 100644 index 00000000000..87b2d404b46 --- /dev/null +++ b/packages/evo-react/src/combobox/test/test.browser.tsx @@ -0,0 +1,619 @@ +import { createRef, useRef, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { EvoIconClose12 } from "../../icon/icons/close-12"; +import { EvoCombobox } from "../combobox"; +import { EvoComboboxOption } from "../combobox-option"; +import type { EvoComboboxProps } from "../types"; + +function ComboboxFixture(props: EvoComboboxProps) { + return ( + + + + + ); +} + +describe("evo-combobox", () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + afterEach(() => { + user.cleanup(); + vi.restoreAllMocks(); + }); + + describe("ARIA attributes", () => { + it("links the combobox to its listbox", async () => { + const screen = await render(); + const input = screen.getByRole("combobox", { name: "Campaign" }); + + await user.click(input); + + const listbox = screen.getByRole("listbox"); + await expect.element(input).toHaveAttribute("aria-expanded", "true"); + await expect + .element(input) + .toHaveAttribute("aria-controls", listbox.element().id); + await expect + .element(input) + .toHaveAttribute("aria-owns", listbox.element().id); + }); + + it("sets the active descendant during keyboard navigation", async () => { + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + + const option = screen.getByRole("option", { name: "August Campaign" }); + await expect + .element(input) + .toHaveAttribute("aria-activedescendant", option.element().id); + await expect.element(option).toHaveAttribute("aria-selected", "true"); + }); + + it("scrolls each newly active option without scrolling on reset", async () => { + const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + + expect(scrollIntoView).toHaveBeenCalledTimes(2); + expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" }); + + await user.keyboard("{Escape}"); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); + }); + + describe("value changes", () => { + it("passes the displayed value to onValueChange while typing", async () => { + const onValueChange = vi.fn(); + const screen = await render( + , + ); + + await user.type(screen.getByRole("combobox"), "Basic"); + + expect(onValueChange).toHaveBeenLastCalledWith("Basic"); + await expect.element(screen.getByRole("combobox")).toHaveValue("Basic"); + }); + + it("passes the native change event to onChange while typing", async () => { + const onChange = vi.fn(); + const screen = await render( + onChange(event.currentTarget.value)} + />, + ); + + await user.type(screen.getByRole("combobox"), "B"); + + expect(onChange).toHaveBeenCalledWith("B"); + }); + + it("passes selected option text only to onValueChange", async () => { + const onChange = vi.fn(); + const onValueChange = vi.fn(); + const screen = await render( + , + ); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.click(screen.getByRole("option", { name: "August Campaign" })); + + expect(onValueChange).toHaveBeenCalledWith("August Campaign"); + expect(onChange).not.toHaveBeenCalled(); + await expect.element(input).toHaveValue("August Campaign"); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + }); + + it("supports controlled values for typing and selection", async () => { + function ControlledCombobox() { + const [value, setValue] = useState(""); + return ; + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.type(input, "B"); + await expect.element(input).toHaveValue("B"); + await user.click(screen.getByRole("option", { name: "Basic Offer" })); + await expect.element(input).toHaveValue("Basic Offer"); + }); + + it("does not let a controlled update leave a stale keyboard preview", async () => { + function ControlledCombobox() { + const [value, setValue] = useState(""); + return ( + { + if (event.key === "ArrowDown") { + setValue("Externally changed"); + } + }} + /> + ); + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + + await expect.element(input).toHaveValue("Externally changed"); + }); + + it("does not let an active option removal leave a stale preview", async () => { + function FilteredCombobox() { + const [showFirst, setShowFirst] = useState(true); + return ( + { + if (event.key === "ArrowDown") { + setShowFirst(false); + } + }} + > + {showFirst && } + + + ); + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + + await expect.element(input).toHaveValue(""); + }); + }); + + describe("filtering", () => { + it("filters options by text when autocomplete is list", async () => { + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.type(input, "Basic"); + + await expect + .element(screen.getByRole("option", { name: "August Campaign" })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("option", { name: "Basic Offer" })) + .toBeInTheDocument(); + }); + + it("keeps sticky options visible when they do not match", async () => { + const screen = await render( + + + + , + ); + + await user.type(screen.getByRole("combobox"), "missing"); + + await expect + .element(screen.getByRole("option", { name: "Create campaign" })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("option", { name: "Basic Offer" })) + .not.toBeInTheDocument(); + }); + }); + + describe("keyboard interactions", () => { + it("previews and selects options in automatic mode", async () => { + const onValueChange = vi.fn(); + const screen = await render( + , + ); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + + await expect.element(input).toHaveValue("August Campaign"); + expect(onValueChange).not.toHaveBeenCalled(); + + await user.keyboard("{Enter}"); + + expect(onValueChange).toHaveBeenCalledWith("August Campaign"); + }); + + it("does not preview option text in manual mode", async () => { + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + + await expect.element(input).toHaveValue(""); + await expect.element(input).toHaveAttribute("aria-activedescendant"); + }); + + it("restores the committed value on Escape", async () => { + const screen = await render( + , + ); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + await expect.element(input).toHaveValue("August Campaign"); + + await user.keyboard("{Escape}"); + + await expect.element(input).toHaveValue("Initial value"); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + }); + + it("clears a preview when list selection changes to manual", async () => { + function ChangingSelectionCombobox() { + const [selection, setSelection] = + useState("automatic"); + const arrowPresses = useRef(0); + + return ( + { + if (event.key === "ArrowDown" && ++arrowPresses.current === 2) { + setSelection("manual"); + } + }} + > + + + + ); + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + await expect.element(input).toHaveValue("August Campaign"); + await user.keyboard("{ArrowDown}"); + + await expect.element(input).toHaveValue(""); + }); + + it("commits preview before native onBlur and allows restoration", async () => { + const onValueChange = vi.fn(); + + function ControlledCombobox() { + const [value, setValue] = useState("Initial value"); + return ( + { + onValueChange(nextValue); + setValue(nextValue); + }} + onBlur={() => setValue("Restored value")} + /> + ); + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + await user.tab(); + + await expect.element(input).toHaveValue("Restored value"); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith("August Campaign"); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + }); + }); + + describe("disabled state", () => { + it("keeps a default-open combobox closed when disabled", async () => { + const screen = await render(); + const input = screen.getByRole("combobox"); + const wrapper = screen.container.querySelector(".combobox"); + + await expect.element(input).toBeDisabled(); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + expect(wrapper).not.toHaveClass("combobox--expanded"); + }); + + it("does not expose a controlled open state while disabled", async () => { + const screen = await render( + , + ); + const input = screen.getByRole("combobox"); + const wrapper = screen.container.querySelector(".combobox"); + + await expect.element(input).toBeDisabled(); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + expect(wrapper).not.toHaveClass("combobox--expanded"); + }); + + it("does not open from focus, click, or keyboard input while disabled", async () => { + const onOpenChange = vi.fn(); + const onFocus = vi.fn(); + const screen = await render( + , + ); + const input = screen.getByRole("combobox"); + + input.element().dispatchEvent(new FocusEvent("focus", { bubbles: true })); + input.element().dispatchEvent(new MouseEvent("click", { bubbles: true })); + input + .element() + .dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }), + ); + + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onFocus).not.toHaveBeenCalled(); + }); + + it("clears active state and stays closed after an uncontrolled combobox is re-enabled", async () => { + function ToggleableCombobox() { + const [disabled, setDisabled] = useState(false); + return ( + <> + + + + ); + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + const toggle = screen.getByRole("button", { name: "Toggle disabled" }); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + await expect.element(input).toHaveAttribute("aria-activedescendant"); + + await user.click(toggle); + await expect.element(input).toBeDisabled(); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + await expect.element(input).not.toHaveAttribute("aria-activedescendant"); + + await user.click(toggle); + await expect.element(input).not.toBeDisabled(); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + await expect.element(input).not.toHaveAttribute("aria-activedescendant"); + }); + + it("follows a controlled open state after being re-enabled", async () => { + function ToggleableCombobox() { + const [disabled, setDisabled] = useState(true); + return ( + <> + + + + ); + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + const toggle = screen.getByRole("button", { name: "Toggle disabled" }); + + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + await user.click(toggle); + await expect.element(input).toHaveAttribute("aria-expanded", "true"); + }); + + it("does not invoke disabled option handlers or select an option", async () => { + const onClick = vi.fn(); + const onKeyDown = vi.fn(); + const onMouseDown = vi.fn(); + const onValueChange = vi.fn(); + const screen = await render( + + + , + ); + const option = + screen.container.querySelector('[role="option"]'); + + option?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + option?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + option?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + + expect(onClick).not.toHaveBeenCalled(); + expect(onKeyDown).not.toHaveBeenCalled(); + expect(onMouseDown).not.toHaveBeenCalled(); + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe("postfix action", () => { + it("runs the button callback without closing the listbox", async () => { + const onClick = vi.fn(); + const screen = await render( + , + buttonProps: { a11yText: "Clear", onClick }, + }} + />, + ); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.click(screen.getByRole("button", { name: "Clear" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await expect.element(input).toHaveFocus(); + await expect.element(input).toHaveAttribute("aria-expanded", "true"); + }); + + it("does not open or focus the input from a closed postfix action", async () => { + const onClick = vi.fn(); + const screen = await render( + , + buttonProps: { a11yText: "Action", onClick }, + }} + />, + ); + const input = screen.getByRole("combobox"); + + await user.click(screen.getByRole("button", { name: "Action" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await expect.element(input).not.toHaveFocus(); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + }); + + it("keeps keyboard focus on the postfix after activation", async () => { + const screen = await render( + , + buttonProps: { a11yText: "Action" }, + }} + />, + ); + const button = screen.getByRole("button", { name: "Action" }); + const input = screen.getByRole("combobox"); + + button.element().focus(); + await user.keyboard("{Enter}"); + + await expect.element(button).toHaveFocus(); + await expect.element(input).toHaveAttribute("aria-expanded", "false"); + }); + + it("clears keyboard preview before a controlled clear callback", async () => { + function ControlledCombobox() { + const [value, setValue] = useState(""); + return ( + , + buttonProps: { + a11yText: "Clear", + onClick: () => setValue(""), + }, + }} + /> + ); + } + + const screen = await render(); + const input = screen.getByRole("combobox"); + + await user.click(input); + await user.keyboard("{ArrowDown}"); + await expect.element(input).toHaveValue("August Campaign"); + + await user.click(screen.getByRole("button", { name: "Clear" })); + + await expect.element(input).toHaveValue(""); + await expect.element(input).toHaveFocus(); + }); + + it("does not run a disabled postfix callback", async () => { + const onClick = vi.fn(); + const screen = await render( + , + buttonProps: { a11yText: "Clear", onClick }, + }} + />, + ); + + const button = screen.getByRole("button", { name: "Clear" }); + await expect.element(button).toBeDisabled(); + button + .element() + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe("refs", () => { + it("passes the ref to the input", async () => { + const ref = createRef(); + + await render(); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current?.getAttribute("role")).toBe("combobox"); + }); + }); + + describe("listbox layout", () => { + it("makes the open listbox at least as wide as the combobox", async () => { + const screen = await render( +
+ +
, + ); + const input = screen.getByRole("combobox"); + + await user.click(input); + + const listbox = screen.getByRole("listbox").element(); + const wrapper = screen.container.querySelector(".combobox"); + expect(listbox.getBoundingClientRect().width).toBeGreaterThanOrEqual( + (wrapper?.getBoundingClientRect().width ?? 0) - 1, + ); + }); + }); +}); diff --git a/packages/evo-react/src/combobox/test/test.server.tsx b/packages/evo-react/src/combobox/test/test.server.tsx new file mode 100644 index 00000000000..cc594faac33 --- /dev/null +++ b/packages/evo-react/src/combobox/test/test.server.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { renderToString } from "react-dom/server"; +import { EvoIconClose12 } from "../../icon/icons/close-12"; +import { EvoCombobox } from "../combobox"; +import { EvoComboboxOption } from "../combobox-option"; + +function renderCombobox(props: Parameters[0] = {}) { + return renderToString( + + + + , + ); +} + +describe("EvoCombobox SSR", () => { + it("renders the default combobox", () => { + expect(renderCombobox()).toMatchSnapshot(); + }); + + it("renders a fluid combobox with a floating label", () => { + expect( + renderCombobox({ + defaultValue: "August Campaign", + floatingLabel: "Campaign", + fluid: true, + }), + ).toMatchSnapshot(); + }); + + it("renders an open borderless combobox", () => { + expect( + renderCombobox({ borderless: true, defaultOpen: true }), + ).toMatchSnapshot(); + }); + + it("renders a fixed-strategy listbox", () => { + expect( + renderCombobox({ defaultOpen: true, strategy: "fixed" }), + ).toMatchSnapshot(); + }); + + it("renders a disabled combobox as closed", () => { + expect( + renderCombobox({ defaultOpen: true, disabled: true }), + ).toMatchSnapshot(); + }); + + it("renders an actionable postfix", () => { + expect( + renderCombobox({ + postfix: { + icon: , + buttonProps: { a11yText: "Clear" }, + }, + }), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/evo-react/src/combobox/types.ts b/packages/evo-react/src/combobox/types.ts new file mode 100644 index 00000000000..6fafd154bea --- /dev/null +++ b/packages/evo-react/src/combobox/types.ts @@ -0,0 +1,89 @@ +import type { ComponentProps, CSSProperties, ReactNode } from "react"; +import type { Strategy } from "@floating-ui/react"; +import type { NativeIconButtonProps } from "../icon-button"; + +/** How the combobox filters its options. */ +export type ComboboxAutocomplete = "list" | "none"; + +/** How keyboard navigation previews the active option. */ +export type ComboboxListSelection = "automatic" | "manual"; + +/** Floating UI positioning strategy used by the listbox. */ +export type ComboboxStrategy = Strategy; + +/** Optional icon rendered after the combobox input. */ +export type EvoComboboxPostfixProps = { + /** Icon displayed after the input. */ + icon: ReactNode; + /** Makes the icon actionable using an EvoIconButton. */ + buttonProps?: Omit; +}; + +export type EvoComboboxProps = Omit< + ComponentProps<"input">, + | "aria-activedescendant" + | "aria-autocomplete" + | "aria-controls" + | "aria-expanded" + | "aria-haspopup" + | "aria-owns" + | "autoComplete" + | "children" + | "className" + | "defaultValue" + | "onSelect" + | "prefix" + | "role" + | "style" + | "type" + | "value" +> & { + /** + * Filters visible options to those whose text includes the current input + * value. Defaults to `"none"`. + */ + autocomplete?: ComboboxAutocomplete; + /** Removes the input border. */ + borderless?: boolean; + /** EvoComboboxOption children rendered in the component's listbox. */ + children?: ReactNode; + /** Class name applied to the combobox wrapper. */ + className?: string; + /** Initial open state for uncontrolled usage. Ignored when `open` is provided. */ + defaultOpen?: boolean; + /** Initial input value for uncontrolled usage. Ignored when `value` is provided. */ + defaultValue?: string; + /** Floating label text shown above the input when focused or filled. */ + floatingLabel?: string; + /** Stretches the component to fill its container. */ + fluid?: boolean; + /** + * Whether arrow-key navigation previews the highlighted option text in the + * input. Defaults to `"automatic"`. + */ + listSelection?: ComboboxListSelection; + /** Called with the displayed input value after typing or option selection. */ + onValueChange?: (value: string) => void; + /** Called when the listbox requests to open or close. */ + onOpenChange?: (open: boolean) => void; + /** Controlled listbox visibility. Manage changes with `onOpenChange`. */ + open?: boolean; + /** Optional icon or icon button rendered after the input. */ + postfix?: EvoComboboxPostfixProps; + /** Listbox positioning strategy. Defaults to `"absolute"`. */ + strategy?: ComboboxStrategy; + /** Style applied to the combobox wrapper. */ + style?: CSSProperties; + /** Controlled displayed input value. */ + value?: string; +}; + +export type EvoComboboxOptionProps = Omit< + ComponentProps<"div">, + "aria-selected" | "children" | "id" | "role" | "tabIndex" +> & { + /** Always shown during list autocomplete, even when its text does not match. */ + sticky?: boolean; + /** Display text shown by the option and written into the input when selected. */ + text: string; +}; diff --git a/packages/evo-react/src/utils/use-active-descendant.ts b/packages/evo-react/src/utils/use-active-descendant.ts new file mode 100644 index 00000000000..b39025bb21b --- /dev/null +++ b/packages/evo-react/src/utils/use-active-descendant.ts @@ -0,0 +1,204 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; + +export type ActiveDescendantItem = { + key: Key; + id: string; + ref: RefObject; + data: Data; +}; + +export type ActiveDescendant = { + activeKey: Key | null; + registerItem: (item: ActiveDescendantItem) => () => void; + getActiveItem: () => ActiveDescendantItem | undefined; + activateNext: () => ActiveDescendantItem | undefined; + activatePrevious: () => ActiveDescendantItem | undefined; + reset: () => void; +}; + +type UseActiveDescendantOptions = { + containerRef: RefObject; + shouldWrap?: boolean; + scrollIntoView?: ScrollIntoViewOptions; +}; + +type UseActiveDescendantItemOptions = { + activeDescendant: ActiveDescendant; + item: ActiveDescendantItem; + enabled?: boolean; +}; + +function compareDomOrder( + first: ActiveDescendantItem, + second: ActiveDescendantItem, +) { + const firstElement = first.ref.current; + const secondElement = second.ref.current; + + if (!firstElement || !secondElement || firstElement === secondElement) { + return 0; + } + + return firstElement.compareDocumentPosition(secondElement) & + Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1; +} + +export function useActiveDescendant({ + containerRef, + scrollIntoView, + shouldWrap = true, +}: UseActiveDescendantOptions): ActiveDescendant { + const itemsRef = useRef(new Map>()); + const [activeKey, setActiveKeyState] = useState(null); + const activeKeyRef = useRef(null); + activeKeyRef.current = activeKey; + + const getItems = useCallback(() => { + const container = containerRef.current; + + return [...itemsRef.current.values()] + .filter(({ ref }) => { + const element = ref.current; + return Boolean(element && container?.contains(element)); + }) + .sort(compareDomOrder); + }, [containerRef]); + + const getActiveItem = useCallback(() => { + const key = activeKeyRef.current; + if (key === null) { + return undefined; + } + + return getItems().find((item) => item.key === key); + }, [getItems]); + + const activateKey = useCallback( + (key: Key | null) => { + if (key === null) { + activeKeyRef.current = null; + setActiveKeyState(null); + return; + } + + const item = itemsRef.current.get(key); + if (!item || !item.ref.current) { + return; + } + + activeKeyRef.current = key; + setActiveKeyState(key); + if (scrollIntoView) { + item.ref.current.scrollIntoView?.(scrollIntoView); + } + }, + [scrollIntoView], + ); + + const reset = useCallback(() => { + activateKey(null); + }, [activateKey]); + + const registerItem = useCallback( + (item: ActiveDescendantItem) => { + const key = item.key; + itemsRef.current.set(key, item); + + return () => { + if (itemsRef.current.get(key) !== item) { + return; + } + + itemsRef.current.delete(key); + if (activeKeyRef.current === key) { + activateKey(null); + } + }; + }, + [activateKey], + ); + + const move = useCallback( + (direction: 1 | -1) => { + const items = getItems(); + if (items.length === 0) { + return undefined; + } + + const currentIndex = items.findIndex( + ({ key }) => key === activeKeyRef.current, + ); + let nextIndex: number | null; + + if (currentIndex === -1) { + nextIndex = direction === 1 ? 0 : items.length - 1; + } else { + const rawIndex = currentIndex + direction; + if (rawIndex < 0 || rawIndex >= items.length) { + nextIndex = shouldWrap ? null : currentIndex; + } else { + nextIndex = rawIndex; + } + } + + if (nextIndex === null) { + activateKey(null); + return undefined; + } + + const nextItem = items[nextIndex]; + activateKey(nextItem.key); + return nextItem; + }, + [activateKey, getItems, shouldWrap], + ); + + const activateNext = useCallback(() => move(1), [move]); + const activatePrevious = useCallback(() => move(-1), [move]); + + return useMemo( + () => ({ + activeKey, + registerItem, + getActiveItem, + activateNext, + activatePrevious, + reset, + }), + [ + activeKey, + activateNext, + activatePrevious, + getActiveItem, + registerItem, + reset, + ], + ); +} + +export function useActiveDescendantItem({ + activeDescendant, + item, + enabled = true, +}: UseActiveDescendantItemOptions): { isActive: boolean } { + const itemRef = useRef(item); + itemRef.current.key = item.key; + itemRef.current.id = item.id; + itemRef.current.ref = item.ref; + itemRef.current.data = item.data; + + useLayoutEffect(() => { + if (!enabled) { + return; + } + + return activeDescendant.registerItem(itemRef.current); + }, [activeDescendant.registerItem, enabled]); + + return { + isActive: enabled && activeDescendant.activeKey === item.key, + }; +} diff --git a/packages/evo-react/src/utils/use-expander.ts b/packages/evo-react/src/utils/use-expander.ts index e849d093a37..212d0511afe 100644 --- a/packages/evo-react/src/utils/use-expander.ts +++ b/packages/evo-react/src/utils/use-expander.ts @@ -8,7 +8,7 @@ import { useFloating, } from "@floating-ui/react"; import type { Middleware, Placement, Strategy } from "@floating-ui/react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; type UseExpanderOptions = { @@ -21,6 +21,8 @@ type UseExpanderOptions = { flip?: boolean; shift?: boolean; inline?: boolean; + /** Closes uncontrolled state when the owning component is disabled. */ + resetOnDisabled?: boolean; }; export function useExpander({ @@ -33,12 +35,19 @@ export function useExpander({ flip = true, shift = true, inline = true, + resetOnDisabled = false, }: UseExpanderOptions = {}) { const isControlled = open !== undefined; const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const currentOpen = isControlled ? open : uncontrolledOpen; const arrowRef = useRef(null); + useEffect(() => { + if (resetOnDisabled && !isControlled) { + setUncontrolledOpen(false); + } + }, [isControlled, resetOnDisabled]); + const setOpen = useCallback( (nextOpen: boolean) => { if (!isControlled) {