@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 +13 -0
- package/README.md +4 -0
- package/dist/components/Combobox.d.ts +14 -0
- package/dist/components/Combobox.js +125 -0
- package/dist/components/Field.d.ts +17 -0
- package/dist/components/Field.js +54 -5
- package/dist/components/combobox/ComboboxPopup.d.ts +25 -0
- package/dist/components/combobox/ComboboxPopup.js +68 -0
- package/dist/components/combobox/entries.d.ts +18 -0
- package/dist/components/combobox/entries.js +34 -0
- package/dist/components/combobox/types.d.ts +63 -0
- package/dist/components/combobox/types.js +1 -0
- package/dist/components/combobox/validate.d.ts +3 -0
- package/dist/components/combobox/validate.js +44 -0
- package/dist/components/field/SearchFrame.d.ts +16 -0
- package/dist/components/field/SearchFrame.js +32 -0
- package/dist/css/css-combobox.d.ts +2 -0
- package/dist/css/css-combobox.js +135 -0
- package/dist/css/css-components.js +3 -0
- package/dist/css/css-field-search.d.ts +2 -0
- package/dist/css/css-field-search.js +118 -0
- package/dist/css/css-field.js +4 -0
- package/dist/css/stacking.js +4 -1
- package/dist/css/stylesheet.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/styles.css +205 -2
- package/package.json +1 -1
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;
|
package/dist/components/Field.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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,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,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,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
|
+
}
|
package/dist/css/css-field.js
CHANGED
|
@@ -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
|
}
|
package/dist/css/stacking.js
CHANGED
|
@@ -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.
|
package/dist/css/stylesheet.js
CHANGED
|
@@ -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);
|