mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
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:
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.
|
||||
|
||||
|
||||
+81
@@ -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.
|
||||
+14
-30
@@ -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).
|
||||
|
||||
|
||||
+57
@@ -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() {
|
||||
|
||||
+301
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -13083,7 +13083,8 @@
|
||||
},
|
||||
"select": {
|
||||
"emptySet": "لم يتم العثور على عناصر",
|
||||
"invalidOption": "يرجى اختيار خيار صالح من القائمة"
|
||||
"invalidOption": "يرجى اختيار خيار صالح من القائمة",
|
||||
"searchPlaceholder": "بحث"
|
||||
},
|
||||
"settingsHeader": {
|
||||
"cancel": "إلغاء"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13083,7 +13083,8 @@
|
||||
},
|
||||
"select": {
|
||||
"emptySet": "項目が見つかりません",
|
||||
"invalidOption": "リストから有効なオプションを選択してください"
|
||||
"invalidOption": "リストから有効なオプションを選択してください",
|
||||
"searchPlaceholder": "検索"
|
||||
},
|
||||
"settingsHeader": {
|
||||
"cancel": "キャンセル"
|
||||
|
||||
@@ -13083,7 +13083,8 @@
|
||||
},
|
||||
"select": {
|
||||
"emptySet": "항목을 찾을 수 없습니다",
|
||||
"invalidOption": "목록에서 유효한 옵션을 선택하세요"
|
||||
"invalidOption": "목록에서 유효한 옵션을 선택하세요",
|
||||
"searchPlaceholder": "검색"
|
||||
},
|
||||
"settingsHeader": {
|
||||
"cancel": "취소"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13083,7 +13083,8 @@
|
||||
},
|
||||
"select": {
|
||||
"emptySet": "未找到任何项目",
|
||||
"invalidOption": "请从列表中选择有效选项"
|
||||
"invalidOption": "请从列表中选择有效选项",
|
||||
"searchPlaceholder": "搜索"
|
||||
},
|
||||
"settingsHeader": {
|
||||
"cancel": "取消"
|
||||
|
||||
Reference in new issue
Block a user