@forwardreach/saas-ui 0.11.0 → 0.13.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.
@@ -0,0 +1,219 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { ChevronDown } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../utils/cn.js";
6
+ import { useFormFieldProps } from "./form-field.js";
7
+ import { OptionListbox, defaultOptionFilter, nextActiveIndex, optionElementId, scrollOptionIntoView, } from "./option-list.js";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
9
+ /**
10
+ * The option count at which `SelectMenu` shows a filter when the consumer has
11
+ * not said either way. Below it the whole list is on screen at once, and a
12
+ * filter is a row to skip past on the way to an answer already visible; above
13
+ * it the reader is scanning. Exported so a consumer can compare against it
14
+ * rather than duplicate the number.
15
+ */
16
+ export const SELECT_MENU_SEARCH_THRESHOLD = 8;
17
+ // Height, text and padding are held together per size rather than split across
18
+ // rules, matching `Select`, so the two controls line up when swapped.
19
+ const CONTROL_SIZE_CLASSES = {
20
+ md: "h-10 py-2 pl-3 pr-3 text-sm",
21
+ sm: "h-8 py-1 pl-2.5 pr-2.5 text-xs",
22
+ };
23
+ /**
24
+ * Single choice from a list, in a popup this package draws.
25
+ *
26
+ * The fourth answer to "choose one" here, and the one to reach for when the
27
+ * popup's appearance is the point: `Select` is the native control, whose popup
28
+ * belongs to the platform and cannot be themed past its background colour;
29
+ * `Combobox` is a search field for lists too long to read; `RoleMenu` is for
30
+ * two or three options that each need a sentence. `SelectMenu` is the `Select`
31
+ * slot drawn as a menu — a trigger that can read as bounded field or as plain
32
+ * text, an option list with headings, and a filter once the list is long
33
+ * enough to want one. The table in `docs/packages/saas-ui.md` says which of the
34
+ * four answers which case.
35
+ *
36
+ * Built on `Popover` rather than `DropdownMenu` because a menu's roving focus
37
+ * and typeahead both fight a filter field. Inside the popup the list runs the
38
+ * listbox pattern with `aria-activedescendant`: focus stays in the filter field
39
+ * when there is one, or on the listbox itself when there is not, and the
40
+ * highlight moves independently — the model `Combobox` already implements, and
41
+ * the same option list rendering, extracted so the two cannot drift.
42
+ *
43
+ * What it gives up, stated plainly: on a phone a native `<select>` opens the
44
+ * platform's own picker, and this cannot. `Select` stays for the bounded form
45
+ * field where that is worth more than a themed popup.
46
+ */
47
+ export function SelectMenu({ options, value, onValueChange, variant = "control", size = "md", searchable, searchPlaceholder = "Search", emptyMessage, emptyLabel, placeholder, name, disabled, align = "start", open: openProp, defaultOpen = false, onOpenChange, className, ...ariaProps }) {
48
+ // An enclosing `FormField` owns the trigger's id and error wiring, as it does
49
+ // for `Input`, `Combobox` and `RoleMenu`.
50
+ const { "aria-label": ariaLabel, ...fieldProps } = useFormFieldProps(ariaProps);
51
+ const generatedId = React.useId();
52
+ const id = fieldProps.id ?? generatedId;
53
+ const listboxId = `${id}-listbox`;
54
+ const filterId = `${id}-filter`;
55
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
56
+ const open = openProp ?? uncontrolledOpen;
57
+ const [query, setQuery] = React.useState("");
58
+ const [activeIndex, setActiveIndex] = React.useState(-1);
59
+ const filterRef = React.useRef(null);
60
+ const listRef = React.useRef(null);
61
+ const triggerRef = React.useRef(null);
62
+ // The empty choice is a real option at the head of the list — it shares the
63
+ // index space, the highlight and `aria-activedescendant` — so no second
64
+ // keyboard path exists for it. `""` is what it reports. An empty `value` is
65
+ // "nothing chosen" everywhere below rather than "the empty row is chosen":
66
+ // the trigger shows its placeholder, no row is marked current, and the list
67
+ // opens with nothing highlighted.
68
+ const chosen = value === "" ? undefined : value;
69
+ const selected = options.find((option) => option.value === chosen);
70
+ const selectedLabel = selected?.inputLabel ?? selected?.label;
71
+ const offered = emptyLabel !== undefined ? [{ value: "", label: emptyLabel }, ...options] : [...options];
72
+ const showFilter = searchable ?? options.length >= SELECT_MENU_SEARCH_THRESHOLD;
73
+ // A freshly opened list starts with nothing typed and the current value
74
+ // highlighted, so Enter with no other key confirms what is already set and an
75
+ // arrow moves from it rather than from the top. The reset keys on the open
76
+ // state itself rather than on the trigger, because the trigger is only one
77
+ // way in: `defaultOpen` and a consumer flipping `open` from its own gesture —
78
+ // the click-to-edit row this component exists for — never touch it. Done
79
+ // during render, in the pattern React documents for reacting to a prop
80
+ // change, so the popup's first frame is already reset rather than one frame
81
+ // stale.
82
+ const [wasOpen, setWasOpen] = React.useState(false);
83
+ if (open !== wasOpen) {
84
+ setWasOpen(open);
85
+ if (open) {
86
+ setQuery("");
87
+ setActiveIndex(chosen === undefined ? -1 : offered.findIndex((option) => option.value === chosen));
88
+ }
89
+ }
90
+ const navigable = showFilter ? offered.filter((option) => defaultOptionFilter(option, query)) : offered;
91
+ const activeOption = activeIndex >= 0 ? navigable[activeIndex] : undefined;
92
+ function setOpen(next) {
93
+ if (next === open)
94
+ return;
95
+ if (openProp === undefined)
96
+ setUncontrolledOpen(next);
97
+ onOpenChange?.(next);
98
+ }
99
+ function commit(option) {
100
+ if (option.value !== (value ?? ""))
101
+ void onValueChange?.(option.value);
102
+ setOpen(false);
103
+ }
104
+ function moveActive(delta) {
105
+ if (navigable.length === 0)
106
+ return;
107
+ const next = nextActiveIndex(activeIndex, delta, navigable.length);
108
+ setActiveIndex(next);
109
+ scrollOptionIntoView(listRef.current, next);
110
+ }
111
+ function handleKeyDown(event) {
112
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
113
+ event.preventDefault();
114
+ moveActive(event.key === "ArrowDown" ? 1 : -1);
115
+ return;
116
+ }
117
+ if (event.key === "Home" || event.key === "End") {
118
+ if (navigable.length === 0)
119
+ return;
120
+ event.preventDefault();
121
+ const next = event.key === "Home" ? 0 : navigable.length - 1;
122
+ setActiveIndex(next);
123
+ scrollOptionIntoView(listRef.current, next);
124
+ return;
125
+ }
126
+ if (event.key === "Enter") {
127
+ // The open popup owns Enter whether or not anything is highlighted, so a
128
+ // stray press cannot reach the form behind the control.
129
+ event.preventDefault();
130
+ if (activeOption)
131
+ commit(activeOption);
132
+ }
133
+ // Escape is left to the popover, which closes on it and returns focus to
134
+ // the trigger; stopping it here would keep the popup open.
135
+ }
136
+ function handleTriggerKeyDown(event) {
137
+ // A native `<select>` opens on either arrow, and so does `RoleMenu`; a user
138
+ // swapping `Select` for this keeps the key. Space and Enter are the button's
139
+ // own, and the open state's reset above does the rest.
140
+ if (open || (event.key !== "ArrowDown" && event.key !== "ArrowUp"))
141
+ return;
142
+ event.preventDefault();
143
+ setOpen(true);
144
+ }
145
+ // The name carries purpose and current value, as `RoleMenu`'s does. A
146
+ // `FormField`'s visible label does that job better, so a consumer inside one
147
+ // passes no `aria-label`; one that does gets what it asked for, as it would
148
+ // from `RoleMenu`. With neither, the button's own text — the current value —
149
+ // is its name.
150
+ const currentText = selectedLabel ?? (typeof placeholder === "string" ? placeholder : "none");
151
+ const triggerLabel = ariaLabel ? `${ariaLabel} (current: ${currentText})` : undefined;
152
+ const activeDescendant = open && activeOption ? optionElementId(id, activeOption.value) : undefined;
153
+ return (_jsxs(_Fragment, { children: [name ? _jsx("input", { disabled: disabled, name: name, type: "hidden", value: value ?? "" }) : null, _jsxs(Popover, { onOpenChange: setOpen, open: open, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { ...fieldProps, "aria-controls": open ? listboxId : undefined, "aria-haspopup": "listbox", "aria-label": triggerLabel, className: cn("group/select-menu inline-flex items-center gap-1.5 text-left transition-colors focus-visible:outline-none", variant === "control"
154
+ ? cn(
155
+ // `Select`'s field, so the two line up when swapped: same
156
+ // radius, border, shadow, sizes, and ring with an offset.
157
+ "w-full justify-between rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] text-[color:var(--ssui-text)] shadow-sm 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:pointer-events-none disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", CONTROL_SIZE_CLASSES[size])
158
+ : // No box at rest: the row's own text on the row's own background,
159
+ // and only as wide as that text — `w-fit`, because a grid or flex
160
+ // cell would otherwise stretch it, and a hover surface the width
161
+ // of the row reads as a bar rather than as the value lighting up.
162
+ // The hover surface and the chevron arrive on hover, focus and
163
+ // while open, and the focus ring carries no offset — an inline
164
+ // trigger sits in a line with a couple of pixels to give, and a
165
+ // ring pushed outside it lands on the lines above and below
166
+ // rather than on this one. The ring is 2px, so with no offset
167
+ // it fits exactly the gutter a dense list keeps. Disabled, the
168
+ // pointer gets nothing: hover is the whole signal this variant
169
+ // uses to say "operable", so it must not light up and grow a
170
+ // chevron for a trigger that will then do nothing.
171
+ "-mx-1 w-fit max-w-full rounded-[var(--ssui-radius-sm)] px-1 text-[color:var(--ssui-text)] hover:bg-[color:var(--ssui-overlay-hover)] focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] data-[state=open]:bg-[color:var(--ssui-overlay-hover)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70", className), disabled: disabled, onKeyDown: handleTriggerKeyDown, ref: triggerRef, type: "button", children: [_jsx("span", { className: cn("min-w-0 truncate", selectedLabel === undefined && "text-[color:var(--ssui-text-subtle)]"), children: selectedLabel ?? placeholder }), _jsx(ChevronDown, { "aria-hidden": "true", className: cn("shrink-0 text-[color:var(--ssui-text-subtle)]", variant === "control"
172
+ ? size === "sm"
173
+ ? "size-3.5"
174
+ : "size-4"
175
+ : "size-3.5 opacity-0 transition-opacity group-hover/select-menu:opacity-100 group-focus-visible/select-menu:opacity-100 group-data-[state=open]/select-menu:opacity-100") })] }) }), _jsxs(PopoverContent, { align: align,
176
+ // The panel's own padding plus a row's puts an option's text 12px
177
+ // inside the panel, while an inline trigger's text sits 4px inside the
178
+ // box the panel aligns to. Pulling the panel back by the difference
179
+ // lands the options directly under the value they replace, so the
180
+ // popup reads as that value opening rather than as a panel arriving
181
+ // beside it. A bounded trigger's own padding already matches, so it
182
+ // needs no correction.
183
+ alignOffset: variant === "inline" ? -8 : 0, className: cn("p-1",
184
+ // A bounded trigger's popup starts at the field's own width, so the two
185
+ // read as one control rather than as a panel that happens to be near it,
186
+ // and grows only if an option needs more than the field has. A fixed
187
+ // width would truncate those options; a fixed minimum — which this used
188
+ // to carry for both variants — made every narrow field's popup overhang
189
+ // it, which is what the field-type control showed. An inline trigger is
190
+ // as wide as its text, which is no width for a list, so it keeps a floor
191
+ // of its own.
192
+ variant === "control" ? "min-w-[var(--radix-popover-trigger-width)]" : "min-w-56"), onCloseAutoFocus: (event) => {
193
+ // Focus returns to the trigger on close — unless the trigger has
194
+ // left the document with the popup, as it does when a consumer
195
+ // unmounts the control on a choice or a cancel. Returning focus to
196
+ // a detached element is a no-op that still pre-empts wherever the
197
+ // consumer then puts it, so the return is skipped and left to them.
198
+ if (!triggerRef.current?.isConnected)
199
+ event.preventDefault();
200
+ }, onOpenAutoFocus: (event) => {
201
+ // Focus goes to whichever element runs the keyboard model — the
202
+ // filter field, or the listbox itself when there is none — not to
203
+ // the first tabbable thing Radix would pick.
204
+ event.preventDefault();
205
+ (filterRef.current ?? listRef.current)?.focus();
206
+ },
207
+ // The panel is a listbox popup, not a dialog: the element that holds
208
+ // focus inside it announces itself, and a "dialog" wrapper around a
209
+ // select's options would be one more thing to hear on the way to them.
210
+ role: "presentation", children: [showFilter ? (_jsx("input", { "aria-activedescendant": activeDescendant, "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": "true", "aria-label": searchPlaceholder, autoComplete: "off", className: "mb-1 flex h-8 w-full rounded-[var(--ssui-radius-sm)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] px-2 text-sm text-[color:var(--ssui-text)] placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)]", id: filterId, onChange: (event) => {
211
+ setQuery(event.target.value);
212
+ setActiveIndex(-1);
213
+ }, onKeyDown: handleKeyDown, placeholder: searchPlaceholder, ref: filterRef, role: "combobox", type: "text", value: query })) : null, _jsx(OptionListbox, { activeIndex: activeIndex, "aria-activedescendant": showFilter ? undefined : activeDescendant, "aria-label": showFilter ? undefined : ariaLabel, className: "max-h-64 overflow-y-auto outline-none",
214
+ // `undefined` is "the consumer said nothing"; `null` is "show
215
+ // nothing", which the list documents and `Combobox` honours. `??`
216
+ // would swallow the second, so the test is on `undefined` alone.
217
+ emptyMessage: emptyMessage !== undefined ? emptyMessage : showFilter ? "No matches" : "No options", id: listboxId, idPrefix: id, onActivate: setActiveIndex, onCommit: commit, onKeyDown: showFilter ? undefined : handleKeyDown, options: navigable, ref: listRef, tabIndex: showFilter ? undefined : -1, value: chosen })] })] })] }));
218
+ }
219
+ SelectMenu.displayName = "SelectMenu";
@@ -12,5 +12,11 @@ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectE
12
12
  * Styled native `<select>` matching the shared `Input` look, with a trailing
13
13
  * chevron. Native on purpose: correct mobile behavior and form participation
14
14
  * with zero positioning code. Participates in `FormField` wiring.
15
+ *
16
+ * The default for a bounded field in an ordinary form. Its popup belongs to the
17
+ * platform and cannot be themed past its background colour; where that popup is
18
+ * the whole of what the reader sees — a choice presented as inline text — reach
19
+ * for `SelectMenu` instead. The four single-choice controls and which case each
20
+ * answers are tabled once, in `docs/packages/saas-ui.md`.
15
21
  */
16
22
  export declare const Select: React.ForwardRefExoticComponent<SelectProps & React.RefAttributes<HTMLSelectElement>>;
@@ -14,6 +14,12 @@ const SIZE_CLASSES = {
14
14
  * Styled native `<select>` matching the shared `Input` look, with a trailing
15
15
  * chevron. Native on purpose: correct mobile behavior and form participation
16
16
  * with zero positioning code. Participates in `FormField` wiring.
17
+ *
18
+ * The default for a bounded field in an ordinary form. Its popup belongs to the
19
+ * platform and cannot be themed past its background colour; where that popup is
20
+ * the whole of what the reader sees — a choice presented as inline text — reach
21
+ * for `SelectMenu` instead. The four single-choice controls and which case each
22
+ * answers are tabled once, in `docs/packages/saas-ui.md`.
17
23
  */
18
24
  export const Select = React.forwardRef(({ className, children, size = "md", ...props }, ref) => (_jsxs("div", { className: cn("relative", className), children: [_jsx("select", { ref: ref, className: cn("flex w-full cursor-pointer appearance-none rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] text-[color:var(--ssui-text)] shadow-sm transition-colors 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", SIZE_CLASSES[size].select), ...useFormFieldProps(props), children: children }), _jsx(ChevronDown, { "aria-hidden": "true", className: cn("pointer-events-none absolute top-1/2 -translate-y-1/2 text-[color:var(--ssui-text-subtle)]", SIZE_CLASSES[size].chevron) })] })));
19
25
  Select.displayName = "Select";
@@ -63,6 +63,20 @@
63
63
  --ssui-avatar-4-text: #9d174d;
64
64
  --ssui-avatar-5-bg: #f0f9ff;
65
65
  --ssui-avatar-5-text: #075985;
66
+ /* Pill tones: a reference (a link to a record), a tag, an attribute (a field), a date, and
67
+ a category (a grouping tag). Each defaults to a DERIVATION of the base contract rather
68
+ than a literal, so a consumer that has mapped the status and accent ramps sees a
69
+ coherent pair in both of its themes before it maps a single pill token. */
70
+ --ssui-pill-reference-bg: var(--ssui-accent-subtle);
71
+ --ssui-pill-reference-text: var(--ssui-accent-subtle-foreground);
72
+ --ssui-pill-tag-bg: var(--ssui-status-info-bg);
73
+ --ssui-pill-tag-text: var(--ssui-status-info-text);
74
+ --ssui-pill-attribute-bg: var(--ssui-status-neutral-bg);
75
+ --ssui-pill-attribute-text: var(--ssui-status-neutral-text);
76
+ --ssui-pill-date-bg: var(--ssui-status-warning-bg);
77
+ --ssui-pill-date-text: var(--ssui-status-warning-text);
78
+ --ssui-pill-category-bg: var(--ssui-status-success-bg);
79
+ --ssui-pill-category-text: var(--ssui-status-success-text);
66
80
  }
67
81
 
68
82
  @media (prefers-reduced-motion: reduce) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardreach/saas-ui",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Brand-neutral React UI primitives and SaaS app patterns for ForwardReach-owned business applications.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -51,6 +51,7 @@
51
51
  "@radix-ui/react-avatar": "^1.1.10",
52
52
  "@radix-ui/react-dialog": "^1.1.15",
53
53
  "@radix-ui/react-dropdown-menu": "^2.1.16",
54
+ "@radix-ui/react-popover": "^1.1.23",
54
55
  "@radix-ui/react-scroll-area": "^1.2.10",
55
56
  "@radix-ui/react-separator": "^1.1.7",
56
57
  "@radix-ui/react-slot": "^1.2.4",
@@ -72,7 +73,7 @@
72
73
  "typescript": "^5.8.3",
73
74
  "vitest": "^3.2.4"
74
75
  },
75
- "gitHead": "544931dd34ff3f738a58cf9e7ad161fb8fa8763f",
76
+ "gitHead": "519fa3eab663bd95bba8a6dae8ac056538d25431",
76
77
  "scripts": {
77
78
  "build": "pnpm clean && tsc -p tsconfig.build.json && node scripts/copy-styles.mjs",
78
79
  "dev": "node scripts/copy-styles.mjs && tsc -p tsconfig.build.json --watch",