@scalewing/react 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ae2d883: `Combobox` (with `ComboboxProps`, `ComboboxGroup`, `ComboboxOption`, `ComboboxBadge`, `ComboboxAction`, `ComboboxSize` and `comboboxSizes`): a text input with a list of suggestions, an ARIA 1.2 combobox with list autocomplete (`docs/requests/teisoro-combobox.md`, Teisoro F-011 release R5).
8
+
9
+ - `label`, `inputValue`, `onInputChange`, `groups` (`{ label?, options: { value, label, description?, badge?, disabled? }[] }[]`, filtered by the consumer), `onSelect`, `action?` (`{ label, onPress, badge? }`), `emptyText`, `note?`, `listLabel?`, `countText?` (`(count) => string`), and the Field props `labelVisuallyHidden`, `placeholder`, `description`, `error`, `required`, `disabled`, `size` (`md` | `xs`).
10
+ - Focus stays in the input; the arrow keys move `aria-activedescendant` and wrap; Enter or a press chooses; a disabled option is never chosen; Escape closes, then empties; Alt+ArrowUp, blur, Tab or a press outside closes; the pointer moves the active option; the active option is held by value across new results; input-method keys are ignored; an always-present polite live region announces the empty text and `countText`.
11
+ - The list is on the popup layer (`openPopups` gains `.sw-combobox-popup`), solid under reduced transparency, with forced-colours rules. Blank words, duplicate values, an unknown size or badge tone throw a `RangeError`.
12
+ - New classes `sw-combobox*`. No new tokens or dependencies.
13
+
14
+ - c79024a: `Field` `variant="search"` (`fieldVariants`, `FieldVariant`), the web twin of the native search field (`docs/requests/teisoro-field-search.md`, Teisoro F-011 release R5): a hidden label that still names a `type="search"` input, a `leading` glyph slot, and, while the input holds text and is enabled and not read-only, a clear button named `clearLabel` that returns focus to the input and calls `onClear` (Escape too). The browser's own clear button is hidden; `enterKeyHint` defaults to `search`. Missing or blank `clearLabel`, missing `onClear`, `prefix`/`suffix`, a non-input or uncontrolled child, or the search props on an outlined field throw. New classes `sw-field-search-field`, `sw-field-search`, `sw-field-search-leading`, `sw-field-search-clear`, `sw-field-search-cross`. No new tokens or dependencies.
15
+
3
16
  ## 1.30.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -99,6 +99,8 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
99
99
 
100
100
  `Field` `prefix` and `suffix` put short text such as `$` or `%` inside the frame of one native `<input>` child, before or after the value. The text is not part of the value; the input is named by its label plus the adornment (`"Drop amount $"`), unless it names itself with its own `aria-label` or `aria-labelledby`, in which case the adornment joins its description. A press anywhere on the frame focuses the input. Formatting the value stays with the consumer. Any other child throws a `TypeError`.
101
101
 
102
+ `Field` `variant="search"` (default `"outlined"`; `fieldVariants`) is a search box over one native `<input>` child with a `value`: the `label` is not drawn but still names the input, which becomes `type="search"` (a `searchbox`); `leading` is your glyph at the frame's start, such as a magnifier, hidden from assistive technology; and while the input holds text and is enabled and not read-only, a clear button named `clearLabel` ("Clear search") ends the frame, puts focus back in the input and calls `onClear` (set the value to `''`); Escape with text calls `onClear` too. The browser's own clear button is hidden, so there is one. The input gets `enterKeyHint="search"` unless you pass one. The frame is the adorned field's, so focus, `error`, `description`, `invalid` and the disabled look work as on any field; a press on the glyph focuses the input; the clear button is the 44 px target on a coarse pointer. A search field without a nonempty `clearLabel` or `onClear`, with `prefix` or `suffix`, or with a child that is not an `<input>` with a `value` throws; `leading`, `clearLabel` and `onClear` on an outlined field, or an unknown `variant`, throw too.
103
+
102
104
  ```tsx
103
105
  <Field label="Drop amount" prefix="$">
104
106
  <input inputMode="decimal" name="drop" />
@@ -408,6 +410,8 @@ A ledger read across as well as down takes `Table` `columnRules` (a hairline at
408
410
  </Inline>
409
411
  ```
410
412
 
413
+ `Combobox` is a text input with a list of suggestions, an ARIA 1.2 combobox with list autocomplete. Pass the `label` (drawn above the input unless `labelVisuallyHidden`), the controlled `inputValue` and `onInputChange`, and `groups` you filter from the text yourself: `{ label?, options }`, each option `{ value, label, description?, badge?: { label, tone? }, disabled? }`. Typing, a press on the input or ArrowDown opens the list on the popover layer under the input, at least as wide; each group with a `label` is a `role="group"` under its heading. ArrowDown and ArrowUp move the active option (named by the input's `aria-activedescendant`; focus stays in the input) and wrap; Enter or a press chooses it and calls `onSelect(value)` (set `inputValue` to its label yourself), and a disabled option is shown and read as unavailable but never chosen. `action` (`{ label, onPress, badge? }`) ends the list as one more option, such as adding a new entry. `emptyText` shows when no group has an option, and a polite live region, always in the page, announces it (and, with `countText: (count) => string`, the number of options); `note` is a muted line under the list that describes the input while open. `listLabel` names the list when it differs from the input's label. The active option is held by its value, so it stays put when your results change under it. Escape closes the list and a second Escape empties the input; Alt+ArrowUp, Tab, a blur or a press outside closes it. The input is a `Field` input, so `description`, `error`, `required`, `disabled`, `placeholder` and `size` (`md` or `xs`; `comboboxSizes`) work as on any field. Blank words, duplicate option values, an unknown `size` or badge tone throw a `RangeError`, and an `action` without `onPress` a `TypeError`.
414
+
411
415
  `MultiSelect` is several choices from a short list behind one trigger, such as which kinds a list shows. The trigger reads the muted `label` and then `summary` (your words for what is chosen, such as "All entries" or "2 services") with a chevron; it opens a panel named by the label with `allLabel` and `clearLabel` commands over one checkbox per option, each with an optional `icon` (use a small `GlyphTile`, never a bare dot) and a hairline before an option with `separatorBefore`. `onChange` reports the checked values in the options' order. It is a disclosure of native checkboxes, not a listbox: the first checkbox takes focus as it opens, Escape closes it and returns focus to the trigger, focus or a press outside closes it, and disabling it while open closes it. Blank words, no or duplicate options, a value that is not an option, or an unknown `align` throw a `RangeError`.
412
416
 
413
417
  ```tsx
@@ -0,0 +1,14 @@
1
+ import { type ComboboxProps } from './combobox/types.js';
2
+ export { comboboxSizes, type ComboboxAction, type ComboboxBadge, type ComboboxGroup, type ComboboxOption, type ComboboxProps, type ComboboxSize, } from './combobox/types.js';
3
+ /**
4
+ * A text input with a list of suggestions (ARIA 1.2 combobox, list
5
+ * autocomplete): typing, a press on the input or ArrowDown opens the list
6
+ * and the consumer filters `groups` from `inputValue`; the arrow keys move
7
+ * the active option (`aria-activedescendant`) while focus stays in the
8
+ * input; Enter or a press chooses it (`onSelect`) or runs the `action`;
9
+ * Escape closes the list, and a second Escape empties the input. A
10
+ * disabled option is shown and read as unavailable but never chosen. The
11
+ * active option is held by its value, so it stays the same option (or
12
+ * none) when the results change under it.
13
+ */
14
+ export declare function Combobox(props: ComboboxProps): import("react").JSX.Element;
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useRef, useState } from 'react';
4
+ import { ComboboxPopup } from './combobox/ComboboxPopup.js';
5
+ import { comboboxEntries } from './combobox/entries.js';
6
+ import { assertCombobox } from './combobox/validate.js';
7
+ import { Field } from './Field.js';
8
+ export { comboboxSizes, } from './combobox/types.js';
9
+ /** A key typed into an input method (Safari reports it as keyCode 229). */
10
+ function isComposing(event) {
11
+ return event.nativeEvent.isComposing || event.keyCode === 229;
12
+ }
13
+ /**
14
+ * A text input with a list of suggestions (ARIA 1.2 combobox, list
15
+ * autocomplete): typing, a press on the input or ArrowDown opens the list
16
+ * and the consumer filters `groups` from `inputValue`; the arrow keys move
17
+ * the active option (`aria-activedescendant`) while focus stays in the
18
+ * input; Enter or a press chooses it (`onSelect`) or runs the `action`;
19
+ * Escape closes the list, and a second Escape empties the input. A
20
+ * disabled option is shown and read as unavailable but never chosen. The
21
+ * active option is held by its value, so it stays the same option (or
22
+ * none) when the results change under it.
23
+ */
24
+ export function Combobox(props) {
25
+ assertCombobox(props);
26
+ const { action, countText, description, disabled = false, emptyText, error, groups, inputValue, label, labelVisuallyHidden = false, listLabel, note, onInputChange, onSelect, placeholder, required = false, size = 'md', } = props;
27
+ const listId = useId();
28
+ const noteId = useId();
29
+ const inputRef = useRef(null);
30
+ const [open, setOpen] = useState(false);
31
+ const [activeKey, setActiveKey] = useState(null);
32
+ // Disabling closes the list for good, so enabling never reopens it.
33
+ if (disabled && open) {
34
+ setOpen(false);
35
+ setActiveKey(null);
36
+ }
37
+ const entries = comboboxEntries(listId, groups, action);
38
+ const shown = open && !disabled;
39
+ const active = shown
40
+ ? entries.find((entry) => entry.key === activeKey)
41
+ : undefined;
42
+ // An active option that leaves the results is dropped, so it never comes
43
+ // back active on its own.
44
+ if (shown && activeKey !== null && !active)
45
+ setActiveKey(null);
46
+ const hasList = entries.length > 0;
47
+ const optionCount = entries.filter((entry) => entry.kind === 'option').length;
48
+ const announcement = !shown
49
+ ? ''
50
+ : optionCount === 0
51
+ ? emptyText
52
+ : (countText?.(optionCount) ?? '');
53
+ function close() {
54
+ setOpen(false);
55
+ setActiveKey(null);
56
+ }
57
+ function choose(entry) {
58
+ if (entry.kind === 'option') {
59
+ if (entry.option.disabled)
60
+ return;
61
+ close();
62
+ onSelect(entry.option.value);
63
+ }
64
+ else {
65
+ close();
66
+ entry.action.onPress();
67
+ }
68
+ }
69
+ function move(step) {
70
+ if (entries.length === 0)
71
+ return;
72
+ const last = entries.length - 1;
73
+ if (!shown || !active) {
74
+ setOpen(true);
75
+ setActiveKey(entries[step === 1 ? 0 : last].key);
76
+ return;
77
+ }
78
+ const next = (active.index + step + entries.length) % entries.length;
79
+ setActiveKey(entries[next].key);
80
+ }
81
+ function onKeyDown(event) {
82
+ if (isComposing(event))
83
+ return;
84
+ if (event.altKey && event.key === 'ArrowDown') {
85
+ event.preventDefault();
86
+ setOpen(true);
87
+ }
88
+ else if (event.altKey && event.key === 'ArrowUp') {
89
+ event.preventDefault();
90
+ close();
91
+ }
92
+ else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
93
+ event.preventDefault();
94
+ move(event.key === 'ArrowDown' ? 1 : -1);
95
+ }
96
+ else if (event.key === 'Enter') {
97
+ if (active) {
98
+ event.preventDefault();
99
+ choose(active);
100
+ }
101
+ }
102
+ else if (event.key === 'Escape') {
103
+ if (shown) {
104
+ event.preventDefault();
105
+ close();
106
+ }
107
+ else if (inputValue !== '') {
108
+ event.preventDefault();
109
+ onInputChange('');
110
+ }
111
+ }
112
+ // Tab moves focus out, and the blur closes the list.
113
+ }
114
+ return (_jsxs("div", { className: "sw-combobox", children: [_jsx(Field, { description: description, error: error, label: label, labelVisuallyHidden: labelVisuallyHidden, required: required, size: size, children: _jsx("input", { "aria-activedescendant": active?.id, "aria-autocomplete": "list", "aria-controls": shown && hasList ? listId : undefined, "aria-describedby": shown && note ? noteId : undefined, "aria-expanded": shown && hasList, autoComplete: "off", disabled: disabled, onBlur: close, onChange: (event) => {
115
+ onInputChange(event.currentTarget.value);
116
+ setOpen(true);
117
+ setActiveKey(null);
118
+ },
119
+ // A press on the input opens the list, as the boards show it
120
+ // under an empty field.
121
+ onClick: () => {
122
+ if (!shown)
123
+ setOpen(true);
124
+ }, onKeyDown: onKeyDown, placeholder: placeholder, ref: inputRef, role: "combobox", spellCheck: false, type: "text", value: inputValue }) }), _jsx("span", { "aria-live": "polite", className: "sw-sr-only", role: "status", children: announcement }), shown ? (_jsx(ComboboxPopup, { activeId: active?.id, emptyText: emptyText, entries: entries, groups: groups, inputRef: inputRef, listId: listId, listLabel: listLabel ?? label, note: note, noteId: noteId, onActivate: (entry) => setActiveKey(entry.key), onChoose: choose, onDismiss: close })) : null] }));
125
+ }
@@ -1,6 +1,8 @@
1
1
  import { type SpacingStep } from '@scalewing/tokens';
2
2
  import { type ReactNode } from 'react';
3
3
  export type FieldSize = 'xs' | 'md';
4
+ export declare const fieldVariants: readonly ["outlined", "search"];
5
+ export type FieldVariant = (typeof fieldVariants)[number];
4
6
  export type FieldProps = {
5
7
  children: ReactNode;
6
8
  gap?: SpacingStep;
@@ -35,5 +37,20 @@ export type FieldProps = {
35
37
  prefix?: string;
36
38
  /** Short text inside the control frame after the value, such as a unit. Not part of the value. */
37
39
  suffix?: string;
40
+ /**
41
+ * `outlined` (default) is the labelled form field. `search` is a search
42
+ * box: the label is not drawn (it stays the input's name), a `leading`
43
+ * glyph sits at the frame's start, the input is `type="search"`, and a
44
+ * clear button named `clearLabel` shows while the input holds text and
45
+ * is enabled; it calls `onClear` (set the value to `''`) and puts focus
46
+ * back in the input. Needs one native `input` child with a `value`.
47
+ */
48
+ variant?: FieldVariant;
49
+ /** A search field's glyph at its start, such as a magnifier; hidden from assistive technology. */
50
+ leading?: ReactNode;
51
+ /** A search field's clear button name, such as "Clear search". */
52
+ clearLabel?: string;
53
+ /** Called by a search field's clear button; the consumer empties the value. */
54
+ onClear?: () => void;
38
55
  };
39
56
  export declare function Field(props: FieldProps): import("react").JSX.Element;
@@ -1,10 +1,34 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { cloneElement, isValidElement, useId } from 'react';
3
+ import { cloneElement, isValidElement, useId, } from 'react';
4
4
  import { adornedControlAria, FieldAdornment, } from './FieldAdornment.js';
5
5
  import { FieldErrorRegion } from './FieldErrorRegion.js';
6
6
  import { FieldLabelText } from './FieldLabelText.js';
7
+ import { SearchFrame } from './field/SearchFrame.js';
7
8
  import { Stack } from './Stack.js';
9
+ export const fieldVariants = ['outlined', 'search'];
10
+ function assertSearch(props, child) {
11
+ const { variant = 'outlined' } = props;
12
+ if (!fieldVariants.includes(variant))
13
+ throw new RangeError(`Unknown Field variant: ${String(variant)}`);
14
+ if (variant !== 'search') {
15
+ if (props.leading !== undefined ||
16
+ props.clearLabel !== undefined ||
17
+ props.onClear !== undefined)
18
+ throw new TypeError('Field leading, clearLabel and onClear are for variant="search"');
19
+ return;
20
+ }
21
+ if (!props.clearLabel?.trim())
22
+ throw new RangeError('A search Field needs a nonempty clearLabel');
23
+ if (typeof props.onClear !== 'function')
24
+ throw new TypeError('A search Field needs onClear');
25
+ if (props.prefix || props.suffix)
26
+ throw new TypeError('A search Field takes leading, not prefix or suffix');
27
+ if (!isValidElement(child) || child.type !== 'input')
28
+ throw new TypeError('A search Field needs one native input child');
29
+ if (child.props.value === undefined)
30
+ throw new TypeError('A search Field needs a controlled input: pass value (and onChange)');
31
+ }
8
32
  /**
9
33
  * `invalid` was passed, even as `false`, but not as `undefined` (a wrapper
10
34
  * forwarding an optional prop passes nothing): such a field needs one native
@@ -17,7 +41,9 @@ function passesInvalid(props) {
17
41
  return props.invalid !== undefined || props.changed !== undefined;
18
42
  }
19
43
  export function Field(props) {
20
- const { children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, invalid = false, changed = false, required = false, prefix, suffix, } = props;
44
+ const { children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, invalid = false, changed = false, required = false, prefix, suffix, variant = 'outlined', leading, clearLabel = '', onClear, } = props;
45
+ assertSearch(props, children);
46
+ const search = variant === 'search';
21
47
  const controlId = useId();
22
48
  const descriptionId = useId();
23
49
  const errorId = useId();
@@ -32,17 +58,20 @@ export function Field(props) {
32
58
  'sw-field',
33
59
  size === 'xs' && 'sw-field-xs',
34
60
  marksInvalid && 'sw-field-invalid',
61
+ search && 'sw-field-search-field',
35
62
  changed && !marksInvalid && 'sw-field-changed',
36
63
  ]
37
64
  .filter(Boolean)
38
65
  .join(' ');
39
- const labelText = (_jsx(FieldLabelText, { label: label, required: required, size: size, visuallyHidden: labelVisuallyHidden }));
66
+ // A search field hides its whole label element instead (see below).
67
+ const labelHidden = labelVisuallyHidden && !search;
68
+ const labelText = (_jsx(FieldLabelText, { label: label, required: required, size: size, visuallyHidden: labelHidden }));
40
69
  if (!isValidElement(children) ||
41
70
  typeof children.type !== 'string' ||
42
71
  !['input', 'select', 'textarea'].includes(children.type)) {
43
72
  if (needsControl)
44
73
  throw new TypeError('Field validation and adornments require one native input, select, or textarea child');
45
- return (_jsxs(Stack, { as: "label", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [labelText, children] }));
74
+ return (_jsxs(Stack, { as: "label", className: className, gap: labelHidden ? 0 : gap, children: [labelText, children] }));
46
75
  }
47
76
  if (adorned && children.type !== 'input')
48
77
  throw new TypeError('Field prefix and suffix require a native input child');
@@ -63,6 +92,26 @@ export function Field(props) {
63
92
  'aria-invalid': marksInvalid ? true : children.props['aria-invalid'],
64
93
  'aria-labelledby': adornment.labelledBy,
65
94
  required: required || children.props.required,
95
+ ...(search
96
+ ? {
97
+ type: 'search',
98
+ enterKeyHint: children.props.enterKeyHint ?? 'search',
99
+ // Escape empties the box through onClear, the one clearing path
100
+ // (WebKit and Blink would otherwise clear it themselves).
101
+ onKeyDown: (event) => {
102
+ children.props.onKeyDown?.(event);
103
+ if (event.key === 'Escape' &&
104
+ !event.defaultPrevented &&
105
+ !event.currentTarget.readOnly &&
106
+ event.currentTarget.value !== '') {
107
+ event.preventDefault();
108
+ onClear?.();
109
+ }
110
+ },
111
+ }
112
+ : {}),
66
113
  });
67
- return (_jsxs(Stack, { as: "div", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [_jsx("label", { htmlFor: children.props.id || controlId, id: labelId, children: labelText }), adorned ? (_jsx(FieldAdornment, { prefix: prefix, prefixId: prefixId, suffix: suffix, suffixId: suffixId, children: control })) : (control), description && !error ? (_jsx("span", { className: "sw-field-description", id: descriptionId, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
114
+ const value = children.props.value;
115
+ const hasText = value !== undefined && value !== null && String(value) !== '';
116
+ return (_jsxs(Stack, { as: "div", className: className, gap: labelHidden ? 0 : gap, children: [_jsx("label", { className: search ? 'sw-sr-only' : undefined, htmlFor: children.props.id || controlId, id: labelId, children: labelText }), search ? (_jsx(SearchFrame, { clearLabel: clearLabel, leading: leading, onClear: onClear, showClear: hasText && !children.props.disabled && !children.props.readOnly, children: control })) : adorned ? (_jsx(FieldAdornment, { prefix: prefix, prefixId: prefixId, suffix: suffix, suffixId: suffixId, children: control })) : (control), description && !error ? (_jsx("span", { className: "sw-field-description", id: descriptionId, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
68
117
  }
@@ -0,0 +1,25 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ComboboxEntry } from './entries.js';
3
+ import { type ComboboxGroup } from './types.js';
4
+ type ComboboxPopupProps = {
5
+ listId: string;
6
+ listLabel: string;
7
+ groups: readonly ComboboxGroup[];
8
+ entries: readonly ComboboxEntry[];
9
+ activeId: string | undefined;
10
+ emptyText: string;
11
+ note?: string;
12
+ noteId: string;
13
+ inputRef: RefObject<HTMLInputElement | null>;
14
+ onActivate: (entry: ComboboxEntry) => void;
15
+ onChoose: (entry: ComboboxEntry) => void;
16
+ onDismiss: () => void;
17
+ };
18
+ /**
19
+ * The open list beside the input, on the popover layer and at least as
20
+ * wide as the input: a heading per group, each option's label, badge and
21
+ * description, then the action; the empty text when nothing matches, and
22
+ * the note under it all.
23
+ */
24
+ export declare function ComboboxPopup({ activeId, emptyText, entries, groups, inputRef, listId, listLabel, note, noteId, onActivate, onChoose, onDismiss, }: ComboboxPopupProps): import("react").JSX.Element;
25
+ export {};
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Fragment, useLayoutEffect, useRef, } from 'react';
4
+ import { cx } from '../../class-names.js';
5
+ import { Badge } from '../Badge.js';
6
+ import { useAnchoredPopover } from '../use-anchored-popover.js';
7
+ function keepInputFocus(event) {
8
+ // A press in the list never takes focus from the input.
9
+ event.preventDefault();
10
+ }
11
+ function EntryBadge({ badge }) {
12
+ if (!badge)
13
+ return null;
14
+ return (_jsx(Badge, { size: "sm", tone: badge.tone ?? 'neutral', children: badge.label }));
15
+ }
16
+ /**
17
+ * The open list beside the input, on the popover layer and at least as
18
+ * wide as the input: a heading per group, each option's label, badge and
19
+ * description, then the action; the empty text when nothing matches, and
20
+ * the note under it all.
21
+ */
22
+ export function ComboboxPopup({ activeId, emptyText, entries, groups, inputRef, listId, listLabel, note, noteId, onActivate, onChoose, onDismiss, }) {
23
+ const popupRef = useRef(null);
24
+ useAnchoredPopover(popupRef, inputRef, inputRef, onDismiss, {
25
+ align: 'start',
26
+ flipInline: true,
27
+ });
28
+ // At least as wide as the input, and kept so when the input resizes.
29
+ useLayoutEffect(() => {
30
+ const popup = popupRef.current;
31
+ const input = inputRef.current;
32
+ if (!popup || !input)
33
+ return;
34
+ const fit = () => {
35
+ popup.style.minWidth = `${input.offsetWidth}px`;
36
+ };
37
+ fit();
38
+ if (typeof ResizeObserver === 'undefined')
39
+ return;
40
+ const observer = new ResizeObserver(fit);
41
+ observer.observe(input);
42
+ return () => observer.disconnect();
43
+ }, [inputRef]);
44
+ useLayoutEffect(() => {
45
+ if (activeId)
46
+ document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
47
+ }, [activeId]);
48
+ const options = entries.filter((entry) => entry.kind === 'option');
49
+ const action = entries.find((entry) => entry.kind === 'action');
50
+ function renderEntry(entry) {
51
+ const disabled = entry.kind === 'option' && entry.option.disabled;
52
+ return (_jsx("div", { "aria-disabled": disabled || undefined, "aria-selected": entry.id === activeId, className: cx('sw-combobox-option', entry.kind === 'action' && 'sw-combobox-action'), id: entry.id, onClick: () => onChoose(entry), onMouseDown: keepInputFocus,
53
+ // The pointer moves the active option, as the arrow keys do.
54
+ onMouseMove: () => {
55
+ if (entry.id !== activeId)
56
+ onActivate(entry);
57
+ }, role: "option", children: entry.kind === 'option' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sw-combobox-label", children: entry.option.label }), _jsx(EntryBadge, { badge: entry.option.badge }), entry.option.description ? (_jsx("span", { className: "sw-combobox-description", children: entry.option.description })) : null] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "sw-combobox-label", children: entry.action.label }), _jsx(EntryBadge, { badge: entry.action.badge })] })) }, entry.id));
58
+ }
59
+ return (_jsxs("div", { className: "sw-combobox-popup", onMouseDown: keepInputFocus, ref: popupRef, children: [entries.length > 0 ? (_jsxs("div", { "aria-label": listLabel, className: "sw-combobox-list", id: listId, role: "listbox", children: [groups.map((group, groupIndex) => {
60
+ const groupEntries = options.filter((entry) => entry.kind === 'option' && entry.groupIndex === groupIndex);
61
+ if (groupEntries.length === 0)
62
+ return null;
63
+ if (!group.label)
64
+ return (_jsx(Fragment, { children: groupEntries.map(renderEntry) }, `${listId}-group-${groupIndex}`));
65
+ const headingId = `${listId}-group-${groupIndex}`;
66
+ return (_jsxs("div", { "aria-labelledby": headingId, role: "group", children: [_jsx("div", { className: "sw-combobox-heading", id: headingId, role: "presentation", children: group.label }), groupEntries.map(renderEntry)] }, headingId));
67
+ }), action ? renderEntry(action) : null] })) : null, options.length === 0 ? (_jsx("div", { "aria-hidden": "true", className: "sw-combobox-empty", children: emptyText })) : null, note ? (_jsx("div", { className: "sw-combobox-note", id: noteId, children: note })) : null] }));
68
+ }
@@ -0,0 +1,18 @@
1
+ import { type ComboboxAction, type ComboboxGroup, type ComboboxOption } from './types.js';
2
+ /** One row the arrow keys move over: an option in a group, or the action. */
3
+ export type ComboboxEntry = {
4
+ kind: 'option';
5
+ key: string;
6
+ id: string;
7
+ index: number;
8
+ option: ComboboxOption;
9
+ groupIndex: number;
10
+ } | {
11
+ kind: 'action';
12
+ key: string;
13
+ id: string;
14
+ index: number;
15
+ action: ComboboxAction;
16
+ };
17
+ /** The list's rows in order, each with a stable key and a DOM id under `listId`. */
18
+ export declare function comboboxEntries(listId: string, groups: readonly ComboboxGroup[], action: ComboboxAction | undefined): ComboboxEntry[];
@@ -0,0 +1,34 @@
1
+ /*
2
+ * Each row's key and DOM id come from its value, not its place, so an
3
+ * option keeps its id (and stays the active one) when the consumer's
4
+ * results change under it, and a new option never inherits an old id.
5
+ */
6
+ const encoder = new TextEncoder();
7
+ /** The value as hex, so any value makes a unique, valid id part. */
8
+ function idPart(value) {
9
+ return Array.from(encoder.encode(value), (byte) => byte.toString(16).padStart(2, '0')).join('');
10
+ }
11
+ /** The list's rows in order, each with a stable key and a DOM id under `listId`. */
12
+ export function comboboxEntries(listId, groups, action) {
13
+ const entries = [];
14
+ groups.forEach((group, groupIndex) => {
15
+ for (const option of group.options)
16
+ entries.push({
17
+ kind: 'option',
18
+ key: `option:${option.value}`,
19
+ id: `${listId}-option-${idPart(option.value)}`,
20
+ index: entries.length,
21
+ option,
22
+ groupIndex,
23
+ });
24
+ });
25
+ if (action)
26
+ entries.push({
27
+ kind: 'action',
28
+ key: 'action',
29
+ id: `${listId}-action`,
30
+ index: entries.length,
31
+ action,
32
+ });
33
+ return entries;
34
+ }
@@ -0,0 +1,63 @@
1
+ import { type BadgeTone } from '../../css/css-data.js';
2
+ /** A short tag after an option's label, such as "Warning" or "Blocked". */
3
+ export type ComboboxBadge = {
4
+ label: string;
5
+ tone?: BadgeTone;
6
+ };
7
+ /** One suggestion in a Combobox. */
8
+ export type ComboboxOption = {
9
+ value: string;
10
+ label: string;
11
+ /** A muted line of detail after the label, such as "3 items · updated Sep 28". */
12
+ description?: string;
13
+ badge?: ComboboxBadge;
14
+ /** Shown, read as unavailable, and never chosen, such as an archived item. */
15
+ disabled?: boolean;
16
+ };
17
+ /** A run of suggestions under a heading, such as "All items". */
18
+ export type ComboboxGroup = {
19
+ /** The group's heading; a group without one has no heading. */
20
+ label?: string;
21
+ options: readonly ComboboxOption[];
22
+ };
23
+ /** A command at the end of the list, such as "+ New entry “acme”". */
24
+ export type ComboboxAction = {
25
+ label: string;
26
+ onPress: () => void;
27
+ badge?: ComboboxBadge;
28
+ };
29
+ export declare const comboboxSizes: readonly ["md", "xs"];
30
+ export type ComboboxSize = (typeof comboboxSizes)[number];
31
+ export type ComboboxProps = {
32
+ /** The input's name, drawn above it unless `labelVisuallyHidden`. */
33
+ label: string;
34
+ labelVisuallyHidden?: boolean;
35
+ /** The text in the input; the consumer filters `groups` from it. */
36
+ inputValue: string;
37
+ onInputChange: (value: string) => void;
38
+ groups: readonly ComboboxGroup[];
39
+ /** Called with the chosen option's value; the consumer sets `inputValue`. */
40
+ onSelect: (value: string) => void;
41
+ action?: ComboboxAction;
42
+ /**
43
+ * Shown in the list, and announced, when no group has an option, such as
44
+ * "Nothing matches".
45
+ */
46
+ emptyText: string;
47
+ /**
48
+ * The words announced for the number of options while the list is open,
49
+ * such as `(n) => \`${n} items\``; without it only the empty text is
50
+ * announced.
51
+ */
52
+ countText?: (count: number) => string;
53
+ /** The list's name when it differs from the input's, such as "Results". */
54
+ listLabel?: string;
55
+ /** A muted line under the suggestions, such as how to find more. */
56
+ note?: string;
57
+ placeholder?: string;
58
+ description?: string;
59
+ error?: string;
60
+ required?: boolean;
61
+ disabled?: boolean;
62
+ size?: ComboboxSize;
63
+ };
@@ -0,0 +1 @@
1
+ export const comboboxSizes = ['md', 'xs'];
@@ -0,0 +1,3 @@
1
+ import { type ComboboxProps } from './types.js';
2
+ /** Fails closed on blank words, duplicate values and unknown sizes. */
3
+ export declare function assertCombobox(props: ComboboxProps): void;
@@ -0,0 +1,44 @@
1
+ import { badgeTones } from '../../css/css-data.js';
2
+ import { comboboxSizes, } from './types.js';
3
+ function blank(text) {
4
+ return typeof text !== 'string' || !text.trim();
5
+ }
6
+ function assertBadge(badge) {
7
+ if (badge === undefined)
8
+ return;
9
+ if (blank(badge.label))
10
+ throw new RangeError('A Combobox badge needs a nonempty label');
11
+ if (badge.tone !== undefined && !badgeTones.includes(badge.tone))
12
+ throw new RangeError(`Unknown Combobox badge tone: ${String(badge.tone)}`);
13
+ }
14
+ /** Fails closed on blank words, duplicate values and unknown sizes. */
15
+ export function assertCombobox(props) {
16
+ if (blank(props.label))
17
+ throw new RangeError('Combobox needs a nonempty label');
18
+ if (blank(props.emptyText))
19
+ throw new RangeError('Combobox needs a nonempty emptyText');
20
+ if (props.listLabel !== undefined && blank(props.listLabel))
21
+ throw new RangeError('A Combobox listLabel must not be blank');
22
+ if (props.size !== undefined && !comboboxSizes.includes(props.size))
23
+ throw new RangeError(`Unknown Combobox size: ${String(props.size)}`);
24
+ const values = new Set();
25
+ for (const group of props.groups) {
26
+ if (group.label !== undefined && blank(group.label))
27
+ throw new RangeError('A Combobox group label must not be blank');
28
+ for (const option of group.options) {
29
+ if (blank(option.value) || blank(option.label))
30
+ throw new RangeError('Combobox options need nonempty values and labels');
31
+ if (values.has(option.value))
32
+ throw new RangeError(`Duplicate Combobox option: ${option.value}`);
33
+ values.add(option.value);
34
+ assertBadge(option.badge);
35
+ }
36
+ }
37
+ if (props.action) {
38
+ if (blank(props.action.label))
39
+ throw new RangeError('A Combobox action needs a nonempty label');
40
+ if (typeof props.action.onPress !== 'function')
41
+ throw new TypeError('A Combobox action needs onPress');
42
+ assertBadge(props.action.badge);
43
+ }
44
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from 'react';
2
+ type SearchFrameProps = {
3
+ children: ReactNode;
4
+ leading?: ReactNode;
5
+ clearLabel: string;
6
+ /** Shown while the input holds text and is enabled. */
7
+ showClear: boolean;
8
+ onClear: () => void;
9
+ };
10
+ /**
11
+ * The search field's frame: the consumer's glyph at the start (hidden from
12
+ * assistive technology), the input, and a clear button at the end while
13
+ * there is text. Clearing calls `onClear` and puts focus back in the input.
14
+ */
15
+ export declare function SearchFrame({ children, clearLabel, leading, onClear, showClear, }: SearchFrameProps): import("react").JSX.Element;
16
+ export {};
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * A press on the frame's chrome (the glyph or the padding) focuses the
4
+ * input, as on an adorned field; a press on the input keeps the browser's
5
+ * own caret placement, and the clear button runs its own action.
6
+ */
7
+ function focusSearchInput(event) {
8
+ const input = event.currentTarget.querySelector('input');
9
+ const target = event.target;
10
+ if (!input || input.disabled || target === input)
11
+ return;
12
+ event.preventDefault();
13
+ // The clear button keeps focus in the input: no blur, and its click
14
+ // still runs.
15
+ if (target.closest('.sw-field-search-clear'))
16
+ return;
17
+ input.focus();
18
+ }
19
+ /**
20
+ * The search field's frame: the consumer's glyph at the start (hidden from
21
+ * assistive technology), the input, and a clear button at the end while
22
+ * there is text. Clearing calls `onClear` and puts focus back in the input.
23
+ */
24
+ export function SearchFrame({ children, clearLabel, leading, onClear, showClear, }) {
25
+ return (_jsxs("span", { className: "sw-field-adorned sw-field-search", onMouseDown: focusSearchInput, children: [leading != null && leading !== false ? (_jsx("span", { "aria-hidden": "true", className: "sw-field-search-leading", children: leading })) : null, children, showClear ? (_jsx("button", { "aria-label": clearLabel, className: "sw-field-search-clear", onClick: (event) => {
26
+ // Focus first, so a consumer that moves focus in onClear wins.
27
+ event.currentTarget.parentElement
28
+ ?.querySelector('input')
29
+ ?.focus();
30
+ onClear();
31
+ }, type: "button", children: _jsx("span", { "aria-hidden": "true", className: "sw-field-search-cross" }) })) : null] }));
32
+ }
@@ -0,0 +1,2 @@
1
+ export declare function cssComboboxClasses(): string;
2
+ export declare function comboboxClassCatalog(): string[];
@@ -0,0 +1,135 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ import { zIndex } from './stacking.js';
3
+ const caption = typographyVariants.caption;
4
+ const label = typographyVariants.label;
5
+ /*
6
+ * The combobox input is a Field's input. Its list is a glass popover on
7
+ * the popup layer (as Select's list), at least as wide as the input: a
8
+ * caption heading per group, then options at the md control height with
9
+ * the label, a badge and a muted description on one wrapping line. The
10
+ * active option (the one aria-activedescendant names) takes the soft
11
+ * muted fill; a hairline parts each option from the one before it, as
12
+ * the boards draw it; a disabled option is muted and not pressable; the action
13
+ * ends the list in the accent colour over a hairline. The empty text and
14
+ * the note are muted captions under the list.
15
+ */
16
+ export function cssComboboxClasses() {
17
+ return `.sw-combobox {
18
+ max-width: 100%;
19
+ min-width: 0;
20
+ }
21
+
22
+ .sw-combobox-popup {
23
+ background: var(--sw-glass-fill);
24
+ border: 1px solid var(--sw-glass-border);
25
+ border-radius: var(--sw-radius-sm);
26
+ box-shadow: var(--sw-elevation-sm), inset 0 1px 0 var(--sw-glass-specular);
27
+ backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
28
+ -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
29
+ box-sizing: border-box;
30
+ color: var(--sw-color-text);
31
+ inset: auto;
32
+ margin: 0;
33
+ max-height: min(var(--sw-select-max), calc(100% - var(--sw-space-2) - var(--sw-space-2)));
34
+ max-width: calc(100% - var(--sw-space-2) - var(--sw-space-2));
35
+ overflow: auto;
36
+ padding: var(--sw-space-1);
37
+ position: fixed;
38
+ ${zIndex('popup')}
39
+ }
40
+
41
+ .sw-combobox-heading {
42
+ color: var(--sw-color-muted);
43
+ font-family: var(--sw-font-sans);
44
+ font-size: ${caption.fontSize}px;
45
+ font-weight: ${label.fontWeight};
46
+ letter-spacing: ${caption.letterSpacing}px;
47
+ line-height: ${caption.lineHeight}px;
48
+ padding: var(--sw-space-2) var(--sw-space-3) var(--sw-space-1);
49
+ }
50
+
51
+ .sw-combobox-option {
52
+ align-items: center;
53
+ border-radius: var(--sw-radius-sm);
54
+ box-sizing: border-box;
55
+ column-gap: var(--sw-space-2);
56
+ cursor: pointer;
57
+ display: flex;
58
+ flex-wrap: wrap;
59
+ font-family: var(--sw-font-sans);
60
+ font-size: ${label.fontSize}px;
61
+ letter-spacing: ${label.letterSpacing}px;
62
+ line-height: ${label.lineHeight}px;
63
+ min-height: var(--sw-control-md-min-height);
64
+ overflow-wrap: anywhere;
65
+ padding: var(--sw-space-1) var(--sw-space-3);
66
+ }
67
+
68
+ .sw-combobox-option + .sw-combobox-option {
69
+ border-top: 1px solid var(--sw-color-border);
70
+ }
71
+
72
+ .sw-combobox-label {
73
+ font-weight: ${label.fontWeight};
74
+ }
75
+
76
+ .sw-combobox-description {
77
+ color: var(--sw-color-muted);
78
+ font-size: ${caption.fontSize}px;
79
+ letter-spacing: ${caption.letterSpacing}px;
80
+ line-height: ${caption.lineHeight}px;
81
+ }
82
+
83
+ .sw-combobox-option[aria-selected='true'] {
84
+ background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent);
85
+ }
86
+
87
+ .sw-combobox-option[aria-disabled='true'] {
88
+ cursor: not-allowed;
89
+ }
90
+
91
+ .sw-combobox-option[aria-disabled='true'] .sw-combobox-label {
92
+ color: var(--sw-color-muted);
93
+ }
94
+
95
+ .sw-combobox-action {
96
+ border-top: 1px solid var(--sw-color-border);
97
+ color: var(--sw-color-accent);
98
+ margin-top: var(--sw-space-1);
99
+ }
100
+
101
+ .sw-combobox-empty,
102
+ .sw-combobox-note {
103
+ color: var(--sw-color-muted);
104
+ font-family: var(--sw-font-sans);
105
+ font-size: ${caption.fontSize}px;
106
+ letter-spacing: ${caption.letterSpacing}px;
107
+ line-height: ${caption.lineHeight}px;
108
+ padding: var(--sw-space-2) var(--sw-space-3);
109
+ }
110
+
111
+ @media (forced-colors: active) {
112
+ .sw-combobox-popup { border-color: CanvasText; }
113
+ .sw-combobox-option[aria-selected='true'] { background: Highlight; color: HighlightText; forced-color-adjust: none; }
114
+ .sw-combobox-option[aria-selected='true'] .sw-combobox-description { color: HighlightText; }
115
+ .sw-combobox-option[aria-selected='true'] .sw-badge { forced-color-adjust: auto; }
116
+ .sw-combobox-option[aria-disabled='true'] .sw-combobox-label { color: GrayText; }
117
+ .sw-combobox-option[aria-selected='true'][aria-disabled='true'] .sw-combobox-label { color: HighlightText; }
118
+ .sw-combobox-option + .sw-combobox-option { border-color: CanvasText; }
119
+ .sw-combobox-action { border-color: CanvasText; }
120
+ }`;
121
+ }
122
+ export function comboboxClassCatalog() {
123
+ return [
124
+ 'sw-combobox',
125
+ 'sw-combobox-popup',
126
+ 'sw-combobox-list',
127
+ 'sw-combobox-heading',
128
+ 'sw-combobox-option',
129
+ 'sw-combobox-label',
130
+ 'sw-combobox-description',
131
+ 'sw-combobox-action',
132
+ 'sw-combobox-empty',
133
+ 'sw-combobox-note',
134
+ ];
135
+ }
@@ -14,6 +14,7 @@ import { cssTableClasses } from './css-table.js';
14
14
  import { cssDateFieldClasses } from './css-date-field.js';
15
15
  import { cssDialogClasses } from './css-dialog.js';
16
16
  import { cssDialogFrameClasses } from './css-dialog-frame.js';
17
+ import { cssComboboxClasses } from './css-combobox.js';
17
18
  import { cssSelectClasses } from './css-select.js';
18
19
  import { cssMultiSelectClasses } from './css-multi-select.js';
19
20
  import { cssSplitClasses } from './css-split.js';
@@ -64,6 +65,7 @@ function reducedTransparencyRules() {
64
65
  .sw-select-list,
65
66
  .sw-action-menu-list,
66
67
  .sw-multi-select-panel,
68
+ .sw-combobox-popup,
67
69
  .sw-date-field-calendar,
68
70
  .sw-toast {
69
71
  background: var(--sw-color-surface);
@@ -148,6 +150,7 @@ ${cssSelectClasses()}
148
150
 
149
151
  ${cssActionMenuClasses()}
150
152
 
153
+ ${cssComboboxClasses()}
151
154
  ${cssMultiSelectClasses()}
152
155
 
153
156
  ${cssActionBarClasses()}
@@ -0,0 +1,2 @@
1
+ export declare function cssFieldSearchClasses(): string;
2
+ export declare function fieldSearchClassCatalog(): string[];
@@ -0,0 +1,118 @@
1
+ import { coarsePointerQuery, touchTarget } from './touch-target.js';
2
+ /*
3
+ * A search field is the adorned frame (the control surface, its focus
4
+ * ring, its danger and disabled looks) with the consumer's glyph in the
5
+ * muted colour at its start, a space-2 gap, and a clear button at its end:
6
+ * a muted disc with a cross in the surface colour, centred in an xs control
7
+ * square (the 44 px target on a coarse pointer) that reaches the frame's
8
+ * edge. The browser's own clear button and search glyph are hidden, so the
9
+ * field shows one of each.
10
+ */
11
+ export function cssFieldSearchClasses() {
12
+ return `.sw-field-adorned.sw-field-search {
13
+ gap: var(--sw-space-2);
14
+ padding-inline-end: 0;
15
+ }
16
+
17
+ .sw-field-search-leading {
18
+ color: var(--sw-color-muted);
19
+ display: inline-flex;
20
+ flex: none;
21
+ }
22
+
23
+ [data-theme] .sw-field-search > input[type='search'] {
24
+ appearance: none;
25
+ -webkit-appearance: none;
26
+ }
27
+
28
+ .sw-field-search > input[type='search']::-webkit-search-cancel-button,
29
+ .sw-field-search > input[type='search']::-webkit-search-decoration {
30
+ -webkit-appearance: none;
31
+ appearance: none;
32
+ display: none;
33
+ }
34
+
35
+ .sw-field-search > input:last-child {
36
+ margin-inline-end: var(--sw-control-md-padding-inline);
37
+ }
38
+
39
+ .sw-field-search-clear {
40
+ align-items: center;
41
+ align-self: stretch;
42
+ appearance: none;
43
+ background: transparent;
44
+ border: 0;
45
+ border-radius: var(--sw-radius-sm);
46
+ color: inherit;
47
+ cursor: pointer;
48
+ display: inline-flex;
49
+ flex: none;
50
+ justify-content: center;
51
+ margin: 0;
52
+ min-width: var(--sw-control-xs-min-height);
53
+ padding: 0 var(--sw-space-2);
54
+ }
55
+
56
+ .sw-field-search-clear:focus-visible {
57
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
58
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
59
+ }
60
+
61
+ /* One ring at a time: the clear button's own while it has focus. */
62
+ .sw-field-search:has(.sw-field-search-clear:focus-visible) {
63
+ outline: none;
64
+ }
65
+
66
+ .sw-field-search-clear:hover .sw-field-search-cross {
67
+ background: var(--sw-color-text);
68
+ }
69
+
70
+ .sw-field-xs .sw-field-search > input:last-child {
71
+ margin-inline-end: var(--sw-control-xs-padding-inline);
72
+ }
73
+
74
+ .sw-field-search-cross {
75
+ background: var(--sw-color-muted);
76
+ border-radius: var(--sw-radius-pill);
77
+ display: inline-block;
78
+ height: var(--sw-space-4);
79
+ position: relative;
80
+ width: var(--sw-space-4);
81
+ }
82
+
83
+ .sw-field-search-cross::before,
84
+ .sw-field-search-cross::after {
85
+ background: var(--sw-color-surface);
86
+ border-radius: 1px;
87
+ content: '';
88
+ height: 2px;
89
+ inset: 0;
90
+ margin: auto;
91
+ position: absolute;
92
+ width: calc(var(--sw-space-2) + 2px);
93
+ }
94
+
95
+ .sw-field-search-cross::before { transform: rotate(45deg); }
96
+ .sw-field-search-cross::after { transform: rotate(-45deg); }
97
+
98
+ @media ${coarsePointerQuery} {
99
+ .sw-field-search-clear { min-width: ${touchTarget}; }
100
+ }
101
+
102
+ @media (forced-colors: active) {
103
+ .sw-field-search-leading { color: FieldText; }
104
+ /* Backgrounds are forced in this mode; the disc keeps its own system colours. */
105
+ .sw-field-search-cross { background: ButtonText; forced-color-adjust: none; }
106
+ .sw-field-search-cross::before,
107
+ .sw-field-search-cross::after { background: ButtonFace; }
108
+ }`;
109
+ }
110
+ export function fieldSearchClassCatalog() {
111
+ return [
112
+ 'sw-field-search-field',
113
+ 'sw-field-search',
114
+ 'sw-field-search-leading',
115
+ 'sw-field-search-clear',
116
+ 'sw-field-search-cross',
117
+ ];
118
+ }
@@ -1,6 +1,7 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
2
  import { controlSurface, disabledControlForcedColors, disabledControlSurface, } from './css-document.js';
3
3
  import { cssFieldChangedClasses } from './css-field-changed.js';
4
+ import { cssFieldSearchClasses, fieldSearchClassCatalog, } from './css-field-search.js';
4
5
  const caption = typographyVariants.caption;
5
6
  /*
6
7
  * A prefix or suffix sits inside the control frame: the wrapper draws the
@@ -83,6 +84,8 @@ export function cssFieldClasses() {
83
84
 
84
85
  ${adornmentRules()}
85
86
 
87
+ ${cssFieldSearchClasses()}
88
+
86
89
  ${cssFieldChangedClasses()}
87
90
 
88
91
  @media (forced-colors: active) {
@@ -106,5 +109,6 @@ export function fieldClassCatalog() {
106
109
  'sw-field-adorned',
107
110
  'sw-field-prefix',
108
111
  'sw-field-suffix',
112
+ ...fieldSearchClassCatalog(),
109
113
  ];
110
114
  }
@@ -17,7 +17,7 @@
17
17
  * surface that scrolls up under it (the header is the page's way back, the
18
18
  * strip its way between sections, and a popup opens downward).
19
19
  * 6. `popup`: a popup outside any lifted surface (Select list, ActionMenu
20
- * list, MultiSelect panel, Tooltip, the DateField and CalendarButton
20
+ * list, MultiSelect panel, Combobox list, Tooltip, the DateField and CalendarButton
21
21
  * calendar) over everything in the page.
22
22
  *
23
23
  * Dialog and Toast sit on the browser's top layer, above all of these.
@@ -53,6 +53,9 @@ const openPopups = [
53
53
  // MultiSelect's panel. Rendered only while open; on the top layer where
54
54
  // the popover API exists.
55
55
  '.sw-multi-select-panel',
56
+ // Combobox's list. Rendered only while open; on the top layer where the
57
+ // popover API exists.
58
+ '.sw-combobox-popup',
56
59
  '.sw-tooltip:not([hidden])',
57
60
  // DateField and CalendarButton share it. Rendered only while open; on the
58
61
  // top layer where the popover API exists.
@@ -39,6 +39,7 @@ import { dialogClassCatalog } from './css-dialog.js';
39
39
  import { dialogFrameClassCatalog } from './css-dialog-frame.js';
40
40
  import { selectClassCatalog } from './css-select.js';
41
41
  import { multiSelectClassCatalog } from './css-multi-select.js';
42
+ import { comboboxClassCatalog } from './css-combobox.js';
42
43
  import { splitClassCatalog } from './css-split.js';
43
44
  import { toastClassCatalog } from './css-toast.js';
44
45
  import { toastCardClassCatalog } from './css-toast-card.js';
@@ -74,6 +75,7 @@ export function utilityClassCatalog() {
74
75
  ...selectClassCatalog(),
75
76
  ...actionMenuClassCatalog(),
76
77
  ...multiSelectClassCatalog(),
78
+ ...comboboxClassCatalog(),
77
79
  ...actionBarClassCatalog(),
78
80
  ...switchClassCatalog(),
79
81
  ...dateFieldClassCatalog(),
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@ export { StatTile, type StatTileEmphasis, type StatTileProps, type StatTileTone,
25
25
  export { TabPanel, type TabItem, type TabPanelProps, Tabs, type TabsProps, tabId, tabPanelId, } from './components/Tabs.js';
26
26
  export { Toast, toastPlacements, toastTones, type ToastAction, type ToastPlacement, } from './components/Toast.js';
27
27
  export type { ToastProps, ToastTone } from './components/Toast.js';
28
- export { Field, type FieldProps, type FieldSize } from './components/Field.js';
28
+ export { Field, fieldVariants, type FieldProps, type FieldSize, type FieldVariant, } from './components/Field.js';
29
29
  export { Step, type StepHeadingLevel, type StepProps, Steps, type StepsLabels, type StepsProps, type StepStatus, } from './components/Steps.js';
30
30
  export { Stepper, type StepperProps, type StepperWidth, } from './components/Stepper.js';
31
31
  export { Inline, type InlineProps } from './components/Inline.js';
@@ -34,6 +34,7 @@ export type { SplitProps } from './components/Split.js';
34
34
  export { Nav, type NavProps } from './components/Nav.js';
35
35
  export { SectionNav, sectionNavAppearances, type SectionNavAppearance, type SectionNavItem, type SectionNavProps, } from './components/SectionNav.js';
36
36
  export { SegmentedControl, type SegmentedControlProps, type SegmentedControlVariant, type SegmentedItem, } from './components/SegmentedControl.js';
37
+ export { Combobox, comboboxSizes, type ComboboxAction, type ComboboxBadge, type ComboboxGroup, type ComboboxOption, type ComboboxProps, type ComboboxSize, } from './components/Combobox.js';
37
38
  export { Select } from './components/Select.js';
38
39
  export type { SelectAction, SelectOption, SelectProps, SelectWidth, } from './components/Select.js';
39
40
  export { MultiSelect, type MultiSelectAlign, type MultiSelectOption, type MultiSelectProps, } from './components/MultiSelect.js';
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ export { Dialog, dialogSizes, } from './components/Dialog.js';
23
23
  export { StatTile, statTileTones, } from './components/StatTile.js';
24
24
  export { TabPanel, Tabs, tabId, tabPanelId, } from './components/Tabs.js';
25
25
  export { Toast, toastPlacements, toastTones, } from './components/Toast.js';
26
- export { Field } from './components/Field.js';
26
+ export { Field, fieldVariants, } from './components/Field.js';
27
27
  export { Step, Steps, } from './components/Steps.js';
28
28
  export { Stepper, } from './components/Stepper.js';
29
29
  export { Inline } from './components/Inline.js';
@@ -31,6 +31,7 @@ export { Split } from './components/Split.js';
31
31
  export { Nav } from './components/Nav.js';
32
32
  export { SectionNav, sectionNavAppearances, } from './components/SectionNav.js';
33
33
  export { SegmentedControl, } from './components/SegmentedControl.js';
34
+ export { Combobox, comboboxSizes, } from './components/Combobox.js';
34
35
  export { Select } from './components/Select.js';
35
36
  export { MultiSelect, } from './components/MultiSelect.js';
36
37
  export { Stack, } from './components/Stack.js';
package/dist/styles.css CHANGED
@@ -828,8 +828,8 @@ body {
828
828
  }
829
829
 
830
830
  /* Backdrop blur makes a glass surface its own stacking context, so it lifts while it holds an open popup (see stacking.ts). */
831
- .sw-card-glass:has(.sw-select-list, .sw-action-menu-list, .sw-multi-select-panel, .sw-tooltip:not([hidden]), .sw-date-field-calendar),
832
- .sw-accordion:has(.sw-select-list, .sw-action-menu-list, .sw-multi-select-panel, .sw-tooltip:not([hidden]), .sw-date-field-calendar) {
831
+ .sw-card-glass:has(.sw-select-list, .sw-action-menu-list, .sw-multi-select-panel, .sw-combobox-popup, .sw-tooltip:not([hidden]), .sw-date-field-calendar),
832
+ .sw-accordion:has(.sw-select-list, .sw-action-menu-list, .sw-multi-select-panel, .sw-combobox-popup, .sw-tooltip:not([hidden]), .sw-date-field-calendar) {
833
833
  position: relative;
834
834
  z-index: 4;
835
835
  }
@@ -3020,6 +3020,110 @@ body {
3020
3020
  }
3021
3021
  }
3022
3022
 
3023
+ .sw-combobox {
3024
+ max-width: 100%;
3025
+ min-width: 0;
3026
+ }
3027
+
3028
+ .sw-combobox-popup {
3029
+ background: var(--sw-glass-fill);
3030
+ border: 1px solid var(--sw-glass-border);
3031
+ border-radius: var(--sw-radius-sm);
3032
+ box-shadow: var(--sw-elevation-sm), inset 0 1px 0 var(--sw-glass-specular);
3033
+ backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
3034
+ -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
3035
+ box-sizing: border-box;
3036
+ color: var(--sw-color-text);
3037
+ inset: auto;
3038
+ margin: 0;
3039
+ max-height: min(var(--sw-select-max), calc(100% - var(--sw-space-2) - var(--sw-space-2)));
3040
+ max-width: calc(100% - var(--sw-space-2) - var(--sw-space-2));
3041
+ overflow: auto;
3042
+ padding: var(--sw-space-1);
3043
+ position: fixed;
3044
+ z-index: 10;
3045
+ }
3046
+
3047
+ .sw-combobox-heading {
3048
+ color: var(--sw-color-muted);
3049
+ font-family: var(--sw-font-sans);
3050
+ font-size: 13px;
3051
+ font-weight: 600;
3052
+ letter-spacing: -0.08px;
3053
+ line-height: 18px;
3054
+ padding: var(--sw-space-2) var(--sw-space-3) var(--sw-space-1);
3055
+ }
3056
+
3057
+ .sw-combobox-option {
3058
+ align-items: center;
3059
+ border-radius: var(--sw-radius-sm);
3060
+ box-sizing: border-box;
3061
+ column-gap: var(--sw-space-2);
3062
+ cursor: pointer;
3063
+ display: flex;
3064
+ flex-wrap: wrap;
3065
+ font-family: var(--sw-font-sans);
3066
+ font-size: 15px;
3067
+ letter-spacing: -0.2px;
3068
+ line-height: 20px;
3069
+ min-height: var(--sw-control-md-min-height);
3070
+ overflow-wrap: anywhere;
3071
+ padding: var(--sw-space-1) var(--sw-space-3);
3072
+ }
3073
+
3074
+ .sw-combobox-option + .sw-combobox-option {
3075
+ border-top: 1px solid var(--sw-color-border);
3076
+ }
3077
+
3078
+ .sw-combobox-label {
3079
+ font-weight: 600;
3080
+ }
3081
+
3082
+ .sw-combobox-description {
3083
+ color: var(--sw-color-muted);
3084
+ font-size: 13px;
3085
+ letter-spacing: -0.08px;
3086
+ line-height: 18px;
3087
+ }
3088
+
3089
+ .sw-combobox-option[aria-selected='true'] {
3090
+ background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent);
3091
+ }
3092
+
3093
+ .sw-combobox-option[aria-disabled='true'] {
3094
+ cursor: not-allowed;
3095
+ }
3096
+
3097
+ .sw-combobox-option[aria-disabled='true'] .sw-combobox-label {
3098
+ color: var(--sw-color-muted);
3099
+ }
3100
+
3101
+ .sw-combobox-action {
3102
+ border-top: 1px solid var(--sw-color-border);
3103
+ color: var(--sw-color-accent);
3104
+ margin-top: var(--sw-space-1);
3105
+ }
3106
+
3107
+ .sw-combobox-empty,
3108
+ .sw-combobox-note {
3109
+ color: var(--sw-color-muted);
3110
+ font-family: var(--sw-font-sans);
3111
+ font-size: 13px;
3112
+ letter-spacing: -0.08px;
3113
+ line-height: 18px;
3114
+ padding: var(--sw-space-2) var(--sw-space-3);
3115
+ }
3116
+
3117
+ @media (forced-colors: active) {
3118
+ .sw-combobox-popup { border-color: CanvasText; }
3119
+ .sw-combobox-option[aria-selected='true'] { background: Highlight; color: HighlightText; forced-color-adjust: none; }
3120
+ .sw-combobox-option[aria-selected='true'] .sw-combobox-description { color: HighlightText; }
3121
+ .sw-combobox-option[aria-selected='true'] .sw-badge { forced-color-adjust: auto; }
3122
+ .sw-combobox-option[aria-disabled='true'] .sw-combobox-label { color: GrayText; }
3123
+ .sw-combobox-option[aria-selected='true'][aria-disabled='true'] .sw-combobox-label { color: HighlightText; }
3124
+ .sw-combobox-option + .sw-combobox-option { border-color: CanvasText; }
3125
+ .sw-combobox-action { border-color: CanvasText; }
3126
+ }
3023
3127
  .sw-multi-select {
3024
3128
  display: inline-flex;
3025
3129
  max-width: 100%;
@@ -4516,6 +4620,104 @@ body {
4516
4620
 
4517
4621
  [data-theme] .sw-field-adorned > input:disabled { background: transparent; }
4518
4622
 
4623
+ .sw-field-adorned.sw-field-search {
4624
+ gap: var(--sw-space-2);
4625
+ padding-inline-end: 0;
4626
+ }
4627
+
4628
+ .sw-field-search-leading {
4629
+ color: var(--sw-color-muted);
4630
+ display: inline-flex;
4631
+ flex: none;
4632
+ }
4633
+
4634
+ [data-theme] .sw-field-search > input[type='search'] {
4635
+ appearance: none;
4636
+ -webkit-appearance: none;
4637
+ }
4638
+
4639
+ .sw-field-search > input[type='search']::-webkit-search-cancel-button,
4640
+ .sw-field-search > input[type='search']::-webkit-search-decoration {
4641
+ -webkit-appearance: none;
4642
+ appearance: none;
4643
+ display: none;
4644
+ }
4645
+
4646
+ .sw-field-search > input:last-child {
4647
+ margin-inline-end: var(--sw-control-md-padding-inline);
4648
+ }
4649
+
4650
+ .sw-field-search-clear {
4651
+ align-items: center;
4652
+ align-self: stretch;
4653
+ appearance: none;
4654
+ background: transparent;
4655
+ border: 0;
4656
+ border-radius: var(--sw-radius-sm);
4657
+ color: inherit;
4658
+ cursor: pointer;
4659
+ display: inline-flex;
4660
+ flex: none;
4661
+ justify-content: center;
4662
+ margin: 0;
4663
+ min-width: var(--sw-control-xs-min-height);
4664
+ padding: 0 var(--sw-space-2);
4665
+ }
4666
+
4667
+ .sw-field-search-clear:focus-visible {
4668
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
4669
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
4670
+ }
4671
+
4672
+ /* One ring at a time: the clear button's own while it has focus. */
4673
+ .sw-field-search:has(.sw-field-search-clear:focus-visible) {
4674
+ outline: none;
4675
+ }
4676
+
4677
+ .sw-field-search-clear:hover .sw-field-search-cross {
4678
+ background: var(--sw-color-text);
4679
+ }
4680
+
4681
+ .sw-field-xs .sw-field-search > input:last-child {
4682
+ margin-inline-end: var(--sw-control-xs-padding-inline);
4683
+ }
4684
+
4685
+ .sw-field-search-cross {
4686
+ background: var(--sw-color-muted);
4687
+ border-radius: var(--sw-radius-pill);
4688
+ display: inline-block;
4689
+ height: var(--sw-space-4);
4690
+ position: relative;
4691
+ width: var(--sw-space-4);
4692
+ }
4693
+
4694
+ .sw-field-search-cross::before,
4695
+ .sw-field-search-cross::after {
4696
+ background: var(--sw-color-surface);
4697
+ border-radius: 1px;
4698
+ content: '';
4699
+ height: 2px;
4700
+ inset: 0;
4701
+ margin: auto;
4702
+ position: absolute;
4703
+ width: calc(var(--sw-space-2) + 2px);
4704
+ }
4705
+
4706
+ .sw-field-search-cross::before { transform: rotate(45deg); }
4707
+ .sw-field-search-cross::after { transform: rotate(-45deg); }
4708
+
4709
+ @media (pointer: coarse) {
4710
+ .sw-field-search-clear { min-width: var(--sw-control-md-min-height); }
4711
+ }
4712
+
4713
+ @media (forced-colors: active) {
4714
+ .sw-field-search-leading { color: FieldText; }
4715
+ /* Backgrounds are forced in this mode; the disc keeps its own system colours. */
4716
+ .sw-field-search-cross { background: ButtonText; forced-color-adjust: none; }
4717
+ .sw-field-search-cross::before,
4718
+ .sw-field-search-cross::after { background: ButtonFace; }
4719
+ }
4720
+
4519
4721
  [data-theme] .sw-field-changed > :is(input, select, textarea),
4520
4722
  .sw-field-changed .sw-field-adorned {
4521
4723
  border-color: var(--sw-color-accent);
@@ -5118,6 +5320,7 @@ body {
5118
5320
  .sw-select-list,
5119
5321
  .sw-action-menu-list,
5120
5322
  .sw-multi-select-panel,
5323
+ .sw-combobox-popup,
5121
5324
  .sw-date-field-calendar,
5122
5325
  .sw-toast {
5123
5326
  background: var(--sw-color-surface);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",