@forwardreach/saas-ui 0.10.4 → 0.12.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 +253 -34
- package/dist/components/audit-log.js +8 -3
- package/dist/components/checkbox.js +1 -1
- package/dist/components/choice-card.d.ts +29 -7
- package/dist/components/choice-card.js +92 -12
- package/dist/components/combobox.d.ts +60 -24
- package/dist/components/combobox.js +123 -71
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/components/input.js +1 -1
- package/dist/components/option-list.d.ts +140 -0
- package/dist/components/option-list.js +142 -0
- package/dist/components/popover.d.ts +23 -0
- package/dist/components/popover.js +32 -0
- package/dist/components/role-menu.d.ts +7 -5
- package/dist/components/role-menu.js +7 -5
- package/dist/components/select-menu.d.ts +128 -0
- package/dist/components/select-menu.js +219 -0
- package/dist/components/select.d.ts +14 -1
- package/dist/components/select.js +14 -1
- package/dist/components/textarea.js +1 -1
- package/dist/components/time-zone-field.d.ts +11 -2
- package/dist/components/time-zone-field.js +25 -15
- package/package.json +4 -2
|
@@ -1,27 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
label: string;
|
|
5
|
-
/** Optional trailing content (e.g. a GMT offset) shown right-aligned. */
|
|
6
|
-
trailing?: React.ReactNode;
|
|
7
|
-
/**
|
|
8
|
-
* Optional secondary text shown next to the label, in the reading order a
|
|
9
|
-
* subtitle would take. Distinct from `trailing`, which is right-aligned and
|
|
10
|
-
* monospaced; an option may carry both.
|
|
11
|
-
*/
|
|
12
|
-
description?: React.ReactNode;
|
|
13
|
-
/**
|
|
14
|
-
* Optional heading this option sits under. Consecutive options declaring the
|
|
15
|
-
* same group collapse into one heading, exactly as `<optgroup>` does — the
|
|
16
|
-
* component never reorders, so interleaved groups (`A, B, A`) render three
|
|
17
|
-
* headings, not two. Sort the list before passing it if that is not wanted.
|
|
18
|
-
*
|
|
19
|
-
* Grouped and ungrouped options may be mixed: an option declaring no group
|
|
20
|
-
* ends the run above it and renders outside every group, the way an
|
|
21
|
-
* `<option>` following an `</optgroup>` sits outside that group.
|
|
22
|
-
*/
|
|
23
|
-
group?: string;
|
|
24
|
-
}
|
|
2
|
+
import type { ComboboxOption } from "./option-list.js";
|
|
3
|
+
export type { ComboboxOption };
|
|
25
4
|
export interface ComboboxProps {
|
|
26
5
|
/** Full option list; the component filters it against the typed query. */
|
|
27
6
|
options: ComboboxOption[];
|
|
@@ -35,8 +14,60 @@ export interface ComboboxProps {
|
|
|
35
14
|
filter?: (option: ComboboxOption, query: string) => boolean;
|
|
36
15
|
placeholder?: string;
|
|
37
16
|
disabled?: boolean;
|
|
38
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Message shown when no option matches. Defaults to "No matches". Not shown
|
|
19
|
+
* while a create entry is offered — the popup is offering an action, so
|
|
20
|
+
* saying there is nothing to do contradicts the row underneath it.
|
|
21
|
+
*/
|
|
39
22
|
emptyMessage?: React.ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* Offer the typed query as a new option. When set, a trailing create entry
|
|
25
|
+
* joins the listbox whenever the trimmed query is non-empty and equals no
|
|
26
|
+
* option's label or value; choosing it calls this instead of
|
|
27
|
+
* `onValueChange`.
|
|
28
|
+
*
|
|
29
|
+
* The query arrives trimmed and otherwise raw. Normalization — casing,
|
|
30
|
+
* separators, a leading `#` — is the consumer's, because it is a product
|
|
31
|
+
* rule about what the created thing is called, not something this control
|
|
32
|
+
* can know.
|
|
33
|
+
*/
|
|
34
|
+
onCreate?: (query: string) => void;
|
|
35
|
+
/** Label for the create entry. Defaults to `Create "<query>"`. */
|
|
36
|
+
createLabel?: (query: string) => React.ReactNode;
|
|
37
|
+
/**
|
|
38
|
+
* Decide whether an existing option already covers the typed query, which is
|
|
39
|
+
* what suppresses the create entry. Defaults to case-insensitive equality
|
|
40
|
+
* against an option's `label` or `value`.
|
|
41
|
+
*
|
|
42
|
+
* The default is case-*insensitive* rather than exact because the offer is
|
|
43
|
+
* only useful if it agrees with what `onCreate` will do, and casing is the
|
|
44
|
+
* cheapest way for the two to disagree: with `urgent` in the list, typing
|
|
45
|
+
* `Urgent` under an exact comparison offers "Create Urgent", the consumer
|
|
46
|
+
* lowercases it on create, and the user has taken an action that either did
|
|
47
|
+
* nothing or made a duplicate.
|
|
48
|
+
*
|
|
49
|
+
* Casing is only the common half. A consumer whose normalization does more —
|
|
50
|
+
* hyphenating spaces, stripping a leading `#` — owns the rest, and this is
|
|
51
|
+
* where it says so, because the component cannot guess the rule it was
|
|
52
|
+
* deliberately not given in {@link ComboboxProps.onCreate}.
|
|
53
|
+
*/
|
|
54
|
+
isDuplicate?: (query: string, option: ComboboxOption) => boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Leave the popup open after a choice is committed, clearing the query for
|
|
57
|
+
* the next one instead of closing.
|
|
58
|
+
*
|
|
59
|
+
* For the caller whose control adds to a set rather than setting a value — a
|
|
60
|
+
* tag picker, an attendee list — where closing after each pick makes adding
|
|
61
|
+
* three things cost three round trips through opening the control. Such a
|
|
62
|
+
* caller typically pairs this with `value=""`, since a control that holds no
|
|
63
|
+
* selection has nothing to display once the choice has been applied
|
|
64
|
+
* elsewhere.
|
|
65
|
+
*
|
|
66
|
+
* Off by default: for an ordinary single-select, staying open after the one
|
|
67
|
+
* choice has been made is the wrong behaviour, and closing is what says the
|
|
68
|
+
* choice registered.
|
|
69
|
+
*/
|
|
70
|
+
keepOpenOnSelect?: boolean;
|
|
40
71
|
id?: string;
|
|
41
72
|
name?: string;
|
|
42
73
|
"aria-label"?: string;
|
|
@@ -50,5 +81,10 @@ export interface ComboboxProps {
|
|
|
50
81
|
* highlight with wrap-around and scroll-into-view, Enter commits the active
|
|
51
82
|
* option, Escape closes, and losing focus closes without committing a
|
|
52
83
|
* partial value. Selection submits via a hidden input when `name` is set.
|
|
84
|
+
*
|
|
85
|
+
* For the list where search *is* the interaction — too long to read, or one
|
|
86
|
+
* the consumer offers to add to. A short list that only wants a themed popup is
|
|
87
|
+
* `SelectMenu`'s case; the four single-choice controls and which case each
|
|
88
|
+
* answers are tabled once, in `docs/packages/saas-ui.md`.
|
|
53
89
|
*/
|
|
54
90
|
export declare const Combobox: React.ForwardRefExoticComponent<ComboboxProps & React.RefAttributes<HTMLInputElement>>;
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { ChevronDown } from "lucide-react";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronDown, Plus } from "lucide-react";
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import { cn } from "../utils/cn.js";
|
|
6
6
|
import { useFormFieldProps } from "./form-field.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
import { OptionListbox, OptionRow, defaultOptionFilter, nextActiveIndex, optionElementId, scrollOptionIntoView, } from "./option-list.js";
|
|
8
|
+
/**
|
|
9
|
+
* The create entry's stand-in `value`. It exists only so the entry can travel
|
|
10
|
+
* through the same `ComboboxOption` shape as every real option — sharing the
|
|
11
|
+
* index space, the `data-index` lookup, and the `aria-activedescendant` id —
|
|
12
|
+
* rather than needing a parallel keyboard path beside the list. `commit`
|
|
13
|
+
* routes on it and it never reaches `onValueChange`.
|
|
14
|
+
*/
|
|
15
|
+
const CREATE_ENTRY_VALUE = "__ssui_combobox_create__";
|
|
16
|
+
function defaultIsDuplicate(query, option) {
|
|
17
|
+
const normalized = query.toLowerCase();
|
|
18
|
+
return (option.value.toLowerCase() === normalized ||
|
|
19
|
+
option.label.toLowerCase() === normalized);
|
|
13
20
|
}
|
|
14
21
|
/**
|
|
15
22
|
* Filterable single-select following the WAI-ARIA combobox pattern: a text
|
|
@@ -17,25 +24,91 @@ function defaultFilter(option, query) {
|
|
|
17
24
|
* highlight with wrap-around and scroll-into-view, Enter commits the active
|
|
18
25
|
* option, Escape closes, and losing focus closes without committing a
|
|
19
26
|
* partial value. Selection submits via a hidden input when `name` is set.
|
|
27
|
+
*
|
|
28
|
+
* For the list where search *is* the interaction — too long to read, or one
|
|
29
|
+
* the consumer offers to add to. A short list that only wants a themed popup is
|
|
30
|
+
* `SelectMenu`'s case; the four single-choice controls and which case each
|
|
31
|
+
* answers are tabled once, in `docs/packages/saas-ui.md`.
|
|
20
32
|
*/
|
|
21
|
-
export const Combobox = React.forwardRef(({ options, value, onValueChange, filter =
|
|
33
|
+
export const Combobox = React.forwardRef(({ options, value, onValueChange, filter = defaultOptionFilter, placeholder, disabled, emptyMessage = "No matches", onCreate, createLabel, isDuplicate = defaultIsDuplicate, keepOpenOnSelect = false, className, name, ...ariaProps }, ref) => {
|
|
22
34
|
const fieldProps = useFormFieldProps(ariaProps);
|
|
23
35
|
const generatedId = React.useId();
|
|
24
36
|
const id = fieldProps.id ?? generatedId;
|
|
25
37
|
const listboxId = `${id}-listbox`;
|
|
38
|
+
const currentValueId = `${id}-current-value`;
|
|
26
39
|
const [open, setOpen] = React.useState(false);
|
|
27
40
|
const [query, setQuery] = React.useState(null);
|
|
28
41
|
const [activeIndex, setActiveIndex] = React.useState(-1);
|
|
29
42
|
const listRef = React.useRef(null);
|
|
30
43
|
const selectedOption = options.find((option) => option.value === value);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
// The predicate runs even with nothing typed, against an empty query. It used to be
|
|
45
|
+
// skipped entirely in that state, on the reasoning that "no query" means "show
|
|
46
|
+
// everything" — true of a predicate that only *searches*, and false of one that also
|
|
47
|
+
// *excludes*. A consumer hiding options it must not offer (an already-applied tag, a
|
|
48
|
+
// record already named) had its rule silently ignored for exactly as long as the field
|
|
49
|
+
// was empty, which is the state the popup opens in. `defaultFilter` returns true for
|
|
50
|
+
// an empty query, so nothing changes for a consumer that only searches.
|
|
51
|
+
const filtered = options.filter((option) => filter(option, query ?? ""));
|
|
52
|
+
// What the field shows: the live query while searching, the committed option's
|
|
53
|
+
// own text otherwise. `inputLabel` lets an option carry a fuller form for the
|
|
54
|
+
// collapsed field than the listbox row needs.
|
|
55
|
+
const committedLabel = selectedOption?.inputLabel ?? selectedOption?.label ?? "";
|
|
56
|
+
const inputValue = query ?? committedLabel;
|
|
57
|
+
// The committed value moves into the placeholder while searching, which is a
|
|
58
|
+
// presentation trick with two costs to pay off.
|
|
59
|
+
//
|
|
60
|
+
// Colour: a placeholder is styled `--ssui-text-subtle`, which is the pairing
|
|
61
|
+
// this release retired from `ChoiceCard`'s description for measuring 2.87:1.
|
|
62
|
+
// That is the right weight for a hint the user is meant to type over and the
|
|
63
|
+
// wrong one for the answer to "what is this set to right now?", so the value
|
|
64
|
+
// takes `--ssui-text-muted` and only a true placeholder stays subtle.
|
|
65
|
+
//
|
|
66
|
+
// Announcement: screen-reader handling of placeholder text is inconsistent
|
|
67
|
+
// and never guaranteed, so with the box cleared on focus a non-sighted user
|
|
68
|
+
// could lose the current value entirely. `aria-describedby` gives it a
|
|
69
|
+
// programmatic home that does not depend on how the placeholder is treated.
|
|
70
|
+
const showingValueAsPlaceholder = open && committedLabel !== "";
|
|
71
|
+
const describedBy = [fieldProps["aria-describedby"], showingValueAsPlaceholder ? currentValueId : null]
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(" ") || undefined;
|
|
74
|
+
// Offered only when there is something to create that is not already there:
|
|
75
|
+
// a "Create X" row sitting above an existing X is an offer to do nothing.
|
|
76
|
+
// The comparison is against the whole option list rather than the filtered
|
|
77
|
+
// one, because a match hidden by a consumer's own `filter` is still an
|
|
78
|
+
// existing option. `isDuplicate` decides what counts as a match, and its
|
|
79
|
+
// default is case-insensitive — see the prop's own note for why exact
|
|
80
|
+
// equality is the wrong default here.
|
|
81
|
+
const trimmedQuery = (query ?? "").trim();
|
|
82
|
+
const createEntry = onCreate !== undefined &&
|
|
83
|
+
trimmedQuery.length > 0 &&
|
|
84
|
+
!options.some((option) => isDuplicate(trimmedQuery, option))
|
|
85
|
+
? { value: CREATE_ENTRY_VALUE, label: trimmedQuery }
|
|
86
|
+
: null;
|
|
87
|
+
// The one list the keyboard model runs on. Appending the create entry here
|
|
88
|
+
// rather than rendering it as a footer is the whole of its integration:
|
|
89
|
+
// wrap-around, `activeIndex`, scroll-into-view and `aria-activedescendant`
|
|
90
|
+
// all keep working with no branch for it.
|
|
91
|
+
const navigable = createEntry ? [...filtered, createEntry] : filtered;
|
|
92
|
+
const activeOption = activeIndex >= 0 ? navigable[activeIndex] : undefined;
|
|
36
93
|
function openList() {
|
|
37
94
|
setOpen(true);
|
|
38
95
|
setActiveIndex(-1);
|
|
96
|
+
// Focusing starts a search, and a search starts empty.
|
|
97
|
+
//
|
|
98
|
+
// Leaving the committed label in the box made the field read as editable
|
|
99
|
+
// text holding the setting: a caret landed in the middle of
|
|
100
|
+
// "America/New_York", typing appended to it, and the result matched
|
|
101
|
+
// nothing. The user had to notice their own value was in the way and clear
|
|
102
|
+
// it before the control would do the one thing it exists to do. It also
|
|
103
|
+
// invited the reasonable belief that the text was the value and could be
|
|
104
|
+
// corrected in place, which it never was — an uncommitted query is
|
|
105
|
+
// discarded on close.
|
|
106
|
+
//
|
|
107
|
+
// Empty on focus keeps the two modes distinct: unfocused the field displays
|
|
108
|
+
// a value, focused it takes a query. The value is not lost while searching —
|
|
109
|
+
// it becomes the placeholder below — and it cannot be edited into something
|
|
110
|
+
// that was never a choice.
|
|
111
|
+
setQuery("");
|
|
39
112
|
}
|
|
40
113
|
function close() {
|
|
41
114
|
setOpen(false);
|
|
@@ -43,38 +116,28 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
|
|
|
43
116
|
setActiveIndex(-1);
|
|
44
117
|
}
|
|
45
118
|
function commit(option) {
|
|
46
|
-
|
|
119
|
+
if (option.value === CREATE_ENTRY_VALUE) {
|
|
120
|
+
onCreate?.(option.label);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
onValueChange?.(option.value);
|
|
124
|
+
}
|
|
125
|
+
if (keepOpenOnSelect) {
|
|
126
|
+
// Back to the state a freshly opened popup is in — empty query, nothing
|
|
127
|
+
// active — rather than closed. `setQuery("")` and not `setQuery(null)`:
|
|
128
|
+
// null means "show the committed value", and this control is mid-search.
|
|
129
|
+
setQuery("");
|
|
130
|
+
setActiveIndex(-1);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
47
133
|
close();
|
|
48
134
|
}
|
|
49
135
|
function moveActive(delta) {
|
|
50
|
-
if (
|
|
136
|
+
if (navigable.length === 0)
|
|
51
137
|
return;
|
|
52
|
-
const next = activeIndex
|
|
53
|
-
? delta === 1
|
|
54
|
-
? 0
|
|
55
|
-
: filtered.length - 1
|
|
56
|
-
: (activeIndex + delta + filtered.length) % filtered.length;
|
|
138
|
+
const next = nextActiveIndex(activeIndex, delta, navigable.length);
|
|
57
139
|
setActiveIndex(next);
|
|
58
|
-
|
|
59
|
-
// Arriving at the first option of a group brings that group's heading into
|
|
60
|
-
// view as well as the option, so a keyboard user entering a group can see
|
|
61
|
-
// which one they are in. Both, and in this order — scrolling only the
|
|
62
|
-
// option leaves the 16px heading clipped above the scrollport, and
|
|
63
|
-
// scrolling only the heading is worse, because entering a group from below
|
|
64
|
-
// aligns the heading's bottom edge with the scrollport's and leaves the
|
|
65
|
-
// newly highlighted option out of view entirely.
|
|
66
|
-
//
|
|
67
|
-
// Two `nearest` calls settle both directions. Downward: the first brings
|
|
68
|
-
// the heading to the bottom edge, the second scrolls one option further,
|
|
69
|
-
// leaving heading and option both visible. Upward: the first aligns the
|
|
70
|
-
// heading to the top and the second is a no-op, the option having come
|
|
71
|
-
// with it. Never losing the highlight is the constraint; showing the
|
|
72
|
-
// heading is the preference.
|
|
73
|
-
const previous = element?.previousElementSibling ?? null;
|
|
74
|
-
if (previous?.getAttribute("role") === "presentation") {
|
|
75
|
-
previous.scrollIntoView({ block: "nearest" });
|
|
76
|
-
}
|
|
77
|
-
element?.scrollIntoView({ block: "nearest" });
|
|
140
|
+
scrollOptionIntoView(listRef.current, next);
|
|
78
141
|
}
|
|
79
142
|
function handleKeyDown(event) {
|
|
80
143
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
@@ -108,45 +171,34 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
|
|
|
108
171
|
}
|
|
109
172
|
}
|
|
110
173
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
if (last?.kind === "group" && last.group === option.group) {
|
|
119
|
-
last.entries.push({ option, index });
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
rows.push({ kind: "group", group: option.group, entries: [{ option, index }] });
|
|
123
|
-
});
|
|
124
|
-
function renderOption({ option, index }) {
|
|
125
|
-
return (_jsxs("button", { "aria-selected": option.value === value, className: cn("flex w-full items-center justify-between gap-3 rounded-[var(--ssui-radius-sm)] px-2 py-1.5 text-left text-sm transition-colors", index === activeIndex && "bg-[color:var(--ssui-overlay-hover)]", option.value === value
|
|
126
|
-
? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
|
|
127
|
-
: "text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]"), "data-index": index, id: `${id}-option-${option.value}`, onClick: () => commit(option), onMouseDown: (event) => event.preventDefault(), onMouseMove: () => setActiveIndex(index), role: "option", tabIndex: -1, type: "button", children: [option.description !== undefined ? (_jsxs("span", { className: "flex min-w-0 items-baseline gap-1.5", children: [_jsx("span", { className: "truncate", children: option.label }), _jsx("span", { className: "truncate text-xs text-[color:var(--ssui-text-subtle)]", children: option.description })] })) : (_jsx("span", { className: "truncate", children: option.label })), option.trailing !== undefined ? (_jsx("span", { className: "shrink-0 font-mono text-xs text-[color:var(--ssui-text-subtle)]", children: option.trailing })) : null] }, option.value));
|
|
174
|
+
// The create entry is a row of its own kind — an action, not a value — but
|
|
175
|
+
// it is rendered through the same row primitive so wrap-around,
|
|
176
|
+
// `activeIndex`, scroll-into-view and `aria-activedescendant` all keep
|
|
177
|
+
// working with no branch for it.
|
|
178
|
+
function renderCreateEntry(option, index) {
|
|
179
|
+
return (_jsx(OptionRow, { active: index === activeIndex, idPrefix: id, index: index, onActivate: setActiveIndex, onCommit: commit, option: option, selected: false, children: _jsxs("span", { className: "flex min-w-0 items-center gap-3", children: [_jsx(Plus, { "aria-hidden": "true", className: "size-3.5 shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: createLabel ? (createLabel(option.label)) : (_jsxs(_Fragment, { children: ["Create", " ", _jsx("span", { className: "font-medium text-[color:var(--ssui-text)]", children: option.label })] })) })] }) }, option.value));
|
|
128
180
|
}
|
|
129
181
|
return (_jsxs("div", { className: cn("relative", className), onBlur: (event) => {
|
|
130
182
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
131
183
|
close();
|
|
132
184
|
}
|
|
133
|
-
}, children: [_jsx("input", { ...fieldProps, ref: ref, id: id, role: "combobox", type: "text", "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": open, "aria-haspopup": "listbox", "aria-activedescendant": open && activeOption ?
|
|
185
|
+
}, children: [_jsx("input", { ...fieldProps, ref: ref, id: id, role: "combobox", type: "text", "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": open, "aria-haspopup": "listbox", "aria-activedescendant": open && activeOption ? optionElementId(id, activeOption.value) : undefined, "aria-describedby": describedBy, autoComplete: "off", className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] py-2 pl-3 pr-9 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70",
|
|
186
|
+
// Content, not a hint. `cn` is tailwind-merge, so this replaces the
|
|
187
|
+
// subtle placeholder colour above rather than racing it.
|
|
188
|
+
showingValueAsPlaceholder &&
|
|
189
|
+
"placeholder:text-[color:var(--ssui-text-muted)]"), disabled: disabled, onChange: (event) => {
|
|
134
190
|
setQuery(event.target.value);
|
|
135
191
|
setOpen(true);
|
|
136
192
|
setActiveIndex(-1);
|
|
137
|
-
}, onFocus: openList, onKeyDown: handleKeyDown,
|
|
138
|
-
|
|
193
|
+
}, onFocus: openList, onKeyDown: handleKeyDown,
|
|
194
|
+
// While searching, the committed value takes the placeholder's place, so
|
|
195
|
+
// emptying the box to type never hides what is currently set.
|
|
196
|
+
placeholder: open && committedLabel ? committedLabel : placeholder, value: inputValue }), _jsx("button", { "aria-label": "Show options", className: "absolute right-1 top-1 grid size-8 place-items-center rounded-[var(--ssui-radius-sm)] text-[color:var(--ssui-text-subtle)] transition-colors hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text)] disabled:pointer-events-none disabled:opacity-50", disabled: disabled, onClick: () => {
|
|
139
197
|
openList();
|
|
140
198
|
document.getElementById(id)?.focus();
|
|
141
|
-
}, tabIndex: -1, type: "button", children: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) }), name ? _jsx("input", { name: name, type: "hidden", value: value ?? "" }) : null, open ? (_jsx(
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
// the listbox's content model, so a screen-reader user hears a
|
|
146
|
-
// flat list of options and never learns which kind each is.
|
|
147
|
-
// The heading takes `role="presentation"` so it stays out of
|
|
148
|
-
// that content model while `aria-labelledby` still names the
|
|
149
|
-
// group from its text.
|
|
150
|
-
_jsxs("div", { "aria-labelledby": `${id}-group-${rowIndex}`, role: "group", children: [_jsx("div", { className: cn("px-2 pb-0.5 pt-4 text-xs font-medium uppercase tracking-wide text-[color:var(--ssui-text-subtle)]", rowIndex === 0 && "pt-1"), id: `${id}-group-${rowIndex}`, role: "presentation", children: row.group }), row.entries.map(renderOption)] }, `group-${rowIndex}`)))) : (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) })) : null] }));
|
|
199
|
+
}, tabIndex: -1, type: "button", children: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) }), showingValueAsPlaceholder ? (_jsx("span", { className: "sr-only", id: currentValueId, children: `Current selection: ${committedLabel}` })) : null, name ? _jsx("input", { name: name, type: "hidden", value: value ?? "" }) : null, open ? (_jsx(OptionListbox, { activeIndex: activeIndex, className: "absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] p-1 shadow-[var(--ssui-shadow-md)]",
|
|
200
|
+
// The create entry is an action the reader can take, so saying there
|
|
201
|
+
// is nothing to do would contradict the row underneath it.
|
|
202
|
+
emptyMessage: createEntry ? null : emptyMessage, id: listboxId, idPrefix: id, onActivate: setActiveIndex, onCommit: commit, options: filtered, ref: listRef, value: value, children: createEntry ? renderCreateEntry(createEntry, filtered.length) : null })) : null] }));
|
|
151
203
|
});
|
|
152
204
|
Combobox.displayName = "Combobox";
|
|
@@ -24,11 +24,13 @@ export * from "./list-shell.js";
|
|
|
24
24
|
export * from "./login.js";
|
|
25
25
|
export * from "./overflow-menu.js";
|
|
26
26
|
export * from "./page-header.js";
|
|
27
|
+
export * from "./popover.js";
|
|
27
28
|
export * from "./rail-toggle.js";
|
|
28
29
|
export * from "./request-access.js";
|
|
29
30
|
export * from "./role-menu.js";
|
|
30
31
|
export * from "./scroll-area.js";
|
|
31
32
|
export * from "./search-input.js";
|
|
33
|
+
export * from "./select-menu.js";
|
|
32
34
|
export * from "./select.js";
|
|
33
35
|
export * from "./separator.js";
|
|
34
36
|
export * from "./settings-layout.js";
|
package/dist/components/index.js
CHANGED
|
@@ -24,11 +24,13 @@ export * from "./list-shell.js";
|
|
|
24
24
|
export * from "./login.js";
|
|
25
25
|
export * from "./overflow-menu.js";
|
|
26
26
|
export * from "./page-header.js";
|
|
27
|
+
export * from "./popover.js";
|
|
27
28
|
export * from "./rail-toggle.js";
|
|
28
29
|
export * from "./request-access.js";
|
|
29
30
|
export * from "./role-menu.js";
|
|
30
31
|
export * from "./scroll-area.js";
|
|
31
32
|
export * from "./search-input.js";
|
|
33
|
+
export * from "./select-menu.js";
|
|
32
34
|
export * from "./select.js";
|
|
33
35
|
export * from "./separator.js";
|
|
34
36
|
export * from "./settings-layout.js";
|
package/dist/components/input.js
CHANGED
|
@@ -2,5 +2,5 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { cn } from "../utils/cn.js";
|
|
4
4
|
import { useFormFieldProps } from "./form-field.js";
|
|
5
|
-
export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
|
|
5
|
+
export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
|
|
6
6
|
Input.displayName = "Input";
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* One option, in the shape both `Combobox` and `SelectMenu` take. Two controls
|
|
4
|
+
* presenting the same list should not need two option types, so the shape lives
|
|
5
|
+
* here and each component re-exports it under the name its consumers know.
|
|
6
|
+
*/
|
|
7
|
+
export interface ComboboxOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
/** Optional trailing content (e.g. a GMT offset) shown right-aligned. */
|
|
11
|
+
trailing?: React.ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* Optional secondary text shown next to the label, in the reading order a
|
|
14
|
+
* subtitle would take. Distinct from `trailing`, which is right-aligned and
|
|
15
|
+
* monospaced; an option may carry both.
|
|
16
|
+
*/
|
|
17
|
+
description?: React.ReactNode;
|
|
18
|
+
/**
|
|
19
|
+
* Text the closed control shows once this option is committed, when that
|
|
20
|
+
* should differ from `label`. Defaults to `label`.
|
|
21
|
+
*
|
|
22
|
+
* For an option whose `trailing` content is part of its identity rather than
|
|
23
|
+
* decoration — a time zone's GMT offset, a currency's symbol — the collapsed
|
|
24
|
+
* control would otherwise drop it: `trailing` is a `ReactNode` rendered into
|
|
25
|
+
* the listbox row, and an input can only display a string. This is that
|
|
26
|
+
* string. `SelectMenu`'s trigger shows it too, for the same reason; the name
|
|
27
|
+
* predates that component and stays because renaming it would break shipped
|
|
28
|
+
* consumers to fix a word.
|
|
29
|
+
*/
|
|
30
|
+
inputLabel?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Optional heading this option sits under. Consecutive options declaring the
|
|
33
|
+
* same group collapse into one heading, exactly as `<optgroup>` does — the
|
|
34
|
+
* component never reorders, so interleaved groups (`A, B, A`) render three
|
|
35
|
+
* headings, not two. Sort the list before passing it if that is not wanted.
|
|
36
|
+
*
|
|
37
|
+
* Grouped and ungrouped options may be mixed: an option declaring no group
|
|
38
|
+
* ends the run above it and renders outside every group, the way an
|
|
39
|
+
* `<option>` following an `</optgroup>` sits outside that group.
|
|
40
|
+
*/
|
|
41
|
+
group?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Case-insensitive substring match on the option's value, label and
|
|
45
|
+
* `inputLabel`, with spaces treated as underscores so `new york` finds
|
|
46
|
+
* `America/New_York`. Returns true for an empty query.
|
|
47
|
+
*/
|
|
48
|
+
export declare function defaultOptionFilter(option: ComboboxOption, query: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The DOM id of an option row, which `aria-activedescendant` points at.
|
|
51
|
+
*
|
|
52
|
+
* The value is a consumer's data — a user-defined field's option is whatever a
|
|
53
|
+
* person typed — and an id may not contain whitespace, while the single-IDREF
|
|
54
|
+
* attribute pointing at it has to resolve in every browser and screen reader.
|
|
55
|
+
* So the value is encoded into `[A-Za-z0-9-]`: every other character, `_`
|
|
56
|
+
* included so the encoding stays one-to-one, becomes `_<code point in hex>_`.
|
|
57
|
+
* Distinct values give distinct ids; a value that already fits passes through
|
|
58
|
+
* unchanged.
|
|
59
|
+
*/
|
|
60
|
+
export declare function optionElementId(prefix: string, value: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* The next highlight position after one arrow press: from nothing highlighted,
|
|
63
|
+
* down lands on the first entry and up on the last; otherwise the highlight
|
|
64
|
+
* wraps at both ends. `-1` when there is nothing to highlight.
|
|
65
|
+
*/
|
|
66
|
+
export declare function nextActiveIndex(current: number, delta: 1 | -1, length: number): number;
|
|
67
|
+
/**
|
|
68
|
+
* Bring the highlighted row into view, and its group heading with it.
|
|
69
|
+
*
|
|
70
|
+
* Arriving at the first option of a group brings that group's heading into
|
|
71
|
+
* view as well as the option, so a keyboard user entering a group can see which
|
|
72
|
+
* one they are in. Both, and in this order — scrolling only the option leaves
|
|
73
|
+
* the 16px heading clipped above the scrollport, and scrolling only the heading
|
|
74
|
+
* is worse, because entering a group from below aligns the heading's bottom
|
|
75
|
+
* edge with the scrollport's and leaves the newly highlighted option out of
|
|
76
|
+
* view entirely.
|
|
77
|
+
*
|
|
78
|
+
* Two `nearest` calls settle both directions. Downward: the first brings the
|
|
79
|
+
* heading to the bottom edge, the second scrolls one option further, leaving
|
|
80
|
+
* heading and option both visible. Upward: the first aligns the heading to the
|
|
81
|
+
* top and the second is a no-op, the option having come with it. Never losing
|
|
82
|
+
* the highlight is the constraint; showing the heading is the preference.
|
|
83
|
+
*/
|
|
84
|
+
export declare function scrollOptionIntoView(list: HTMLElement | null, index: number): void;
|
|
85
|
+
export interface OptionRowProps {
|
|
86
|
+
option: ComboboxOption;
|
|
87
|
+
/** Position in the navigable list; what `data-index` and the highlight key on. */
|
|
88
|
+
index: number;
|
|
89
|
+
/** Highlighted by keyboard or pointer. */
|
|
90
|
+
active: boolean;
|
|
91
|
+
/** The committed value, marked to assistive technology as well as visually. */
|
|
92
|
+
selected: boolean;
|
|
93
|
+
/** Prefix for the row's DOM id — the owning control's id. */
|
|
94
|
+
idPrefix: string;
|
|
95
|
+
onCommit: (option: ComboboxOption) => void;
|
|
96
|
+
onActivate: (index: number) => void;
|
|
97
|
+
/**
|
|
98
|
+
* Replaces the row's label and description, for a row that is an action
|
|
99
|
+
* rather than an option — `Combobox`'s create entry. The option's `trailing`
|
|
100
|
+
* content and the selected mark are the row's own and still render after it,
|
|
101
|
+
* so a custom row carrying either is marked like any other. Pass a row that
|
|
102
|
+
* needs neither, or leave those fields unset on its option.
|
|
103
|
+
*/
|
|
104
|
+
children?: React.ReactNode;
|
|
105
|
+
}
|
|
106
|
+
/** One `role="option"` row. Pointer moves highlight it; clicks commit it. */
|
|
107
|
+
export declare function OptionRow({ option, index, active, selected, idPrefix, onCommit, onActivate, children, }: OptionRowProps): import("react/jsx-runtime").JSX.Element;
|
|
108
|
+
export interface OptionListboxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "id" | "role"> {
|
|
109
|
+
/** The listbox's own DOM id, which the controlling element's `aria-controls` names. */
|
|
110
|
+
id: string;
|
|
111
|
+
/** Prefix for option and heading ids — the owning control's id. */
|
|
112
|
+
idPrefix: string;
|
|
113
|
+
/** The options to present, already filtered. Rendered in the order given. */
|
|
114
|
+
options: ComboboxOption[];
|
|
115
|
+
/** The committed value. */
|
|
116
|
+
value?: string;
|
|
117
|
+
/** Index into `options` of the highlighted row, or `-1`. */
|
|
118
|
+
activeIndex: number;
|
|
119
|
+
onActivate: (index: number) => void;
|
|
120
|
+
onCommit: (option: ComboboxOption) => void;
|
|
121
|
+
/**
|
|
122
|
+
* Shown when `options` is empty. Pass `null` to show nothing — for a caller
|
|
123
|
+
* that is rendering an action row of its own into `children` and so has not
|
|
124
|
+
* left the reader with nothing to do.
|
|
125
|
+
*/
|
|
126
|
+
emptyMessage?: React.ReactNode;
|
|
127
|
+
/** Rows rendered after the options, inside the listbox — `Combobox`'s create entry. */
|
|
128
|
+
children?: React.ReactNode;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The popup list both `Combobox` and `SelectMenu` render: options walked into
|
|
132
|
+
* grouped runs under headings, each row a `role="option"` button, the highlight
|
|
133
|
+
* driven from outside by `activeIndex`. It owns no positioning and no keyboard
|
|
134
|
+
* handling — the owner decides which element holds focus and where the list
|
|
135
|
+
* sits — so the surrounding chrome is the owner's `className`.
|
|
136
|
+
*
|
|
137
|
+
* Internal to the package. Not exported from the barrel, so it is not an API
|
|
138
|
+
* this package has to keep.
|
|
139
|
+
*/
|
|
140
|
+
export declare const OptionListbox: React.ForwardRefExoticComponent<OptionListboxProps & React.RefAttributes<HTMLDivElement>>;
|