@forwardreach/saas-ui 0.8.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.2
4
+
5
+ ### Patch Changes
6
+
7
+ - `Combobox` keyboard scrolling brings a group's heading into view *alongside*
8
+ its first option rather than instead of it. `0.10.1` scrolled the heading and
9
+ not the option, which fixed entering a group from above and broke entering
10
+ one from below: `scrollIntoView({block: "nearest"})` on a heading below the
11
+ fold aligns its bottom edge with the scrollport's, leaving the newly
12
+ highlighted option out of view. Two `nearest` calls — heading first, then the
13
+ option — settle both directions, and losing the highlight is now impossible
14
+ in either.
15
+
16
+ Packed under a new number rather than re-packed under `0.10.1`, which is the
17
+ practice the `0.10.0` double-pack cost us.
18
+
19
+ ## 0.10.1
20
+
21
+ ### Patch Changes
22
+
23
+ - `Combobox` grouping now reaches assistive technology. Each run of options
24
+ sharing a `group` is wrapped in a `role="group"` labelled by its heading (the
25
+ APG grouped-listbox shape), instead of the heading being a roleless sibling
26
+ of the options. A roleless element is not in the content model of
27
+ `role="listbox"`, so the previous markup conveyed the grouping to sighted
28
+ users only — a regression against the native `<select>` with `<optgroup
29
+ label>` that consumers replace with this. The options array stays flat and
30
+ every index the keyboard model uses is unchanged; only the DOM the rows are
31
+ emitted into differs.
32
+
33
+ A consequence, and the behavior the `group` doc comment already promised: an
34
+ option declaring no `group` after a grouped run now stands outside that
35
+ group rather than rendering under its heading, the way an `<option>` after an
36
+ `</optgroup>` does.
37
+
38
+ - **Behavior change.** `Combobox` now consumes Enter whenever its popup is
39
+ open, committing the active option where there is one and swallowing the key
40
+ where there is none. Enter previously fell through to the surrounding form
41
+ when nothing was highlighted, which was harmless only while every consumer
42
+ had a submit button to absorb it — and `0.10.0` exists to let a consumer drop
43
+ theirs. A form whose only field is a `Combobox` is implicitly submitted by
44
+ Enter under HTML's rules, so an uncommitted query submitted an empty value. A
45
+ consumer that relied on Enter in a closed popup reaching its form is
46
+ unaffected; one that relied on it with the popup open must now press Escape
47
+ first.
48
+
49
+ - Arrowing to the first option of a group scrolls that group's heading into
50
+ view rather than the option, so the heading is not left clipped above the
51
+ scrollport at the moment it is most needed.
52
+
53
+ - `0.10.0` was packed twice under one number: the group heading's padding was
54
+ retuned after the tarball had already been handed to a consumer, so two
55
+ distinct `0.10.0` tarballs existed and the one that was verified is gone.
56
+ This release supersedes both. See `future.md` for the packing-provenance
57
+ remedy.
58
+
59
+ ## 0.10.0
60
+
61
+ ### Minor Changes
62
+
63
+ - `Combobox` options may declare a `group` and a `description`, both optional
64
+ and both additive — every existing call site renders exactly as before, down
65
+ to the markup.
66
+
67
+ - `group` puts a run of consecutive options under a heading. The options prop
68
+ stays one flat ordered list, which is what keeps the keyboard model intact:
69
+ filtering, the active-option index, wrap-around, and scroll-into-view all
70
+ still work on that single list, and headings are siblings inside the
71
+ listbox rather than wrappers around the options. A heading is presentation,
72
+ not a choice — it carries no `role="option"`, arrow keys pass over it, and
73
+ a heading whose options are all filtered away is not rendered. The
74
+ component does not reorder, so interleaved groups repeat their heading, as
75
+ `<optgroup>` does; sort before passing if that is not wanted. A heading is
76
+ spaced asymmetrically — much more room above it than below — so that each
77
+ run reads as one block instead of the heading floating equidistant between
78
+ the group above and the group it names.
79
+ - `description` is secondary text beside the label, for the kind of
80
+ qualifier that has to read next to a name — a record's type, say. It is
81
+ distinct from `trailing`, which is right-aligned and monospaced because it
82
+ was built for GMT offsets, and an option may carry both.
83
+
84
+ Between `0.8.0` and this release, a `0.9.1` tarball circulated to a consumer
85
+ without a release note or a published version behind it. This version is
86
+ numbered past it so no consumer moves backwards.
87
+
3
88
  ## 0.8.0
4
89
 
5
90
  ### Minor Changes
@@ -4,6 +4,23 @@ export interface ComboboxOption {
4
4
  label: string;
5
5
  /** Optional trailing content (e.g. a GMT offset) shown right-aligned. */
6
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;
7
24
  }
8
25
  export interface ComboboxProps {
9
26
  /** Full option list; the component filters it against the typed query. */
@@ -55,9 +55,26 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
55
55
  : filtered.length - 1
56
56
  : (activeIndex + delta + filtered.length) % filtered.length;
57
57
  setActiveIndex(next);
58
- listRef.current
59
- ?.querySelector(`[data-index="${next}"]`)
60
- ?.scrollIntoView({ block: "nearest" });
58
+ const element = listRef.current?.querySelector(`[data-index="${next}"]`);
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" });
61
78
  }
62
79
  function handleKeyDown(event) {
63
80
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
@@ -70,10 +87,18 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
70
87
  return;
71
88
  }
72
89
  if (event.key === "Enter") {
73
- if (open && activeOption) {
74
- event.preventDefault();
90
+ // An open popup owns Enter, whether or not anything is highlighted.
91
+ // Committing when there is an active option is the obvious half; the
92
+ // other half is swallowing the key when there is not, so that Enter on
93
+ // an uncommitted query cannot reach the form behind the control. A
94
+ // consumer whose only field is this combobox has no submit button to
95
+ // absorb it, and HTML would otherwise implicitly submit the form with
96
+ // whatever the hidden input last held.
97
+ if (!open)
98
+ return;
99
+ event.preventDefault();
100
+ if (activeOption)
75
101
  commit(activeOption);
76
- }
77
102
  return;
78
103
  }
79
104
  if (event.key === "Escape") {
@@ -83,6 +108,24 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
83
108
  }
84
109
  }
85
110
  }
111
+ const rows = [];
112
+ filtered.forEach((option, index) => {
113
+ const last = rows[rows.length - 1];
114
+ if (option.group === undefined) {
115
+ rows.push({ kind: "option", entry: { option, index } });
116
+ return;
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));
128
+ }
86
129
  return (_jsxs("div", { className: cn("relative", className), onBlur: (event) => {
87
130
  if (!event.currentTarget.contains(event.relatedTarget)) {
88
131
  close();
@@ -95,9 +138,15 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
95
138
  setQuery(null);
96
139
  openList();
97
140
  document.getElementById(id)?.focus();
98
- }, 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("div", { 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)]", id: listboxId, ref: listRef, role: "listbox", children: filtered.length > 0 ? (filtered.map((option, index) => (_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 &&
99
- "bg-[color:var(--ssui-overlay-hover)]", option.value === value
100
- ? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
101
- : "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: [_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)))) : (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) })) : null] }));
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("div", { 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)]", id: listboxId, ref: listRef, role: "listbox", children: rows.length > 0 ? (rows.map((row, rowIndex) => row.kind === "option" ? (renderOption(row.entry)) : (
142
+ // A run is wrapped in `role="group"` labelled by its heading —
143
+ // the APG grouped-listbox shape. Without it the grouping is
144
+ // conveyed to sighted users only: a roleless heading is not in
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] }));
102
151
  });
103
152
  Combobox.displayName = "Combobox";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardreach/saas-ui",
3
- "version": "0.8.0",
3
+ "version": "0.10.2",
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,