feat(opal): dropdown search, foldable dividers, one keyboard model, rows on LineItemButton (#15115)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Raunak BhagatandClaude Fable 5.1 authored and GitHub committed 2026-09-25 03:19:15 +00:00
1 parent a2325cf71d
commit 7f12a5f1b0
36 files changed
+1238 -394

No files matched your search

@@ -36,6 +36,7 @@ A plain line with no title or description.
| -------------- | ------------------------- | -------------- | ------------------------------- |
| `title` | `string \| RichStr` | **(required)** | Label to the left of the line |
| `foldable` | `true` | **(required)** | Enables fold/expand behavior |
| `interaction` | `"rest" \| "hover" \| "active"` | — | Overrides the header's interaction state; unset, an open header reads as hover |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Uncontrolled initial open state |
| `onOpenChange` | `(open: boolean) => void` | — | Callback when toggled |
@@ -5,7 +5,7 @@ import { useState, useCallback } from "react";
import type { OrientationVariants, RichStr } from "@opal/types";
import { Button, Text } from "@opal/components";
import { SvgChevronRight } from "@opal/icons";
import { Interactive } from "@opal/core";
import { Interactive, type InteractiveStatelessInteraction } from "@opal/core";
import { cn } from "@opal/utils";
import { spacingToRem } from "@opal/shared";
@@ -25,6 +25,7 @@ interface DividerSharedProps {
defaultOpen?: never;
onOpenChange?: never;
children?: never;
interaction?: never;
}
/**
@@ -63,7 +64,13 @@ type DividerDescribedProps = Omit<DividerSharedProps, "description"> & {
/** Foldable — requires title, reveals children. */
type DividerFoldableProps = Omit<
DividerSharedProps,
"title" | "foldable" | "open" | "defaultOpen" | "onOpenChange" | "children"
| "title"
| "foldable"
| "open"
| "defaultOpen"
| "onOpenChange"
| "children"
| "interaction"
> & {
/** Title is required when foldable. */
title: string | RichStr;
@@ -76,6 +83,11 @@ type DividerFoldableProps = Omit<
onOpenChange?: (open: boolean) => void;
/** Content revealed when open. */
children?: React.ReactNode;
/**
* Overrides the header's interaction state (a listbox highlights the
* title the keyboard stopped on). Unset, an open header reads as hover.
*/
interaction?: InteractiveStatelessInteraction;
};
type DividerProps =
@@ -161,6 +173,7 @@ function FoldableDivider({
defaultOpen = false,
onOpenChange,
children,
interaction,
}: DividerFoldableProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
@@ -177,7 +190,7 @@ function FoldableDivider({
<Interactive.Stateless
variant="default"
prominence="tertiary"
interaction={isOpen ? "hover" : "rest"}
interaction={interaction ?? (isOpen ? "hover" : "rest")}
onClick={toggle}
>
<Interactive.Container rounding={2} size="fit" width="full">
@@ -1,5 +1,5 @@
import React from "react";
import { clickOnKeyDown } from "@opal/utils";
import { LineItemButton } from "@opal/components/buttons/line-item-button/components";
import { SelectOption } from "../types";
import { sanitizeOptionId } from "./aria";
@@ -11,42 +11,14 @@ interface OptionItemProps {
isSelected: boolean;
isExact: boolean;
onSelect: (option: SelectOption) => void;
onMouseEnter: (index: number) => void;
onMouseMove: () => void;
/** Search term to highlight in the label */
searchTerm: string;
}
/**
* Escapes special regex characters in a string
*/
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/**
* Highlights matching text within a string
*/
const highlightMatch = (text: string, searchTerm: string): React.ReactNode => {
if (!searchTerm.trim()) return text;
const regex = new RegExp(`(${escapeRegex(searchTerm)})`, "gi");
const parts = text.split(regex);
if (parts.length === 1) return text;
return parts.map((part, i) =>
part.toLowerCase() === searchTerm.toLowerCase() ? (
<span key={i} className="opal-select-match">
{part}
</span>
) : (
part
)
);
};
/**
* Renders a single option item in the dropdown
* Memoized to prevent unnecessary re-renders
* One row of the listbox: a presentational `LineItemButton`, since the
* listbox owns focus and the keyboard and addresses the row through
* `aria-activedescendant`. Selection and the exact match read as the
* selected state; the keyboard stop reads as hover. Memoized to prevent
* unnecessary re-renders.
*/
export const OptionItem = React.memo(
({
@@ -57,46 +29,35 @@ export const OptionItem = React.memo(
isSelected,
isExact,
onSelect,
onMouseEnter,
onMouseMove,
searchTerm,
}: OptionItemProps) => {
return (
<div
<LineItemButton
presentational
selectVariant="select-heavy"
state={isSelected || isExact ? "selected" : "empty"}
interaction={isHighlighted ? "hover" : "rest"}
disabled={option.disabled}
rounding={2}
icon={option.icon}
title={option.title}
description={option.description}
sizePreset="main-ui"
// `body` resolves to `ContentSm`, which has no description slot; a
// row with one takes the `heading` layout so the line renders.
variant={option.description ? "heading" : "body"}
id={`${fieldId}-option-${sanitizeOptionId(option.value)}`}
data-index={index}
role="option"
tabIndex={-1}
aria-selected={isSelected}
aria-disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
onSelect(option);
}}
onKeyDown={clickOnKeyDown(() => onSelect(option))}
onMouseDown={(e) => {
e.preventDefault();
}}
onMouseEnter={() => onMouseEnter(index)}
onMouseMove={onMouseMove}
className="opal-select-option"
data-exact={isExact || undefined}
data-highlighted={isHighlighted || undefined}
data-selected={isSelected || undefined}
data-disabled={option.disabled || undefined}
>
<span className="opal-select-option-label">
{option.icon && <option.icon className="opal-select-option-icon" />}
<span className="opal-select-option-text">
{highlightMatch(option.title, searchTerm)}
</span>
</span>
{option.description && (
<span className="opal-select-option-description">
{option.description}
</span>
)}
</div>
/>
);
}
);
@@ -5,7 +5,7 @@ import type { SelectOption } from "../types";
import type { OptionGroup } from "../shared";
import { Divider } from "@opal/components/divider/components";
import { Text } from "@opal/components/text/components";
import { clickOnKeyDown } from "@opal/utils";
import { LineItemButton } from "@opal/components/buttons/line-item-button/components";
import { SvgPlus } from "@opal/icons";
import { sanitizeOptionId } from "./aria";
@@ -25,8 +25,6 @@ interface OptionsListProps {
highlightedIndex: number;
fieldId: string;
onSelect: (option: SelectOption) => void;
onMouseEnter: (index: number) => void;
onMouseMove: () => void;
isExactMatch: (option: SelectOption) => boolean;
/** Current input value for creating new option */
inputValue: string;
@@ -34,6 +32,8 @@ interface OptionsListProps {
allowCreate: boolean;
/** Whether to show create option (pre-computed by parent) */
showCreateOption: boolean;
/** A click on a foldable group's title. */
onToggleGroup?: (group: OptionGroup) => void;
}
/**
@@ -50,12 +50,11 @@ export const OptionsList: React.FC<OptionsListProps> = ({
highlightedIndex,
fieldId,
onSelect,
onMouseEnter,
onMouseMove,
isExactMatch,
inputValue,
allowCreate,
showCreateOption,
onToggleGroup,
}) => {
const strings = useOpalStrings();
// Index offset for other options when create option is shown
@@ -65,8 +64,11 @@ export const OptionsList: React.FC<OptionsListProps> = ({
(count, section) => count + section.options.length,
0
);
// A folded group withholds its rows but still shows its title, so a
// list of folded groups is not empty.
const hasFoldedGroups = sections.some((section) => section.folded);
if (totalOptions === 0 && !showCreateOption) {
if (totalOptions === 0 && !showCreateOption && !hasFoldedGroups) {
// An empty SET gets the icon'd empty state; a filter that matched
// nothing keeps the lightweight text row.
if (emptySet) {
@@ -91,7 +93,15 @@ export const OptionsList: React.FC<OptionsListProps> = ({
<>
{/* Create New Option */}
{showCreateOption && (
<div
<LineItemButton
presentational
selectVariant="select-heavy"
interaction={highlightedIndex === 0 ? "hover" : "rest"}
rounding={2}
title={createText}
sizePreset="main-ui"
variant="body"
rightChildren={<SvgPlus className="opal-select-create-icon" />}
id={`${fieldId}-option-${sanitizeOptionId(createText)}`}
data-index={0}
role="option"
@@ -105,20 +115,10 @@ export const OptionsList: React.FC<OptionsListProps> = ({
e.stopPropagation();
onSelect({ value: createText, title: createText });
}}
onKeyDown={clickOnKeyDown(() =>
onSelect({ value: createText, title: createText })
)}
onMouseDown={(e) => {
e.preventDefault();
}}
onMouseEnter={() => onMouseEnter(0)}
onMouseMove={onMouseMove}
className="opal-select-create"
data-highlighted={highlightedIndex === 0 || undefined}
>
<span className="opal-select-create-label">{createText}</span>
<SvgPlus className="opal-select-create-icon" />
</div>
/>
)}
{/* A line separates consecutive groups; it carries the group's title
@@ -127,41 +127,70 @@ export const OptionsList: React.FC<OptionsListProps> = ({
let globalIndex = indexOffset;
let exactSeen = false;
return sections.map((group, groupIdx) => {
const rows = (
const isFoldable = group.foldable && group.title !== undefined;
// The title claims its stop before the rows claim theirs.
const headerIndex = isFoldable ? globalIndex++ : -1;
const rows = group.options.map((option) => {
const index = globalIndex++;
const isExact =
(markAllMatches || !exactSeen) && isExactMatch(option);
if (isExact) exactSeen = true;
return (
<OptionItem
key={option.value}
option={option}
index={index}
fieldId={fieldId}
isHighlighted={index === highlightedIndex}
isSelected={
selectedValues
? selectedValues.has(option.value)
: value === option.value
}
isExact={isExact}
onSelect={onSelect}
/>
);
});
// A foldable group's title is its fold control and a keyboard stop
// of its own; its rows are its children, withheld while folded.
if (isFoldable && group.title !== undefined) {
const index = headerIndex;
return (
<div
key={groupIdx}
// Plumbing only: the id for aria-activedescendant and the
// index for scroll-into-view. Hover and press styling are
// the Divider's own Interactive.
id={`${fieldId}-group-${sanitizeOptionId(group.title)}`}
role="presentation"
className="opal-select-group"
data-index={index}
>
<Divider
title={group.title}
foldable
open={!group.folded}
onOpenChange={() => onToggleGroup?.(group)}
// Only the keyboard stop reads as hover; an open title
// stays at rest, unlike a standalone foldable Divider.
interaction={index === highlightedIndex ? "hover" : "rest"}
>
{rows}
</Divider>
</div>
);
}
return (
<React.Fragment key={groupIdx}>
{group.title !== undefined ? (
<Divider title={group.title} />
) : (
groupIdx > 0 && <Divider />
)}
{group.options.map((option) => {
const index = globalIndex++;
const isExact =
(markAllMatches || !exactSeen) && isExactMatch(option);
if (isExact) exactSeen = true;
return (
<OptionItem
key={option.value}
option={option}
index={index}
fieldId={fieldId}
isHighlighted={index === highlightedIndex}
isSelected={
selectedValues
? selectedValues.has(option.value)
: value === option.value
}
isExact={isExact}
onSelect={onSelect}
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
searchTerm={inputValue}
/>
);
})}
{rows}
</React.Fragment>
);
return rows;
});
})()}
</>
@@ -1,8 +1,9 @@
import React, { useEffect, forwardRef } from "react";
import React, { useEffect, useRef, forwardRef } from "react";
import { createPortal } from "react-dom";
import "@opal/components/inputs/dropdowns/dropdown/styles.css";
import { cn } from "@opal/utils";
import { ShadowDiv } from "@opal/components/shadow-div/components";
import InputTypeIn from "@opal/components/inputs/texts/input-type-in/components";
import usePresence from "@opal/hooks/usePresence";
import { OptionsList } from "./OptionsList";
import type { SelectOption } from "../types";
@@ -23,10 +24,8 @@ interface SelectDropdownProps {
markAllMatches?: boolean;
highlightedIndex: number;
onSelect: (option: SelectOption) => void;
onMouseEnter: (index: number) => void;
/** The pointer moved inside the list: the keyboard highlight yields. */
onMouseMove: () => void;
/** Pointer left the listbox — clear the pointer-driven highlight. */
onMouseLeave: () => void;
isExactMatch: (option: SelectOption) => boolean;
/** Current input value for creating new option */
inputValue: string;
@@ -42,6 +41,19 @@ interface SelectDropdownProps {
* over rows must never scroll the list under itself.
*/
keyboardNav: boolean;
/**
* A search field pinned above the rows. It takes focus when the list
* opens; the key handler is the trigger's, so arrows, Enter, Escape and
* Tab behave the same from either.
*/
searchField?: {
value: string;
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void;
placeholder: string;
};
/** A click on a foldable group's title. */
onToggleGroup?: (group: OptionGroup) => void;
}
/**
@@ -64,15 +76,15 @@ export const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
markAllMatches,
highlightedIndex,
onSelect,
onMouseEnter,
onMouseMove,
onMouseLeave,
isExactMatch,
inputValue,
allowCreate,
showCreateOption,
dropdownMaxHeight,
keyboardNav,
searchField,
onToggleGroup,
},
ref
) => {
@@ -80,6 +92,15 @@ export const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
// it can animate out without living in the tree while closed.
const presence = usePresence(isOpen);
// The search field takes focus when the list opens, so typing starts
// at once. An effect rather than autoFocus: the field mounts with the
// list, and focus must follow every open, not only the first mount.
const searchRef = useRef<HTMLInputElement>(null);
const hasSearch = searchField !== undefined;
useEffect(() => {
if (isOpen && hasSearch) searchRef.current?.focus();
}, [isOpen, hasSearch]);
// Keyboard navigation keeps the highlighted row in view. Pointer
// highlights never scroll: the list must not move under the mouse.
useEffect(() => {
@@ -91,9 +112,15 @@ export const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
ref.current &&
highlightedIndex >= 0
) {
const highlightedElement = ref.current.querySelector(
const stop = ref.current.querySelector(
`[data-index="${highlightedIndex}"]`
);
// A foldable group's stop is its wrapper, title and rows together;
// "nearest" is satisfied while any of that tall block shows, so
// scroll the title itself.
const highlightedElement = stop?.classList.contains("opal-select-group")
? stop.firstElementChild
: stop;
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
@@ -140,10 +167,14 @@ export const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
// Closed while exiting: invisible to AT and to the pointer.
aria-hidden={presence.state === "closed" || undefined}
data-state={presence.state}
// Highlighting is modal: while the keyboard drives it, rows and
// titles ignore the pointer so no hover paints beside the keyboard
// stop. The first pointer movement hands control back.
data-keyboard-nav={keyboardNav || undefined}
onMouseMove={onMouseMove}
className="opal-select-dropdown"
style={floatingStyles}
onAnimationEnd={presence.onAnimationEnd}
onMouseLeave={onMouseLeave}
onMouseDown={(e) => {
// Clicks on padding, gaps, or dividers must not steal focus from
// the combobox input (the listbox is tabIndex={-1} for AT only).
@@ -158,8 +189,34 @@ export const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
e.stopPropagation();
}}
>
{searchField && (
<div
role="presentation"
className="opal-select-search"
// The listbox root cancels mousedown to keep focus on the
// trigger; a click into the search field must focus it. The
// click is held too: React bubbles through the portal, and the
// multi trigger's root click would pull focus straight back.
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<InputTypeIn
ref={searchRef}
searchIcon
variant="internal"
placeholder={searchField.placeholder}
aria-label={searchField.placeholder}
value={searchField.value}
onChange={(e) => searchField.onChange(e.target.value)}
onKeyDown={searchField.onKeyDown}
/>
</div>
)}
<ShadowDiv
shadowHeight={3}
// Fade the rows themselves at the scroll edges. A painted shadow
// sat on top of the rows and read as a smudge on the light surface.
variant="mask"
// The rise-and-settle runs on this non-scrolling wrapper: a
// transform on the scroller itself makes Chromium repaint it at
// scroll offset 0 for a frame when compositing switches.
@@ -183,12 +240,11 @@ export const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
highlightedIndex={highlightedIndex}
fieldId={fieldId}
onSelect={onSelect}
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
isExactMatch={isExactMatch}
inputValue={inputValue}
allowCreate={allowCreate}
showCreateOption={showCreateOption}
onToggleGroup={onToggleGroup}
/>
</ShadowDiv>
</div>,
@@ -1,4 +1,4 @@
import { SelectOption } from "../types";
import type { NavItem } from "../shared";
/**
* Sanitizes a value for use in HTML element IDs.
@@ -13,7 +13,7 @@ interface BuildAriaAttributesProps {
isValid: boolean;
highlightedIndex: number;
fieldId: string;
allVisibleOptions: SelectOption[];
items: NavItem[];
placeholder?: string;
/**
* Whether the trigger is a text input that filters the list. A button
@@ -31,14 +31,18 @@ export function buildAriaAttributes({
isValid,
highlightedIndex,
fieldId,
allVisibleOptions,
items,
placeholder,
typeIn = true,
}: BuildAriaAttributesProps) {
const activeOption =
isOpen && highlightedIndex >= 0
? allVisibleOptions[highlightedIndex]
: undefined;
const active =
isOpen && highlightedIndex >= 0 ? items[highlightedIndex] : undefined;
const activeId =
active?.kind === "option"
? `${fieldId}-option-${sanitizeOptionId(active.option.value)}`
: active?.kind === "group" && active.group.title !== undefined
? `${fieldId}-group-${sanitizeOptionId(active.group.title)}`
: undefined;
return {
"aria-label": placeholder,
@@ -47,9 +51,7 @@ export function buildAriaAttributes({
"aria-expanded": isOpen,
"aria-haspopup": "listbox" as const,
"aria-controls": `${fieldId}-listbox`,
"aria-activedescendant": activeOption
? `${fieldId}-option-${sanitizeOptionId(activeOption.value)}`
: undefined,
"aria-activedescendant": activeId,
"aria-autocomplete": typeIn ? ("list" as const) : undefined,
role: "combobox" as const,
};
@@ -4,18 +4,31 @@
Select dropdown — the family-internal listbox
State styling rides data attributes set by the components:
.opal-select-option: data-exact, data-highlighted, data-selected,
data-disabled. Layout-only values; colors come from the tokens.
Rows are presentational LineItemButtons: selection, hover, press and
the keyboard highlight are its palette. This file carries the box,
the search field, the groups and the create row's icon.
============================================================================ */
.opal-select-dropdown {
@apply z-10000 overflow-hidden border bg-background-neutral-00 rounded-12 shadow-box-01 pointer-events-auto touch-auto;
}
/* The search field pinned above the rows: a borderless input on its own
row, with the same 4px inset as the rows below it. */
.opal-select-search {
@apply p-1 pb-0;
}
/* The inner padding sits inside the ShadowDiv, so its scroll gradients span
the bordered box edge to edge instead of stopping at the padding. */
the bordered box edge to edge instead of stopping at the padding. The
list scrolls without a visible scrollbar: the mask fade at each edge
already says there is more. */
.opal-select-dropdown-scroll {
@apply flex flex-col gap-1 overflow-x-hidden p-1;
scrollbar-width: none;
}
.opal-select-dropdown-scroll::-webkit-scrollbar {
display: none;
}
/* Open and close animations. floating-ui positions the root with a
@@ -77,78 +90,33 @@
}
.opal-select-no-match {
@apply px-3 py-2 text-text-02 font-secondary-body;
@apply px-2 py-2 text-text-02 font-secondary-body;
}
/* ---------------------------------------------------------------------------
Option rows
--------------------------------------------------------------------------- */
.opal-select-option {
@apply flex cursor-pointer flex-col px-3 py-2 rounded-08 transition-colors;
}
.opal-select-option[data-exact] {
@apply bg-[var(--action-selection-01)];
}
.opal-select-option:not([data-exact])[data-highlighted],
.opal-select-option:not([data-exact])[data-selected] {
@apply bg-background-tint-02;
}
.opal-select-option:not([data-disabled]):not([data-exact]):hover {
@apply bg-background-tint-02;
}
.opal-select-option[data-disabled] {
@apply cursor-not-allowed bg-background-neutral-02 opacity-50;
/* 8px side padding: with the listbox 4px wider than the trigger on each
side, a row's text and icon then sit exactly under the trigger's. */
/* Keyboard mode: rows and titles ignore the pointer, so the pointer's hover
cannot paint a second highlight beside the keyboard's. A pointer movement
over the list drops the flag and hover returns. */
.opal-select-dropdown[data-keyboard-nav] [role="option"],
.opal-select-dropdown[data-keyboard-nav] .opal-select-group {
pointer-events: none;
}
.opal-select-option-label {
@apply flex flex-row items-center gap-1.5 text-text-04 font-main-ui-action;
}
.opal-select-option[data-exact] .opal-select-option-label {
@apply font-medium;
color: var(--action-selection-05);
}
.opal-select-option:not([data-exact])[data-selected] .opal-select-option-label {
@apply font-medium;
/* A foldable group wraps its title and rows; the same row gap as the list. */
.opal-select-group {
@apply flex flex-col gap-1;
}
.opal-select-option-icon {
@apply h-4 w-4 shrink-0;
stroke: var(--text-03);
}
.opal-select-option[data-exact] .opal-select-option-icon {
stroke: var(--action-selection-05);
}
/* The highlight splits the label into segments; they must stay inline —
as direct flex children the label's gap would space them mid-word. */
.opal-select-option-text {
@apply min-w-0 truncate;
}
.opal-select-match {
@apply font-semibold;
}
.opal-select-option-description {
@apply mt-0.5 text-text-03 font-secondary-body;
}
.opal-select-option[data-exact] .opal-select-option-description {
color: var(--action-selection-04);
}
/* ---------------------------------------------------------------------------
Create row (open mode)
--------------------------------------------------------------------------- */
.opal-select-create {
@apply flex cursor-pointer items-center justify-between px-3 py-2 rounded-08 transition-colors hover:bg-background-tint-02;
}
.opal-select-create[data-highlighted] {
@apply bg-background-tint-02;
}
.opal-select-create-label {
@apply min-w-0 truncate text-text-04 font-main-ui-action;
/* Keyboard scrolling brings a stop to the nearest edge; the mask fade there
is 12px, so a stop keeps that much clear of the edge. */
.opal-select-dropdown-scroll [role="option"],
.opal-select-group > :first-child {
scroll-margin-block: 0.75rem;
}
.opal-select-create-icon {
@@ -2,7 +2,7 @@
**Import:** `import { InputMultiComboBox, type InputMultiComboBoxProps } from "@opal/components";`
Several picks from a set behind a text input: [InputTypeInTag](../../texts/input-type-in-tag/README.md)'s chips-in-input chrome over the family's dropdown. Typing filters the option set, focus opens the list, arrows navigate, Enter picks, and a chosen option becomes a `Tag`. The pick-only sibling is [InputMultiSelect](../input-multi-select/README.md).
Several picks from a set behind a text input: [InputTypeInTag](../../texts/input-type-in-tag/README.md)'s chips-in-input chrome over the family's dropdown. Typing filters the option set and opens the list, as does a click, Enter or ArrowDown (focus alone does not); the arrows and Tab walk the rows and wrap around, Enter picks, and a chosen option becomes a `Tag`. The pick-only sibling is [InputMultiSelect](../input-multi-select/README.md).
Free tagging with no set to pick from is `InputTypeInTag` itself. `InputMultiComboBox` always has an option set, so `options` is required.
@@ -23,6 +23,87 @@ function setupUser() {
}
describe("InputMultiSelect", () => {
describe("Search", () => {
test("search: the search field filters the rows and a pick keeps the list open", async () => {
const handleSelect = jest.fn();
const user = setupUser();
render(
<InputMultiSelect
search
tags={[]}
options={mockOptions}
placeholder="Pick"
onSelectOption={handleSelect}
onRemoveTag={jest.fn()}
/>
);
await user.click(screen.getByRole("combobox", { name: "Pick" }));
const search = screen.getByRole("textbox", { name: "Search" });
expect(search).toHaveFocus();
await user.type(search, "ban");
expect(screen.getAllByRole("option")).toHaveLength(1);
await user.click(screen.getByRole("option", { name: /Banana/ }));
expect(handleSelect).toHaveBeenCalledWith(
expect.objectContaining({ value: "banana" })
);
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
});
describe("Keyboard removal", () => {
test("Backspace on the closed field arms the last chip, then removes chips one by one", async () => {
const handleRemove = jest.fn();
const user = setupUser();
render(
<InputMultiSelect
tags={[
{ id: "apple", label: "Apple" },
{ id: "banana", label: "Banana" },
]}
options={mockOptions}
placeholder="Pick"
onSelectOption={jest.fn()}
onRemoveTag={handleRemove}
/>
);
screen.getByRole("combobox", { name: "Pick" }).focus();
await user.keyboard("{Backspace}");
expect(screen.getByRole("button", { name: /Banana/ })).toHaveFocus();
await user.keyboard("{Backspace}");
expect(handleRemove).toHaveBeenCalledWith("banana");
});
test("the arrows walk the chips and Tab leaves the field", async () => {
const user = setupUser();
render(
<>
<InputMultiSelect
tags={[
{ id: "apple", label: "Apple" },
{ id: "banana", label: "Banana" },
]}
options={mockOptions}
placeholder="Pick"
onSelectOption={jest.fn()}
onRemoveTag={jest.fn()}
/>
<button type="button">After</button>
</>
);
const field = screen.getByRole("combobox", { name: "Pick" });
field.focus();
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("button", { name: /Banana/ })).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("button", { name: /Apple/ })).toHaveFocus();
await user.keyboard("{ArrowRight}{ArrowRight}");
expect(field).toHaveFocus();
// Chips are not Tab stops.
await user.keyboard("{Tab}");
expect(screen.getByRole("button", { name: "After" })).toHaveFocus();
});
});
describe("Rendering and picking", () => {
test("a chip shows its option's icon", () => {
function Swatch(props: React.SVGProps<SVGSVGElement>) {
@@ -11,7 +11,12 @@ type-in sibling is [InputMultiComboBox](../input-multi-combo-box/README.md).
A chosen option becomes a tag whose `id` is the option's `value` (via
`onSelectOption`); choosing it again, in the list or on the chip, removes it
through `onRemoveTag`. A chip shows its option's `icon`, when it has one. A
tag outside the set flags the chrome's error variant.
tag outside the set flags the chrome's error variant. Backspace or ArrowLeft
on the focused field arms the last chip; ArrowLeft and ArrowRight walk the
chips and ArrowRight off the last returns to the field; Backspace on an armed
chip removes it and arms the one before, so repeated presses clear chips one
by one. Chips are not Tab stops, so Tab leaves the field. `search` and
foldable dividers work as on [InputSingleSelect](../input-single-select/README.md#search).
```tsx
<InputMultiSelect
@@ -34,6 +39,7 @@ Every [InputTypeInTag](../../texts/input-type-in-tag/README.md) chrome prop (`ta
| `options` | `SelectOptions` | **(required)** | Loose options and dividers, in order |
| `onSelectOption` | `(option: SelectOption) => void` | **(required)** | Called when an option is chosen |
| `placeholder` | `string` | **(required)** | Names the combobox element and shows while there are no chips |
| `search` | `boolean` | `false` | A search field at the top of the list filters the rows; see [InputSingleSelect](../input-single-select/README.md#search) |
| `dropdownMaxHeight` | `string` | `"15rem"` | Max height of the dropdown in CSS units |
Formik: `InputMultiSelectField` from `@opal/form`, whose value is `string[]` of option values.
@@ -84,7 +84,7 @@ describe("InputSingleComboBox", () => {
/>
);
const input = screen.getByPlaceholderText("Select");
fireEvent.focus(input);
fireEvent.click(input);
expect(screen.getByText("A red fruit")).toBeInTheDocument();
});
});
@@ -99,7 +99,7 @@ describe("InputSingleComboBox", () => {
/>
);
const input = screen.getByPlaceholderText("Select");
fireEvent.focus(input);
fireEvent.click(input);
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
@@ -108,7 +108,7 @@ describe("InputSingleComboBox", () => {
<InputSingleComboBox placeholder="Select" value="" options={[]} />
);
const input = screen.getByPlaceholderText("Select");
fireEvent.focus(input);
fireEvent.click(input);
expect(screen.getByRole("listbox")).toBeInTheDocument();
expect(screen.queryAllByRole("option")).toHaveLength(0);
});
@@ -140,7 +140,7 @@ describe("InputSingleComboBox", () => {
/>
);
const input = screen.getByDisplayValue("Apple");
fireEvent.focus(input);
fireEvent.click(input);
// The selected label stays in the trigger and filters the list, so
// only the selection shows, painted as the exact match.
@@ -164,7 +164,7 @@ describe("InputSingleComboBox", () => {
expect(screen.getAllByRole("option").length).toBe(3);
});
test("closes dropdown on tab", async () => {
test("Tab walks the rows while the list is open", async () => {
const user = setupUser();
render(
<InputSingleComboBox
@@ -179,7 +179,12 @@ describe("InputSingleComboBox", () => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
await user.tab();
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(screen.getByRole("listbox")).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Apple/ })).toHaveAttribute(
"data-interaction",
"hover"
);
expect(input).toHaveFocus();
});
});
@@ -489,7 +494,7 @@ describe("InputSingleComboBox", () => {
/>
);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.click(input);
expect(input).toHaveAttribute("aria-expanded", "true");
});
@@ -502,7 +507,7 @@ describe("InputSingleComboBox", () => {
/>
);
const input = screen.getByPlaceholderText("Select");
fireEvent.focus(input);
fireEvent.click(input);
const options = screen.getAllByRole("option");
expect(options.length).toBe(3);
@@ -517,34 +522,13 @@ describe("InputSingleComboBox", () => {
/>
);
const input = screen.getByPlaceholderText("Select a fruit");
fireEvent.focus(input);
fireEvent.click(input);
const listbox = screen.getByRole("listbox");
expect(listbox).toHaveAttribute("aria-label", "Select a fruit");
});
});
describe("Text Highlighting", () => {
test("matching text is highlighted in option labels", async () => {
const user = setupUser();
const { container } = render(
<InputSingleComboBox
placeholder="Select"
value=""
options={mockOptions}
/>
);
const input = screen.getByPlaceholderText("Select");
await user.type(input, "app");
// Look for the highlighted match span
const boldText = container.querySelector(".opal-select-match");
expect(boldText).toBeInTheDocument();
expect(boldText?.textContent).toBe("App");
});
});
describe("onChange vs onValueChange", () => {
test("onChange is called on every keystroke", async () => {
const handleChange = jest.fn();
@@ -2,9 +2,11 @@
**Import:** `import { InputSingleComboBox, type InputSingleComboBoxProps, type SelectOption, type SelectDivider, type SelectOptions } from "@opal/components";`
A single pick from a set behind a text input: typing filters the options, focus
opens the list, and a click while focused reopens it. Keyboard navigation
(arrows, Enter, Escape) and ARIA combobox semantics are built in. `options` is
A single pick from a set behind a text input: typing filters the options and
opens the list, as does a click, Enter or ArrowDown; focus alone does not, so
tabbing through a form passes by. Open, the arrows and Tab walk the rows and
wrap around, Enter picks, Escape closes, and ARIA combobox semantics are
built in. `options` is
required; for a plain text input use `InputTypeIn`. The pick-only sibling is
[InputSingleSelect](../input-single-select/README.md).
@@ -83,6 +83,63 @@ export const WithSections: Story = {
},
};
const providerOptions: SelectOptions = [
{
title: "OpenAI",
foldable: true,
options: [
{ value: "gpt-4o", title: "GPT-4o" },
{ value: "gpt-4.1", title: "GPT-4.1" },
{ value: "o3", title: "o3" },
],
},
{
title: "Anthropic",
foldable: true,
options: [
{ value: "sonnet", title: "Claude Sonnet 4" },
{ value: "opus", title: "Claude Opus 4" },
],
},
{
title: "Google",
foldable: true,
options: [{ value: "gemini", title: "Gemini 2.5 Pro" }],
},
];
/** A search field at the top of the list filters the rows. */
export const Searchable: Story = {
render: function SearchableStory() {
const [value, setValue] = React.useState("");
return (
<InputSingleSelect
search
placeholder="Select a fruit"
value={value}
onValueChange={setValue}
options={fruitOptions}
/>
);
},
};
/** Foldable dividers: the group holding the selection opens, the rest fold. */
export const FoldableDividers: Story = {
render: function FoldableDividersStory() {
const [value, setValue] = React.useState("sonnet");
return (
<InputSingleSelect
search
placeholder="Select a model"
value={value}
onValueChange={setValue}
options={providerOptions}
/>
);
},
};
/** With a default the select never empties: a re-pick does nothing. */
export const WithDefaultOption: Story = {
render: function WithDefaultOptionStory() {
@@ -29,6 +29,307 @@ function setupUser() {
}
describe("InputSingleSelect", () => {
describe("Search and foldable dividers", () => {
const providerOptions = [
{
title: "OpenAI",
foldable: true,
options: [
{ value: "gpt-4o", title: "GPT-4o" },
{ value: "gpt-4.1", title: "GPT-4.1" },
],
},
{
title: "Anthropic",
foldable: true,
options: [{ value: "claude", title: "Claude Sonnet" }],
},
];
test("search: the search field takes focus and filters the rows", async () => {
const user = setupUser();
render(
<InputSingleSelect
search
placeholder="Model"
value=""
options={mockOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
const search = screen.getByRole("textbox", { name: "Search" });
expect(search).toHaveFocus();
expect(screen.getAllByRole("option")).toHaveLength(3);
await user.type(search, "ban");
expect(screen.getAllByRole("option")).toHaveLength(1);
expect(
screen.getByRole("option", { name: /Banana/ })
).toBeInTheDocument();
});
test("search: Escape closes the list and returns focus to the trigger", async () => {
const user = setupUser();
render(
<InputSingleSelect
search
placeholder="Model"
value=""
options={mockOptions}
/>
);
const trigger = screen.getByRole("combobox", { name: "Model" });
await user.click(trigger);
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
expect(trigger).toHaveFocus();
});
test("the walk skips disabled rows", async () => {
const handleValueChange = jest.fn();
const user = setupUser();
render(
<InputSingleSelect
placeholder="Model"
value=""
onValueChange={handleValueChange}
options={[
{ value: "apple", title: "Apple" },
{ value: "banana", title: "Banana", disabled: true },
{ value: "cherry", title: "Cherry" },
]}
/>
);
screen.getByRole("combobox", { name: "Model" }).focus();
await user.keyboard("{Enter}{ArrowDown}{ArrowDown}");
expect(screen.getByRole("option", { name: /Cherry/ })).toHaveAttribute(
"data-interaction",
"hover"
);
await user.keyboard("{ArrowUp}");
expect(screen.getByRole("option", { name: /Apple/ })).toHaveAttribute(
"data-interaction",
"hover"
);
});
test("Enter on the closed trigger opens the list", async () => {
const user = setupUser();
render(
<InputSingleSelect placeholder="Model" value="" options={mockOptions} />
);
screen.getByRole("combobox", { name: "Model" }).focus();
await user.keyboard("{Enter}");
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
test("Tab and the arrows walk the stops and wrap around", async () => {
const handleValueChange = jest.fn();
const user = setupUser();
render(
<InputSingleSelect
placeholder="Model"
value=""
onValueChange={handleValueChange}
options={mockOptions}
/>
);
screen.getByRole("combobox", { name: "Model" }).focus();
await user.keyboard("{Enter}");
// Nothing highlighted yet; Tab lands on the first row.
await user.keyboard("{Tab}");
expect(screen.getByRole("option", { name: /Apple/ })).toHaveAttribute(
"data-interaction",
"hover"
);
// Shift+Tab from the first row wraps to the last.
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByRole("option", { name: /Cherry/ })).toHaveAttribute(
"data-interaction",
"hover"
);
// ArrowDown from the last wraps to the first; Enter picks it.
await user.keyboard("{ArrowDown}{Enter}");
expect(handleValueChange).toHaveBeenCalledWith("apple");
});
test("with a search field, the cycle skips it and typing never highlights", async () => {
const user = setupUser();
render(
<InputSingleSelect
search
placeholder="Model"
value=""
options={mockOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
const searchField = screen.getByRole("textbox", { name: "Search" });
await user.type(searchField, "a");
expect(
screen
.queryAllByRole("option")
.filter((o) => o.getAttribute("data-interaction") === "hover")
).toHaveLength(0);
// Shift+Tab from nothing highlighted enters at the last visible row.
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByRole("option", { name: /Banana/ })).toHaveAttribute(
"data-interaction",
"hover"
);
// Tab from the last row wraps straight to the first; the field keeps focus.
await user.keyboard("{Tab}");
expect(screen.getByRole("option", { name: /Apple/ })).toHaveAttribute(
"data-interaction",
"hover"
);
expect(searchField).toHaveFocus();
});
test("Enter on a foldable divider's title toggles it", async () => {
const user = setupUser();
render(
<InputSingleSelect
placeholder="Model"
value=""
options={providerOptions}
/>
);
screen.getByRole("combobox", { name: "Model" }).focus();
await user.keyboard("{Enter}");
expect(screen.queryAllByRole("option")).toHaveLength(0);
// The first stop is the OpenAI title; Enter opens it.
await user.keyboard("{Tab}{Enter}");
expect(screen.getAllByRole("option")).toHaveLength(2);
await user.keyboard("{Enter}");
expect(screen.queryAllByRole("option")).toHaveLength(0);
});
test("a foldable divider starts folded unless it holds the selection", async () => {
const user = setupUser();
render(
<InputSingleSelect
placeholder="Model"
value="claude"
options={providerOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
expect(
screen.getByRole("option", { name: /Claude/ })
).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: /GPT-4o/ })
).not.toBeInTheDocument();
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
test("clicking a foldable divider's title keeps the list open", async () => {
const user = setupUser();
render(
<InputSingleSelect
placeholder="Model"
value=""
options={providerOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.click(screen.getByText("OpenAI"));
expect(screen.getByRole("listbox")).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(2);
});
test("folding the group that holds the selection leaves every title in place", async () => {
const user = setupUser();
render(
<InputSingleSelect
placeholder="Model"
value="claude"
options={providerOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.click(screen.getByText("Anthropic"));
expect(screen.queryAllByRole("option")).toHaveLength(0);
expect(screen.getByText("OpenAI")).toBeInTheDocument();
expect(screen.getByText("Anthropic")).toBeInTheDocument();
expect(screen.queryByText("No options found")).not.toBeInTheDocument();
});
test("clicking a foldable divider's title toggles its rows", async () => {
const user = setupUser();
render(
<InputSingleSelect
placeholder="Model"
value=""
options={providerOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
expect(screen.queryAllByRole("option")).toHaveLength(0);
await user.click(screen.getByText("OpenAI"));
expect(screen.getAllByRole("option")).toHaveLength(2);
await user.click(screen.getByText("OpenAI"));
expect(screen.queryAllByRole("option")).toHaveLength(0);
});
test("a term matching a divider's title keeps its whole section", async () => {
const user = setupUser();
render(
<InputSingleSelect
search
placeholder="Model"
value=""
options={providerOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.type(screen.getByRole("textbox", { name: "Search" }), "open");
expect(screen.getAllByRole("option")).toHaveLength(2);
expect(screen.queryByText("Anthropic")).not.toBeInTheDocument();
});
test("a group can be folded while searching", async () => {
const user = setupUser();
render(
<InputSingleSelect
search
placeholder="Model"
value=""
options={providerOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.type(screen.getByRole("textbox", { name: "Search" }), "gpt");
expect(screen.getAllByRole("option")).toHaveLength(2);
await user.click(screen.getByText("OpenAI"));
expect(screen.queryAllByRole("option")).toHaveLength(0);
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
test("searching opens folded groups to show their matches", async () => {
const user = setupUser();
render(
<InputSingleSelect
search
placeholder="Model"
value=""
options={providerOptions}
/>
);
await user.click(screen.getByRole("combobox", { name: "Model" }));
expect(screen.queryAllByRole("option")).toHaveLength(0);
await user.type(
screen.getByRole("textbox", { name: "Search" }),
"claude"
);
expect(screen.getAllByRole("option")).toHaveLength(1);
expect(screen.queryByText("OpenAI")).not.toBeInTheDocument();
});
});
describe("Rendering and picking", () => {
const dividedOptions = [
{ value: "none", title: "Do not re-index" },
@@ -43,6 +43,58 @@ outside the set shows the placeholder with the validation error.
/>
```
## Keyboard
The trigger is a button: a click, Enter or ArrowDown opens the list, focus
alone does not, and a second click closes it. The ComboBoxes share every rule
below. Open, the arrows and Tab walk the stops in
order and wrap around from the last row to the first: a foldable divider's
title is a stop of its own, then its rows. With `search` the field keeps
focus throughout and is not a stop, so typing works from anywhere in the
walk. Enter picks the highlighted row or toggles the highlighted title;
Escape closes and returns focus to the trigger. Disabled rows are passed
over. Physical focus stays on the
trigger or the search field, and `aria-activedescendant` follows the
highlight. Typing in the search field never highlights a row; only walking
the list does. Highlighting is modal: while the keyboard drives it the
pointer's hover is suppressed, and the first pointer movement over the list
hands control back to the pointer. A title stop has no highlight of its own yet: its hover and
press come from the Divider's `Interactive`.
Foldable groups start closed, except the one holding the selection; with
nothing selected, every group starts closed.
## Search
With `search` a search field sits at the top of the list and filters the
rows by title or value; a term that matches a divider's title keeps that
whole section. It takes focus when the list opens, so typing starts at once;
arrows, Enter, Escape and Tab work from it as from the trigger. Escape closes
the list and returns focus to the trigger; a Tab on a closed list moves on to the
next field, as it would from the trigger.
## Foldable dividers
A titled divider with `foldable: true` folds its rows behind the title. It
starts closed unless it holds the selection, and starts open while a search
is on so no match hides; either way a click on the title toggles it. Toggles
reset when the search starts or stops and when the list closes. Folded rows
leave the keyboard order.
```tsx
<InputSingleSelect
search
placeholder="Select a model"
value={model}
onValueChange={setModel}
options={providers.map((provider) => ({
title: provider.name,
foldable: true,
options: provider.models,
}))}
/>
```
## Props
| Prop | Type | Default | Description |
@@ -50,6 +102,7 @@ outside the set shows the placeholder with the validation error.
| `value` | `string` | — | Current value (controlled) |
| `onValueChange` | `(value: string) => void` | — | Fires on a pick, and with `""` on an unpick |
| `options` | `SelectOptions` | `[]` | Loose options and dividers, in order |
| `search` | `boolean` | `false` | A search field at the top of the list filters the rows |
| `defaultOption` | `string` | — | Option value an empty `value` resolves to; never empties then |
| `placeholder` | `string` | — | Shown while empty; always the accessible name (required) |
| `isError` | `boolean` | — | External error state (overrides internal validation) |
@@ -1,16 +1,26 @@
"use client";
import { useCallback, useEffect, useId, useMemo, useRef } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useOpalStrings } from "@opal/strings";
import {
TagField,
type TagItem,
} from "@opal/components/inputs/texts/input-type-in-tag/TagField";
import {
buildNavItems,
filterSections,
flattenSections,
normalizeSections,
useSelectKeyboard,
useSelectOverlay,
useFoldedGroups,
} from "../shared";
import { SelectDropdown } from "../dropdown/SelectDropdown";
import { SelectChevron } from "../dropdown/SelectChevron";
@@ -48,7 +58,9 @@ function MultiDropdown(props: MultiDropdownProps) {
focusOnMount,
dropdownMaxHeight,
} = props;
const strings = useOpalStrings();
const typeIn = props.trigger !== "button";
const search = props.trigger === "button" && (props.search ?? false);
// A button trigger has no text: an empty filter, and nothing to commit.
const value = props.value ?? "";
const onChange = props.onChange;
@@ -125,13 +137,20 @@ function MultiDropdown(props: MultiDropdownProps) {
wasOpenRef.current = isOpen;
}, [typeIn, isOpen, value, onChange]);
const hasSearchTerm = value.trim() !== "";
// A Select with a search field filters through it instead of
// the typed text; it is transient and clears with the list.
const [searchText, setSearchText] = useState("");
useEffect(() => {
if (!isOpen) setSearchText("");
}, [isOpen]);
const filterText = typeIn ? value : search ? searchText : "";
const hasSearchTerm = filterText.trim() !== "";
const visibleSections = useMemo(
() => [
...filterSections([{ options: customSelected }], value),
...filterSections(sections, value),
...filterSections([{ options: customSelected }], filterText),
...filterSections(sections, filterText),
],
[customSelected, sections, value]
[customSelected, sections, filterText]
);
const trimmedValue = value.trim().toLowerCase();
// An exact match means Enter should pick the option — or nothing, when
@@ -149,16 +168,26 @@ function MultiDropdown(props: MultiDropdownProps) {
);
const showCreateOption = freeEntry && hasSearchTerm && !exactOptionMatch;
const allVisibleOptions = useMemo(() => {
const baseOptions = flattenSections(visibleSections);
if (showCreateOption) {
// Trimmed, like the create row's own element id, so the keyboard's
// aria-activedescendant resolves.
const trimmed = value.trim();
return [{ value: trimmed, title: trimmed }, ...baseOptions];
}
return baseOptions;
}, [visibleSections, showCreateOption, value]);
const isSelectedOption = useCallback(
(option: SelectOption) => selectedValues.has(option.value),
[selectedValues]
);
const { foldedSections, toggleGroup } = useFoldedGroups({
isOpen,
sections: visibleSections,
isSelected: isSelectedOption,
searching: hasSearchTerm,
});
// The keyboard's stops in render order: the create row when shown, then
// each group's title (when foldable) and its rows. Trimmed, like the
// create row's own element id, so aria-activedescendant resolves.
const navItems = useMemo(() => {
const trimmed = value.trim();
return buildNavItems(
foldedSections,
showCreateOption ? { value: trimmed, title: trimmed } : undefined
);
}, [foldedSections, showCreateOption, value]);
const handleOptionSelect = useCallback(
(option: SelectOption) => {
@@ -201,8 +230,9 @@ function MultiDropdown(props: MultiDropdownProps) {
highlightedIndex,
setHighlightedIndex,
setIsKeyboardNav,
allVisibleOptions,
items: navItems,
onSelect: handleOptionSelect,
onToggleGroup: toggleGroup,
});
const autoId = useId();
@@ -213,7 +243,7 @@ function MultiDropdown(props: MultiDropdownProps) {
isValid: !hasInvalidTag,
highlightedIndex,
fieldId,
allVisibleOptions,
items: navItems,
placeholder: placeholder ?? "",
typeIn,
}),
@@ -247,11 +277,9 @@ function MultiDropdown(props: MultiDropdownProps) {
rootRef={setRootRef}
inputRef={inputRef}
onInputKeyDown={handleDropdownKeyDown}
// Type-in opens on focus, and a click on the already-focused input
// reopens after Escape, like the single's. A button trigger opens on
// click or ArrowDown and a second click closes it; focus alone does
// not open it.
onInputFocus={typeIn ? () => setIsOpen(true) : undefined}
// A click opens either trigger; a second click closes a button
// trigger, and a type-in also opens on typing. Focus alone never
// opens the list, so tabbing through a form passes by.
onInputClick={() => setIsOpen((prev) => (typeIn ? true : !prev))}
inputAriaProps={ariaProps}
>
@@ -272,29 +300,46 @@ function MultiDropdown(props: MultiDropdownProps) {
setFloatingRef={setFloatingRef}
fieldId={fieldId}
placeholder={placeholder ?? ""}
sections={visibleSections}
sections={foldedSections}
emptySet={flatOptions.length === 0}
value=""
selectedValues={selectedValues}
highlightedIndex={highlightedIndex}
onSelect={handleOptionSelect}
onMouseEnter={(index) => {
setIsKeyboardNav(false);
setHighlightedIndex(index);
}}
// The pointer took over: the keyboard highlight yields to Interactive's
// own hover on whatever the pointer is on.
onMouseMove={() => {
if (isKeyboardNav) setIsKeyboardNav(false);
}}
onMouseLeave={() => {
if (!isKeyboardNav) setHighlightedIndex(-1);
if (isKeyboardNav) {
setIsKeyboardNav(false);
setHighlightedIndex(-1);
}
}}
isExactMatch={(option) => selectedValues.has(option.value)}
markAllMatches
inputValue={value}
inputValue={filterText}
allowCreate={freeEntry}
showCreateOption={showCreateOption}
dropdownMaxHeight={dropdownMaxHeight}
keyboardNav={isKeyboardNav}
onToggleGroup={toggleGroup}
searchField={
search
? {
value: searchText,
onChange: (next) => {
setSearchText(next);
// Typing never highlights; only walking the list does.
setHighlightedIndex(-1);
setIsKeyboardNav(false);
},
onKeyDown: (event) => {
handleDropdownKeyDown(event);
if (event.key === "Escape") focusField();
},
placeholder: strings.selectSearchPlaceholder,
}
: undefined
}
/>
</TagField>
);
@@ -24,6 +24,10 @@ import { SelectOption, SelectOptions } from "./types";
export interface OptionGroup {
title?: string;
options: SelectOption[];
/** The rows fold behind the title (see `SelectDivider`). */
foldable?: boolean;
/** Render-only: the group is folded, so its rows are withheld. */
folded?: boolean;
}
/** Groups the set for rendering: each divider is a group, each run of loose options one too. */
@@ -32,7 +36,11 @@ export function normalizeSections(options: SelectOptions = []): OptionGroup[] {
let looseRun: OptionGroup | null = null;
for (const entry of options) {
if ("options" in entry) {
groups.push({ title: entry.title, options: entry.options });
groups.push({
title: entry.title,
options: entry.options,
foldable: entry.foldable,
});
looseRun = null;
continue;
}
@@ -52,8 +60,34 @@ export function flattenSections(groups: OptionGroup[]): SelectOption[] {
}
/**
* Filters each group's options by the search term; groups left empty
* disappear, so the dropdown's dividers never dangle.
* What the keyboard walks: a foldable group's title is a stop of its own
* (Enter toggles it), then its rows. The list renders in this exact order,
* so `highlightedIndex` addresses the same stop in both.
*/
export type NavItem =
| { kind: "group"; group: OptionGroup }
| { kind: "option"; option: SelectOption };
export function buildNavItems(
groups: OptionGroup[],
createOption?: SelectOption
): NavItem[] {
const items: NavItem[] = [];
if (createOption) items.push({ kind: "option", option: createOption });
for (const group of groups) {
if (group.foldable && group.title !== undefined) {
items.push({ kind: "group", group });
}
for (const option of group.options) items.push({ kind: "option", option });
}
return items;
}
/**
* Filters each group's options by the search term, matched against a
* row's title or value. A term that matches a divider's title keeps the
* whole section. Groups left empty disappear, so the dropdown's dividers
* never dangle.
*/
export function filterSections(
groups: OptionGroup[],
@@ -62,17 +96,88 @@ export function filterSections(
const searchTerm = inputValue.trim().toLowerCase();
if (!searchTerm) return groups.filter((g) => g.options.length > 0);
return groups
.map((group) => ({
...group,
options: group.options.filter(
(option) =>
option.title.toLowerCase().includes(searchTerm) ||
option.value.toLowerCase().includes(searchTerm)
),
}))
.map((group) =>
group.title?.toLowerCase().includes(searchTerm)
? group
: {
...group,
options: group.options.filter(
(option) =>
option.title.toLowerCase().includes(searchTerm) ||
option.value.toLowerCase().includes(searchTerm)
),
}
)
.filter((group) => group.options.length > 0);
}
// =============================================================================
// HOOK: useFoldedGroups
// =============================================================================
interface UseFoldedGroupsProps {
isOpen: boolean;
/** Post-filter groups in render order. */
sections: OptionGroup[];
isSelected: (option: SelectOption) => boolean;
/** A search is on: groups open to show their matches, until folded. */
searching: boolean;
}
/**
* Fold state for foldable groups, per open session. A group starts closed
* unless it holds the selection, and starts open while a search is on;
* either way a click on its title toggles it, and the toggle holds until
* the search starts or stops, or the list closes. Returns the groups with
* folded rows withheld, so rendering and the keyboard order agree.
*/
export function useFoldedGroups({
isOpen,
sections,
isSelected,
searching,
}: UseFoldedGroupsProps) {
const [toggled, setToggled] = useState<ReadonlyMap<string, boolean>>(
new Map()
);
// Toggles reset when the list closes and when a search starts or stops,
// so each of those begins from the defaults below.
useEffect(() => {
setToggled(new Map());
}, [isOpen, searching]);
const isGroupOpen = useCallback(
(group: OptionGroup) => {
if (!group.foldable || group.title === undefined) return true;
const choice = toggled.get(group.title);
if (choice !== undefined) return choice;
if (searching) return true;
return group.options.some(isSelected);
},
[toggled, searching, isSelected]
);
const toggleGroup = useCallback(
(group: OptionGroup) => {
if (group.title === undefined) return;
const title = group.title;
const open = isGroupOpen(group);
setToggled((prev) => new Map(prev).set(title, !open));
},
[isGroupOpen]
);
const foldedSections = useMemo(
() =>
sections.map((group) =>
isGroupOpen(group) ? group : { ...group, options: [], folded: true }
),
[sections, isGroupOpen]
);
return { foldedSections, toggleGroup };
}
// =============================================================================
// HOOK: useSelectKeyboard
// =============================================================================
@@ -83,13 +188,20 @@ interface UseSelectKeyboardProps {
highlightedIndex: number;
setHighlightedIndex: (index: number | ((prev: number) => number)) => void;
setIsKeyboardNav: (isKeyboard: boolean) => void;
allVisibleOptions: SelectOption[];
/** The stops in render order. */
items: NavItem[];
onSelect: (option: SelectOption) => void;
onToggleGroup?: (group: OptionGroup) => void;
}
/**
* Manages keyboard navigation for the ComboBox
* Handles arrow keys, Enter, Escape, and Tab
* Keyboard navigation for the family's listbox, the same for every trigger:
* Enter or ArrowDown opens a closed list; open, the arrows and Tab walk the
* stops and wrap around from the last row to the first, Enter picks the
* highlighted row or toggles the highlighted title, and Escape closes. A
* closed list leaves Tab alone, so it moves on as normal. Physical focus
* stays on the trigger or the search field; the highlight moves and
* `aria-activedescendant` follows it.
*/
export function useSelectKeyboard({
isOpen,
@@ -97,68 +209,106 @@ export function useSelectKeyboard({
highlightedIndex,
setHighlightedIndex,
setIsKeyboardNav,
allVisibleOptions,
items,
onSelect,
onToggleGroup,
}: UseSelectKeyboardProps) {
const count = items.length;
// A disabled row is not a stop: the walk passes over it.
const isStop = useCallback(
(index: number) => {
const item = items[index];
return (
item !== undefined && !(item.kind === "option" && item.option.disabled)
);
},
[items]
);
// The stop after `prev`, wrapping from the last row to the first; from
// nothing highlighted (-1) both directions enter the list.
const next = useCallback(
(prev: number) => {
let index = prev;
for (let step = 0; step < count; step++) {
index = index < count - 1 ? index + 1 : 0;
if (isStop(index)) return index;
}
return -1;
},
[count, isStop]
);
const previous = useCallback(
(prev: number) => {
let index = prev;
for (let step = 0; step < count; step++) {
index = index > 0 ? index - 1 : count - 1;
if (isStop(index)) return index;
}
return -1;
},
[count, isStop]
);
const activate = useCallback(() => {
const item = items[highlightedIndex];
if (!item) return;
if (item.kind === "option") onSelect(item.option);
else onToggleGroup?.(item.group);
}, [items, highlightedIndex, onSelect, onToggleGroup]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setIsKeyboardNav(true); // Mark as keyboard navigation
setIsKeyboardNav(true);
if (!isOpen) {
// Opening lands on the first row.
setIsOpen(true);
setHighlightedIndex(0);
} else {
setHighlightedIndex((prev) => {
// If no item highlighted yet (-1), start at 0
if (prev === -1) return 0;
// Otherwise move down if not at end
return prev < allVisibleOptions.length - 1 ? prev + 1 : prev;
});
setHighlightedIndex(next);
}
break;
case "ArrowUp":
e.preventDefault();
setIsKeyboardNav(true); // Mark as keyboard navigation
if (isOpen) {
setHighlightedIndex((prev) => {
// If at first item or no highlight, don't go further up
if (prev <= 0) return -1;
return prev - 1;
});
}
setIsKeyboardNav(true);
if (isOpen) setHighlightedIndex(previous);
break;
case "Tab":
if (!isOpen) break;
// Inside the list Tab walks the stops, both ways, wrapping.
e.preventDefault();
setIsKeyboardNav(true);
setHighlightedIndex(e.shiftKey ? previous : next);
break;
case "Enter":
// Always prevent default and stop propagation when dropdown is open
// to avoid bubbling to parent forms
if (isOpen) {
if (!isOpen) {
e.preventDefault();
e.stopPropagation();
if (highlightedIndex >= 0) {
const option = allVisibleOptions[highlightedIndex];
if (option) {
onSelect(option);
}
}
setIsOpen(true);
setHighlightedIndex(-1);
break;
}
// Always prevent default and stop propagation when the list is
// open, so the key never reaches an enclosing form.
e.preventDefault();
e.stopPropagation();
activate();
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setIsKeyboardNav(false);
break;
case "Tab":
setIsOpen(false);
setIsKeyboardNav(false);
break;
}
},
[
isOpen,
allVisibleOptions,
highlightedIndex,
onSelect,
next,
previous,
activate,
setIsOpen,
setHighlightedIndex,
setIsKeyboardNav,
@@ -12,8 +12,10 @@
* - `trigger="type-in"` (ComboBox): typing filters the option set.
* `mode="closed"` permits only option values; `mode="open"` also commits
* the raw text via the create row.
* - `trigger="button"` (Select): nothing to type. A click or ArrowDown opens
* the full set, a second click closes it, and focus alone does not.
* - `trigger="button"` (Select): nothing to type; a second click closes it.
*
* Either opens on click, Enter or ArrowDown, and a ComboBox on typing too.
* Focus alone never opens the list, so tabbing through a form passes by.
*
* Re-picking the selected option unselects it. A Select may carry a
* `defaultOption`, and then never reads as empty: an empty value resolves to
@@ -43,6 +45,8 @@ import { FieldMessage } from "@opal/form";
// Hooks
import {
buildNavItems,
useFoldedGroups,
useSelectKeyboard,
useSelectOverlay,
filterSections,
@@ -79,6 +83,7 @@ function SingleDropdown({
separatorLabel,
showOtherOptions = false,
dropdownMaxHeight,
search = false,
...rest
}: WithoutStyles<SingleDropdownProps>) {
const typeIn = trigger === "type-in";
@@ -154,9 +159,15 @@ function SingleDropdown({
return { value: effectiveValue, title: effectiveValue };
}, [strict, effectiveValue, options]);
// What filters the list. A button trigger never filters: its text is
// only ever the selection's label.
const filterText = typeIn ? inputValue : "";
// What filters the list: a ComboBox's typed text, or a Select's search
// field when it has one. Otherwise nothing: a button trigger's text is
// only ever the selection's label. The search is transient and clears
// with the list.
const [searchText, setSearchText] = useState("");
useEffect(() => {
if (!isOpen) setSearchText("");
}, [isOpen]);
const filterText = typeIn ? inputValue : search ? searchText : "";
// Filtering: each section filters independently; empty ones disappear.
const hasSearchTerm = filterText.trim() !== "";
@@ -215,18 +226,29 @@ function SingleDropdown({
}, [customSelected, options, trimmedInput]);
const showCreateOption = !strict && hasSearchTerm && !exactVisibleMatch;
// Combined list for keyboard navigation (includes create option when shown)
// Only show matched options when searching (hide unmatched)
const allVisibleOptions = useMemo(() => {
const baseOptions = flattenSections(visibleSections);
if (showCreateOption) {
// Prepend a synthetic option for the "create new" item. Trimmed to
// match what the rendered create row commits.
const createText = filterText.trim();
return [{ value: createText, title: createText }, ...baseOptions];
}
return baseOptions;
}, [visibleSections, showCreateOption, filterText]);
// Foldable groups withhold their rows while folded, for rendering and
// for the keyboard order alike.
const isSelectedOption = useCallback(
(option: SelectOption) => option.value === effectiveValue,
[effectiveValue]
);
const { foldedSections, toggleGroup } = useFoldedGroups({
isOpen,
sections: visibleSections,
isSelected: isSelectedOption,
searching: hasSearchTerm,
});
// The keyboard's stops in render order: the create row when shown, then
// each group's title (when foldable) and its rows.
const navItems = useMemo(() => {
// Trimmed to match what the rendered create row commits.
const createText = filterText.trim();
return buildNavItems(
foldedSections,
showCreateOption ? { value: createText, title: createText } : undefined
);
}, [foldedSections, showCreateOption, filterText]);
// Check if an option is an exact match
const isExactMatch = useCallback(
@@ -253,24 +275,27 @@ function SingleDropdown({
onValidationError,
});
// Sync highlightedIndex with exact match when typing (not keyboard nav)
// A ComboBox highlights the row its typed text matches exactly. A
// Select's search field never highlights on its own: only walking the
// list does.
useEffect(() => {
// Skip if keyboard navigating or dropdown closed
if (isKeyboardNav || !isOpen) return;
if (!typeIn || isKeyboardNav || !isOpen) return;
if (!filterText.trim()) return;
const exactMatchIndex = allVisibleOptions.findIndex(
(opt) =>
opt.value.toLowerCase() === filterText.trim().toLowerCase() ||
opt.title.toLowerCase() === filterText.trim().toLowerCase()
const exactMatchIndex = navItems.findIndex(
(item) =>
item.kind === "option" &&
(item.option.value.toLowerCase() === filterText.trim().toLowerCase() ||
item.option.title.toLowerCase() === filterText.trim().toLowerCase())
);
if (exactMatchIndex >= 0) {
setHighlightedIndex(exactMatchIndex);
}
}, [
typeIn,
filterText,
allVisibleOptions,
navItems,
isKeyboardNav,
isOpen,
setHighlightedIndex,
@@ -380,28 +405,11 @@ function SingleDropdown({
highlightedIndex,
setHighlightedIndex,
setIsKeyboardNav,
allVisibleOptions,
items: navItems,
onSelect: handleOptionSelect,
onToggleGroup: toggleGroup,
});
const handleFocus = useCallback(() => {
setInputValue(selectedLabel);
setIsOpen(true);
setHighlightedIndex(-1);
setIsKeyboardNav(false);
// Caret at the end, ready to modify.
requestAnimationFrame(() => {
const el = inputRef.current;
if (el) el.setSelectionRange(el.value.length, el.value.length);
});
}, [
selectedLabel,
setInputValue,
setIsOpen,
setHighlightedIndex,
setIsKeyboardNav,
]);
const toggleDropdown = useCallback(() => {
if (disabled) return;
setIsOpen((prev) => {
@@ -426,7 +434,7 @@ function SingleDropdown({
isValid,
highlightedIndex,
fieldId,
allVisibleOptions,
items: navItems,
placeholder,
typeIn,
});
@@ -439,8 +447,23 @@ function SingleDropdown({
data-trigger={trigger}
// A button trigger is the whole field, padding included, so the
// toggle lives on the root; the input inside carries the keyboard,
// and the chevron and rightChildren stop propagation.
onClick={typeIn ? undefined : toggleDropdown}
// and the chevron and rightChildren stop propagation. The listbox is
// portalled, so its clicks bubble here through React's tree too: a
// foldable title, the search field or the padding must not toggle
// the list. Only a pick closes it, and the rows do that themselves.
onClick={
typeIn
? undefined
: (event) => {
if (
event.target instanceof Node &&
dropdownRef.current?.contains(event.target)
) {
return;
}
toggleDropdown();
}
}
>
<>
<InputTypeIn
@@ -454,10 +477,8 @@ function SingleDropdown({
value={inputValue}
onChange={handleInputChange}
// A button trigger opens on click or ArrowDown and a second click
// closes it, like a native <select>; keyboard focus alone does
// not open it. Type-in opens on focus, and a click while focused
// reopens it (e.g. after Escape) with the text kept for editing.
onFocus={typeIn ? handleFocus : undefined}
// closes it, like a native <select>. A type-in opens on click or
// typing, with the text kept for editing. Focus alone never opens.
onClick={() => {
if (!typeIn) return;
if (!isOpen) {
@@ -529,29 +550,46 @@ function SingleDropdown({
setFloatingRef={setFloatingRef}
fieldId={fieldId}
placeholder={placeholder ?? ""}
sections={visibleSections}
sections={foldedSections}
emptySet={options.length === 0}
value={effectiveValue}
highlightedIndex={highlightedIndex}
onSelect={handleOptionSelect}
onMouseEnter={(index) => {
setIsKeyboardNav(false);
setHighlightedIndex(index);
}}
// The pointer took over: the keyboard highlight yields to Interactive's
// own hover on whatever the pointer is on.
onMouseMove={() => {
if (isKeyboardNav) {
setIsKeyboardNav(false);
setHighlightedIndex(-1);
}
}}
onMouseLeave={() => {
if (!isKeyboardNav) setHighlightedIndex(-1);
}}
isExactMatch={isExactMatch}
inputValue={filterText}
allowCreate={!strict}
showCreateOption={showCreateOption}
dropdownMaxHeight={dropdownMaxHeight}
keyboardNav={isKeyboardNav}
onToggleGroup={toggleGroup}
searchField={
search
? {
value: searchText,
onChange: (next) => {
setSearchText(next);
// Typing never highlights; only walking the list does.
setHighlightedIndex(-1);
setIsKeyboardNav(false);
},
onKeyDown: (event) => {
handleKeyDown(event);
// Escape closes the list; focus goes back to the
// trigger so the field is not left orphaned.
if (event.key === "Escape") inputRef.current?.focus();
},
placeholder: strings.selectSearchPlaceholder,
}
: undefined
}
/>
</>
@@ -13,13 +13,16 @@ export type SelectOption = {
/**
* A divider with the rows under it, like an `<optgroup>`. A separator line
* sits above it, carrying `title` when there is one. Dividers render in
* order; on a ComboBox a divider whose options all filter out disappears
* with its line, so nothing dangles.
* order; a divider whose options all filter out disappears with its line,
* so nothing dangles.
*
* A titled divider may be `foldable`: its rows fold behind the title. It
* starts closed unless it holds the selection, opens while a search is on,
* and a click on the title toggles it either way.
*/
export type SelectDivider = {
title?: string;
options: SelectOption[];
};
export type SelectDivider =
| { title: string; options: SelectOption[]; foldable?: boolean }
| { title?: undefined; options: SelectOption[]; foldable?: never };
/**
* The set: loose options and dividers in any order, like `<option>`s beside
@@ -87,6 +90,7 @@ export type InputSingleComboBoxProps = InputSingleBaseProps & {
*/
showOtherOptions?: boolean;
defaultOption?: never;
search?: never;
/** Trigger placeholder. */
placeholder: string;
};
@@ -106,6 +110,12 @@ export type InputSingleSelectProps = InputSingleBaseProps & {
showOtherOptions?: never;
/** The option value an empty `value` resolves to. Must be in the set. */
defaultOption?: string;
/**
* A search field at the top of the list filters the rows by title or
* value. It takes focus when the list opens; Escape and Tab hand focus
* back to the trigger.
*/
search?: boolean;
/**
* Shown while empty, and always the field's accessible name, so it is
* required even with a `defaultOption` that keeps the trigger filled.
@@ -143,6 +153,7 @@ type InputMultiBaseProps = Omit<
/** `InputMultiComboBox`: chips beside a text input whose text filters the set. */
export type InputMultiComboBoxProps = InputMultiBaseProps & {
search?: never;
/**
* Set openness:
* - "closed" (default): only options can be chosen; typing filters.
@@ -163,6 +174,8 @@ export type InputMultiComboBoxProps = InputMultiBaseProps & {
export type InputMultiSelectProps = InputMultiBaseProps & {
/** Names the combobox element, which has no text input to inherit one. */
placeholder: string;
/** A search field at the top of the list filters the rows. */
search?: boolean;
mode?: never;
value?: never;
onChange?: never;
@@ -7,8 +7,8 @@ Free tagging, the Figma `Input/Tags` component: editable `Tag`s rendered inline
Interaction model:
- Enter adds the trimmed input text via `onAdd`.
- Backspace on an empty input arms the last tag (its dark keyboard-selection state).
- Backspace or Delete on an armed tag removes it and focus returns to the input. Enter and Space also activate the armed remove button.
- Backspace on an empty input, or ArrowLeft with the caret at the start, arms the last tag (its dark keyboard-selection state). ArrowLeft and ArrowRight walk the armed tags; ArrowRight off the last returns to the input. Tags are not Tab stops, so Tab leaves the field.
- Backspace or Delete on an armed tag removes it and arms the tag before it, so repeated presses clear tags one by one; with none left, focus returns to the input. Enter and Space also activate the armed remove button.
- Clicking the field focuses the input.
## Props
@@ -183,6 +183,36 @@ function TagField({
tagsElement.scrollTop = tagsElement.scrollHeight;
}, [tags.length]);
// The chips' remove buttons, in order.
function removeButtons(): HTMLButtonElement[] {
return Array.from(
ownRootRef.current?.querySelectorAll<HTMLButtonElement>(
`.${TAG_REMOVE_CLASS}`
) ?? []
);
}
// Backspace with nothing to delete in the field arms the last chip: its
// remove button takes focus, so the next Backspace removes it.
function armLastTag(event: React.KeyboardEvent<HTMLElement>) {
if (event.key !== "Backspace" || tags.length === 0) return;
event.preventDefault();
removeButtons().at(-1)?.focus();
}
// The button trigger has no text: Backspace or ArrowLeft arms the last
// chip directly.
function handleTriggerKeyDown(event: React.KeyboardEvent<HTMLElement>) {
onInputKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowLeft" && tags.length > 0) {
event.preventDefault();
removeButtons().at(-1)?.focus();
return;
}
armLastTag(event);
}
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
// During IME composition, Enter confirms the candidate and Backspace
// edits the composition. Neither may add or arm tags.
@@ -199,23 +229,51 @@ function TagField({
if (trimmed) onEnter?.(trimmed);
return;
}
if (event.key === "Backspace" && value === "" && tags.length > 0) {
if (value === "") armLastTag(event);
// ArrowLeft with the caret at the start walks back onto the chips.
if (
event.key === "ArrowLeft" &&
tags.length > 0 &&
event.currentTarget.selectionStart === 0 &&
event.currentTarget.selectionEnd === 0
) {
event.preventDefault();
const removes = ownRootRef.current?.querySelectorAll<HTMLButtonElement>(
`.${TAG_REMOVE_CLASS}`
);
removes?.[removes.length - 1]?.focus();
removeButtons().at(-1)?.focus();
}
}
// Backspace/Delete on an armed remove button deletes its tag. Enter and
// Space already work as native button activation.
// Removing from the keyboard then arms the chip before the removed one,
// so holding Backspace clears chips one by one; with none left, focus
// returns to the field.
function handleRootKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key !== "Backspace" && event.key !== "Delete") return;
const target = event.target as HTMLElement;
if (!target.classList.contains(TAG_REMOVE_CLASS)) return;
const buttons = removeButtons();
const index = buttons.indexOf(target as HTMLButtonElement);
// The arrows walk the chips; Right off the last one returns to the
// field. Tab is free to leave the field, since chips are not stops.
if (event.key === "ArrowLeft") {
event.preventDefault();
buttons[index - 1]?.focus();
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
const next = buttons[index + 1];
if (next) next.focus();
else focusField();
return;
}
if (event.key !== "Backspace" && event.key !== "Delete") return;
event.preventDefault();
target.click();
requestAnimationFrame(() => {
const previous = index > 0 ? removeButtons()[index - 1] : undefined;
if (previous) previous.focus();
else focusField();
});
}
return (
@@ -251,6 +309,7 @@ function TagField({
icon={tag.icon}
error={tag.error}
disabled={disabled}
removeInTabOrder={false}
onRemove={() => {
onRemoveTag(tag.id);
focusField();
@@ -272,7 +331,7 @@ function TagField({
aria-disabled={disabled || undefined}
onFocus={disabled ? undefined : onInputFocus}
onClick={disabled ? undefined : onInputClick}
onKeyDown={disabled ? undefined : onInputKeyDown}
onKeyDown={disabled ? undefined : handleTriggerKeyDown}
>
{tags.length === 0 && placeholder && (
<Text font="main-ui-muted" color="text-02">
@@ -91,8 +91,8 @@ export default function InputTypeIn({
onClick={(e) => e.currentTarget.querySelector("input")?.focus()}
>
{searchIcon && (
<div className="px-1">
<SvgSearch className="w-4 h-4 stroke-text-02" />
<div className="opal-input-leading-icon">
<SvgSearch className="opal-input-leading-icon-svg" />
</div>
)}
@@ -10,12 +10,15 @@
}
/* A chosen option's icon, sized like the search icon. */
/* 2px before and 4px after: the icon lands 8px inside the chrome, where a
listbox row's icon sits, and the text 6px after it, matching the row gap. */
.opal-input-leading-icon {
@apply flex shrink-0 items-center justify-center px-1;
@apply flex shrink-0 items-center justify-center ps-0.5 pe-1;
}
.opal-input-leading-icon-svg {
@apply h-4 w-4 stroke-text-02;
/* Colour, not stroke: a CSS stroke would outline fill-only logos. */
@apply h-4 w-4 text-text-02;
}
.opal-input[data-variant="primary"] .opal-input-field,
@@ -15,6 +15,7 @@ A small colored label used to annotate items with status, category, or metadata.
| `color` | `TagColor` | `"gray"` | Color variant |
| `icon` | `IconFunctionComponent` | — | Optional icon before the title |
| `size` | `"sm" \| "md"` | `"sm"` | Size variant (Figma Small / Regular) |
| `removeInTabOrder` | `boolean` | `true` | Editable only: whether the remove button is a Tab stop. Chip fields set it off and walk chips with the arrows |
| `onRemove` | `() => void` | — | Switches to the editable type and renders the remove button |
| `disabled` | `boolean` | `false` | Editable only: dims the tag, hides the remove button |
| `value` | `string \| RichStr` | — | Secondary text after the title, in `text-03` |
@@ -34,6 +34,13 @@ interface TagProps {
*/
onRemove?: () => void;
/**
* Editable only: whether the remove button is a Tab stop. A chip field
* walks its chips with the arrows and keeps Tab for leaving the field.
* @default true
*/
removeInTabOrder?: boolean;
/** Editable only: dims the tag and hides the remove button. */
disabled?: boolean;
@@ -67,6 +74,7 @@ function Tag({
color = "gray",
size = "sm",
onRemove,
removeInTabOrder = true,
disabled = false,
value,
truncate = false,
@@ -128,6 +136,7 @@ function Tag({
<button
type="button"
className={TAG_REMOVE_CLASS}
tabIndex={removeInTabOrder ? undefined : -1}
aria-label={
typeof title === "string"
? strings.removeItem(title)
+2
View File
@@ -79,6 +79,7 @@ export type OpalStrings = {
comboBoxCreateOption: (prefix: string, value: string) => string;
selectEmptySet: string;
selectInvalidOption: string;
selectSearchPlaceholder: string;
keyValueKey: string;
keyValueValue: string;
keyValueAddLine: string;
@@ -178,6 +179,7 @@ export const defaultOpalStrings: OpalStrings = {
comboBoxCreateOption: (prefix, value) => `${prefix} "${value}"`,
selectEmptySet: "No items found",
selectInvalidOption: "Please select a valid option from the list",
selectSearchPlaceholder: "Search",
keyValueKey: "Key",
keyValueValue: "Value",
keyValueAddLine: "Add Line",
+1
View File
@@ -93,6 +93,7 @@ export default function OpalStringsBridge({
t("comboBox.createOption", { prefix, value }),
selectEmptySet: t("select.emptySet"),
selectInvalidOption: t("select.invalidOption"),
selectSearchPlaceholder: t("select.searchPlaceholder"),
keyValueKey: t("keyValue.key"),
keyValueValue: t("keyValue.value"),
keyValueAddLine: t("keyValue.addLine"),
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "لم يتم العثور على عناصر",
"invalidOption": "يرجى اختيار خيار صالح من القائمة"
"invalidOption": "يرجى اختيار خيار صالح من القائمة",
"searchPlaceholder": "بحث"
},
"settingsHeader": {
"cancel": "إلغاء"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "Keine Einträge gefunden",
"invalidOption": "Bitte eine gültige Option aus der Liste auswählen"
"invalidOption": "Bitte eine gültige Option aus der Liste auswählen",
"searchPlaceholder": "Suchen"
},
"settingsHeader": {
"cancel": "Abbrechen"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "No items found",
"invalidOption": "Please select a valid option from the list"
"invalidOption": "Please select a valid option from the list",
"searchPlaceholder": "Search"
},
"settingsHeader": {
"cancel": "Cancel"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "No se encontraron elementos",
"invalidOption": "Selecciona una opción válida de la lista"
"invalidOption": "Selecciona una opción válida de la lista",
"searchPlaceholder": "Buscar"
},
"settingsHeader": {
"cancel": "Cancelar"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "Aucun élément trouvé",
"invalidOption": "Veuillez sélectionner une option valide dans la liste"
"invalidOption": "Veuillez sélectionner une option valide dans la liste",
"searchPlaceholder": "Rechercher"
},
"settingsHeader": {
"cancel": "Annuler"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "項目が見つかりません",
"invalidOption": "リストから有効なオプションを選択してください"
"invalidOption": "リストから有効なオプションを選択してください",
"searchPlaceholder": "検索"
},
"settingsHeader": {
"cancel": "キャンセル"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "항목을 찾을 수 없습니다",
"invalidOption": "목록에서 유효한 옵션을 선택하세요"
"invalidOption": "목록에서 유효한 옵션을 선택하세요",
"searchPlaceholder": "검색"
},
"settingsHeader": {
"cancel": "취소"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "Nenhum item encontrado",
"invalidOption": "Selecione uma opção válida da lista"
"invalidOption": "Selecione uma opção válida da lista",
"searchPlaceholder": "Pesquisar"
},
"settingsHeader": {
"cancel": "Cancelar"
+2 -1
View File
@@ -13083,7 +13083,8 @@
},
"select": {
"emptySet": "未找到任何项目",
"invalidOption": "请从列表中选择有效选项"
"invalidOption": "请从列表中选择有效选项",
"searchPlaceholder": "搜索"
},
"settingsHeader": {
"cancel": "取消"