@terpjs/react-core 0.9.0 → 0.11.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.
Files changed (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
package/src/ui/Input.tsx CHANGED
@@ -1,6 +1,9 @@
1
+ import { useState } from "react";
1
2
  import type { InputHTMLAttributes } from "react";
2
3
 
4
+ import { Icon } from "../icons";
3
5
  import { injectTerpStyles } from "../styles";
6
+ import { useStrings } from "../uiText";
4
7
 
5
8
  injectTerpStyles();
6
9
 
@@ -11,7 +14,67 @@ export type InputProps = InputHTMLAttributes<HTMLInputElement>;
11
14
  * The `data-terp="input"` marker is the whole styling hook: it carries the shared control
12
15
  * surface, the focus ring, the hover border and the disabled treatment from the injected
13
16
  * sheet, with the element type deciding the geometry (ADR 0094).
17
+ *
18
+ * `type="password"` additionally grows a reveal toggle, and it is the TYPE that decides rather
19
+ * than a prop, for the reason the sheet already gives about `input` and `textarea`: "only their
20
+ * geometry differs, so the element type carries that — no second attribute for a distinction the
21
+ * tag name already makes." A `PasswordInput` export would be the `LoadingButton` mistake, a second
22
+ * name for one `<input>`.
23
+ *
24
+ * An app cannot build this itself, which is why it belongs here rather than in a recipe. The toggle
25
+ * needs a positioned wrapper, and `BOUNDARY_SPEC` refuses both `style` and `className` in module
26
+ * files — exactly the `Button.fullWidth` case: a shape the framework can produce and its consumers
27
+ * cannot ask for.
14
28
  */
15
- export function Input(props: InputProps) {
16
- return <input data-terp="input" {...props} />;
29
+ export function Input({ type, ...rest }: InputProps) {
30
+ const strings = useStrings();
31
+ const [revealed, setRevealed] = useState(false);
32
+
33
+ if (type !== "password") {
34
+ return <input data-terp="input" type={type} {...rest} />;
35
+ }
36
+
37
+ // A field the caller has switched off is switched off as a whole. Without this the value stays
38
+ // hidden and unreachable while the control beside it still reveals it — a disabled field with a
39
+ // working button in it.
40
+ const inert = rest.disabled === true || rest.readOnly === true;
41
+
42
+ return (
43
+ <span data-terp="input-password">
44
+ {/*
45
+ `rest` spreads onto the INPUT, never onto the wrapper, and that is load-bearing rather than
46
+ tidy. `Field` clones its control to inject `aria-describedby` and `aria-invalid`, and the
47
+ sheet's invalid border is `input[data-terp="input"][aria-invalid="true"]` — a single-element
48
+ selector. Land those on the span and the attribute matches nothing while the marker sits on
49
+ a different element, so the red border silently disappears for every password field with a
50
+ hint or an error. There is one live today.
51
+ */}
52
+ <input
53
+ data-terp="input"
54
+ type={revealed ? "text" : "password"}
55
+ // Revealing swaps the type to `text`, and a text input is a candidate for spellcheck,
56
+ // autocorrect and autocapitalisation in engines that apply them — none of which a password
57
+ // wants, and two of which would silently rewrite what the user typed on a phone. Declared
58
+ // before the spread so a caller can still override them.
59
+ spellCheck={false}
60
+ autoCorrect="off"
61
+ autoCapitalize="none"
62
+ {...rest}
63
+ />
64
+ <button
65
+ type="button"
66
+ data-terp="iconbutton"
67
+ disabled={inert}
68
+ // No `aria-pressed`. The name already carries the state — it swaps between "Show password"
69
+ // and "Hide password" — and encoding it twice makes the two disagree: a toggle announced
70
+ // as "Hide password, pressed" claims the value is hidden and shown at once. It also keeps
71
+ // this button out of the shared hover guard, which excludes `[aria-pressed="true"]` and
72
+ // would otherwise leave the revealed toggle with no hover feedback at all.
73
+ aria-label={revealed ? strings.hidePassword : strings.showPassword}
74
+ onClick={() => setRevealed((on) => !on)}
75
+ >
76
+ <Icon name={revealed ? "eye-off" : "eye"} />
77
+ </button>
78
+ </span>
79
+ );
17
80
  }
package/src/ui/Menu.tsx CHANGED
@@ -9,7 +9,17 @@ import type { PopoverAlign, PopoverPlacement, PopoverRootMarker, PopoverRootVari
9
9
 
10
10
  export interface MenuProps {
11
11
  trigger: ReactNode;
12
- triggerLabel: UiText;
12
+ /**
13
+ * The trigger's accessible name, as `aria-label`.
14
+ *
15
+ * Optional, and omitting it is a claim rather than a shortcut: `aria-label` REPLACES the
16
+ * subtree text in the accessible name, so a trigger that renders its own visible label
17
+ * must not carry one — naming it something else hides what the user can see and leaves a
18
+ * voice-control user with no spoken label that matches it (WCAG 2.5.3, Label in Name).
19
+ * Pass it whenever the trigger's content is an icon, initials or anything `aria-hidden`,
20
+ * which is every packaged caller but `UserMenu`'s expanded form.
21
+ */
22
+ triggerLabel?: UiText;
13
23
  children: (api: { close: (restoreFocus?: boolean) => void }) => ReactNode;
14
24
  open?: boolean;
15
25
  defaultOpen?: boolean;
@@ -73,11 +83,12 @@ export function Menu({
73
83
  data-owner={owner}
74
84
  trigger={
75
85
  // Its own marker rather than the shared `iconbutton` it used to borrow. That marker
76
- // is worn by sixteen visually different sites — the shell's two header toggles, four
86
+ // is worn by seventeen visually different sites — the shell's two header toggles, four
77
87
  // pagination arrows, a toast dismisser, the combobox's clear button, the calendar's
78
88
  // month arrows, the DataView's expand toggle, the view-options panel's two reorder
79
- // arrows and the DataView toolbar's clear-search button and two layout toggles and it
80
- // has no base rule at all, because each is styled by where it sits.
89
+ // arrows, the DataView toolbar's clear-search button and two layout toggles, and the
90
+ // password field's reveal toggle and its base rule carries a transition and nothing
91
+ // else, because the GEOMETRY of each is decided by where it sits.
81
92
  // This one is an outlined control with a border, a radius and control
82
93
  // typography, and it is also the trigger whose root marker is about to become
83
94
  // configurable, so a `[data-terp="popover"] > [data-terp="iconbutton"]` structural
@@ -86,7 +97,7 @@ export function Menu({
86
97
  <button
87
98
  type="button"
88
99
  data-terp="menu-trigger"
89
- aria-label={resolve(triggerLabel)}
100
+ aria-label={triggerLabel === undefined ? undefined : resolve(triggerLabel)}
90
101
  aria-haspopup="menu"
91
102
  >
92
103
  {trigger}
@@ -237,6 +237,19 @@ export function Popover({
237
237
  // lives in the sheet with everything else instead of travelling as an object.
238
238
  data-owner={owner ?? rootMarker ?? "popover"}
239
239
  tabIndex={-1}
240
+ // Tab closes the panel and restores focus to the trigger FIRST, then lets the default
241
+ // action run from there — the contract `Menu` already implements, and for the reason
242
+ // written out at that call site. The panel is portalled to the end of document.body,
243
+ // so with no Tab branch the sequential-navigation starting point stayed inside a node
244
+ // at the wrong end of the document: Tab out of the panel landed past every piece of
245
+ // page content, and Shift+Tab landed on the last focusable element on the page rather
246
+ // than back on the control that opened it. Deliberately not a focus TRAP — a popover
247
+ // is a non-modal disclosure, and trapping would be a stronger promise than it makes.
248
+ onKeyDown={(event) => {
249
+ if (event.key === "Tab") {
250
+ close(true);
251
+ }
252
+ }}
240
253
  // Only the measured part is inline: the left/top the layout effect computes from
241
254
  // the trigger's rect and clamps against the viewport, and the visibility that
242
255
  // hides the panel for the frame before that measurement exists. Everything the
package/src/ui/Radio.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useId, useState } from "react";
2
- import type { InputHTMLAttributes, ReactNode } from "react";
2
+ import type { ChangeEvent, InputHTMLAttributes } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
5
5
  import { useUiText } from "../uiText";
@@ -28,7 +28,15 @@ export function Radio({ label, value, checked, defaultChecked, onChange, style,
28
28
  value={value}
29
29
  checked={checked}
30
30
  defaultChecked={defaultChecked}
31
- onChange={(event) => onChange?.(event.currentTarget.checked)}
31
+ // Attached only when there is something to call, exactly as Select does and for the
32
+ // reason stated there: an unconditional handler silences React's own "you provided a
33
+ // `checked` prop to a form field without an `onChange` handler" guard, so a caller who
34
+ // pinned `checked` and forgot the handler gets a control that looks operable, never
35
+ // changes, and says nothing about it. The spread form is what keeps the prop absent
36
+ // rather than present-and-undefined, which React treats as the same mistake.
37
+ {...(onChange !== undefined
38
+ ? { onChange: (event: ChangeEvent<HTMLInputElement>) => onChange(event.currentTarget.checked) }
39
+ : {})}
32
40
  />
33
41
  <span>{resolve(label)}</span>
34
42
  </label>
@@ -45,7 +53,6 @@ export interface RadioGroupProps {
45
53
  label: UiText;
46
54
  name?: string;
47
55
  options?: readonly RadioOption[];
48
- children?: ReactNode;
49
56
  value?: string;
50
57
  defaultValue?: string;
51
58
  onChange?: (value: string) => void;
@@ -57,7 +64,6 @@ export function RadioGroup({
57
64
  label,
58
65
  name,
59
66
  options,
60
- children,
61
67
  value,
62
68
  defaultValue,
63
69
  onChange,
@@ -95,7 +101,6 @@ export function RadioGroup({
95
101
  }}
96
102
  />
97
103
  ))}
98
- {children}
99
104
  </div>
100
105
  </fieldset>
101
106
  );
@@ -0,0 +1,232 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { UiTextProvider } from "../uiText";
6
+ import { Select } from "./Select";
7
+ import type { SelectOption } from "./Select";
8
+
9
+ afterEach(cleanup);
10
+
11
+ type Status = "open" | "doing" | "done";
12
+
13
+ const STATUSES: SelectOption<Status>[] = [
14
+ { value: "open", label: "Open" },
15
+ { value: "doing", label: "In progress" },
16
+ { value: "done", label: "Done", disabled: true },
17
+ ];
18
+
19
+ describe("Select — the options list", () => {
20
+ it("renders one option per entry, in order, with the disabled flag applied", () => {
21
+ render(<Select aria-label="Status" options={STATUSES} defaultValue="open" />);
22
+ const options = screen.getAllByRole("option") as HTMLOptionElement[];
23
+ expect(options.map((option) => [option.value, option.textContent])).toEqual([
24
+ ["open", "Open"],
25
+ ["doing", "In progress"],
26
+ ["done", "Done"],
27
+ ]);
28
+ // The flag has to reach the element rather than only the data, which is the half a
29
+ // "renders three options" assertion would miss.
30
+ expect(options.map((option) => option.disabled)).toEqual([false, false, true]);
31
+ });
32
+
33
+ it("resolves a label descriptor through the active resolver, not just a plain string", () => {
34
+ // `label` is a UiText, so a translated catalog has to reach it — a component that
35
+ // rendered `String(label)` would print "[object Object]" and this is what says so.
36
+ render(
37
+ <UiTextProvider resolveText={(text) => (typeof text === "string" ? text : `nl:${text.id}`)}>
38
+ <Select
39
+ aria-label="Status"
40
+ options={[{ value: "open", label: { id: "status.open", message: "Open" } }]}
41
+ />
42
+ </UiTextProvider>,
43
+ );
44
+ expect(screen.getByRole("option")).toHaveTextContent("nl:status.open");
45
+ });
46
+
47
+ it("shows the placeholder as the selected row, not just as an option", () => {
48
+ // The selection is the assertion, and the markup-only version of this test was green
49
+ // against a real bug. HTML's selectedness algorithm picks the first option that is NOT
50
+ // disabled, so a disabled placeholder row is skipped and the control opens on the first
51
+ // real choice: measured, `value=open, selectedIndex=1`, with a "Choose a status" row sitting
52
+ // in the list that the user never saw. Every markup assertion below passed throughout.
53
+ render(<Select aria-label="Status" options={STATUSES} placeholder="Choose a status" />);
54
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
55
+ expect(select.value).toBe("");
56
+ expect(select.selectedIndex).toBe(0);
57
+ expect(select.options[0]!.textContent).toBe("Choose a status");
58
+ // Still disabled and still empty-valued: an enabled placeholder is re-selectable, and a
59
+ // non-empty value would submit the prompt text as data.
60
+ expect(select.options[0]!.disabled).toBe(true);
61
+ expect(select.options[0]!.value).toBe("");
62
+ });
63
+
64
+ it("lets an explicit value win over the placeholder", () => {
65
+ // The other half: starting empty is a default for the unpinned case, not an override. A
66
+ // caller who pins a value gets it, and the placeholder stays in the list as the row they
67
+ // came from.
68
+ render(
69
+ <Select aria-label="Status" options={STATUSES} placeholder="Choose" defaultValue="doing" />,
70
+ );
71
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
72
+ expect(select.value).toBe("doing");
73
+ expect(select.selectedIndex).toBe(2);
74
+ });
75
+
76
+ it("resolves a placeholder descriptor too, not only a plain string", () => {
77
+ // `placeholder` is typed `UiText`, and a `string`-only version would have compiled just as
78
+ // well while rendering "[object Object]" on screen — the string test above cannot tell the
79
+ // two apart, so this is the assertion that can.
80
+ //
81
+ // (An earlier version of this comment claimed `SelectHTMLAttributes` already declares
82
+ // `placeholder?: string` and that the union therefore intersects with it. It does not:
83
+ // `placeholder` is declared on `AllHTMLAttributes`, `InputHTMLAttributes` and
84
+ // `TextareaHTMLAttributes` only. There is no intersection here, and the prop is simply
85
+ // ours.)
86
+ render(
87
+ <UiTextProvider resolveText={(text) => (typeof text === "string" ? text : `nl:${text.id}`)}>
88
+ <Select
89
+ aria-label="Status"
90
+ options={STATUSES}
91
+ placeholder={{ id: "status.choose", message: "Choose a status" }}
92
+ />
93
+ </UiTextProvider>,
94
+ );
95
+ expect(screen.getAllByRole("option")[0]).toHaveTextContent("nl:status.choose");
96
+ });
97
+
98
+ it("hands onValueChange the selected value, and still fires the raw onChange", () => {
99
+ const onValueChange = vi.fn();
100
+ const onChange = vi.fn();
101
+ render(
102
+ <Select
103
+ aria-label="Status"
104
+ options={STATUSES}
105
+ defaultValue="open"
106
+ onValueChange={onValueChange}
107
+ onChange={onChange}
108
+ />,
109
+ );
110
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "doing" } });
111
+ expect(onValueChange).toHaveBeenCalledWith("doing");
112
+ // Both, not one instead of the other: a caller that needs the element keeps its event.
113
+ expect(onChange).toHaveBeenCalledTimes(1);
114
+ expect(onChange.mock.calls[0]![0].target.value).toBe("doing");
115
+ });
116
+
117
+ it("calls onValueChange without an options list too", () => {
118
+ // The typed callback is on the value props, not on the options branch, so the raw-children
119
+ // form gets it as well. Worth pinning: putting it on one branch only would be the
120
+ // works-in-one-branch defect the union exists to prevent.
121
+ const onValueChange = vi.fn();
122
+ render(
123
+ <Select aria-label="Status" defaultValue="open" onValueChange={onValueChange}>
124
+ <option value="open">Open</option>
125
+ <option value="doing">In progress</option>
126
+ </Select>,
127
+ );
128
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "doing" } });
129
+ expect(onValueChange).toHaveBeenCalledWith("doing");
130
+ });
131
+ });
132
+
133
+ describe("Select — what did not change", () => {
134
+ it("still renders raw option children unchanged, and carries the shared control marker", () => {
135
+ render(
136
+ <Select aria-label="Status" defaultValue="doing">
137
+ <option value="open">Open</option>
138
+ <option value="doing">In progress</option>
139
+ </Select>,
140
+ );
141
+ const select = screen.getByRole("combobox");
142
+ expect(select).toHaveAttribute("data-terp", "input");
143
+ expect((select as HTMLSelectElement).value).toBe("doing");
144
+ expect(screen.getAllByRole("option")).toHaveLength(2);
145
+ });
146
+
147
+ it("renders no inline style in either form", () => {
148
+ // ADR 0094 §3 as an assertion rather than a claim: the control's surface, the focus ring
149
+ // and the chevron are all sheet rules keyed on `data-terp="input"`, and the inline-style
150
+ // ledger is exact-equality per file — so a style attribute here would fail markers.test.ts
151
+ // as well, one release later and further from the cause.
152
+ const { container } = render(
153
+ <>
154
+ <Select aria-label="A" options={STATUSES} />
155
+ <Select aria-label="B">
156
+ <option value="open">Open</option>
157
+ </Select>
158
+ </>,
159
+ );
160
+ for (const select of container.querySelectorAll("select")) {
161
+ expect(select.getAttribute("style")).toBeNull();
162
+ }
163
+ });
164
+
165
+ it("still accepts a multiple select with an array value", () => {
166
+ // A regression this file exists to prevent, because it happened. The first version of the
167
+ // options work put the narrowed `value?: T` on BOTH branches, which quietly made
168
+ // `<Select multiple value={["a", "b"]}>` a typecheck error — a shape a raw `<select>` has
169
+ // and this component had always accepted. Nothing in the repo used it, so no gate noticed;
170
+ // it was found by probing the type rather than by running the suite.
171
+ //
172
+ // The narrowing only earns anything where `T` can be inferred, which is the options list.
173
+ // So the raw-children branch keeps `SelectHTMLAttributes` untouched, and this case is the
174
+ // assertion that says so. It has to COMPILE as much as pass.
175
+ render(
176
+ <Select aria-label="Tags" multiple value={["a", "b"]} onChange={() => {}}>
177
+ <option value="a">A</option>
178
+ <option value="b">B</option>
179
+ <option value="c">C</option>
180
+ </Select>,
181
+ );
182
+ const select = screen.getByRole("listbox") as HTMLSelectElement;
183
+ expect(select.multiple).toBe(true);
184
+ expect([...select.selectedOptions].map((option) => option.value)).toEqual(["a", "b"]);
185
+ });
186
+
187
+ it("still accepts a numeric value with numeric option children", () => {
188
+ // The other shape the over-eager narrowing broke: `value?: string | number` is what
189
+ // `SelectHTMLAttributes` declares, and a rank ladder rendered as numbers is the obvious
190
+ // caller — `UserCreate` was written that way before it moved to an options list.
191
+ render(
192
+ <Select aria-label="Rank" value={20} onChange={() => {}}>
193
+ <option value={10}>Viewer</option>
194
+ <option value={20}>Editor</option>
195
+ </Select>,
196
+ );
197
+ expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("20");
198
+ });
199
+
200
+ it("keeps React's own read-only diagnostic for a value with no handler", () => {
201
+ // The change that removed this was an unconditional onChange wrapper: React's
202
+ // `checkControlledValueProps` short-circuits on `props.onChange`, so attaching one always
203
+ // silenced the "you provided a `value` prop to a form field without an `onChange` handler"
204
+ // warning. The control then looked editable, never updated, and said nothing about it —
205
+ // strictly worse than before the options list existed. The wrapper is attached only when
206
+ // there is something to call.
207
+ const errors = vi.spyOn(console, "error").mockImplementation(() => {});
208
+ render(<Select aria-label="Status" options={STATUSES} value="open" />);
209
+ const logged = errors.mock.calls.map((call) => String(call[0])).join(" ");
210
+ errors.mockRestore();
211
+ expect(logged).toContain("without an `onChange` handler");
212
+ });
213
+
214
+ it("still renders with neither options nor children", () => {
215
+ // Legal before this component took an options list — a screen that renders its select
216
+ // before its choices have loaded — so `children` stays optional on that branch. Making it
217
+ // required was an unannounced breaking type change that nothing in the repo exercised.
218
+ render(<Select aria-label="Empty" />);
219
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
220
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
221
+ });
222
+
223
+ it("forwards arbitrary select attributes to the element", () => {
224
+ render(
225
+ <Select aria-label="Status" options={STATUSES} name="status" required disabled />,
226
+ );
227
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
228
+ expect(select.name).toBe("status");
229
+ expect(select.required).toBe(true);
230
+ expect(select.disabled).toBe(true);
231
+ });
232
+ });
package/src/ui/Select.tsx CHANGED
@@ -1,17 +1,186 @@
1
- import type { SelectHTMLAttributes } from "react";
1
+ import type { ChangeEvent, ReactNode, SelectHTMLAttributes } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
+ import { useUiText } from "../uiText";
5
+ import type { UiText } from "../uiText";
4
6
 
5
7
  injectTerpStyles();
6
8
 
7
- export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
9
+ /**
10
+ * One choice in a {@link Select}'s `options` list.
11
+ *
12
+ * `value` is the type parameter rather than `string`, which is the whole point of the list
13
+ * existing: a closed enum passed here checks its own members, so a typo is a typecheck error
14
+ * instead of an option nobody can select. `ComboboxOption` is the same shape without the
15
+ * parameter, and it can follow the day something asks — widening it is additive.
16
+ */
17
+ export interface SelectOption<T extends string = string> {
18
+ value: T;
19
+ /** Display text; a {@link UiText} descriptor so a translated label is expressible. */
20
+ label: UiText;
21
+ disabled?: boolean;
22
+ }
23
+
24
+ /** Everything a raw `<select>` accepts, minus what the two forms disagree about. */
25
+ type SelectBase = Omit<
26
+ SelectHTMLAttributes<HTMLSelectElement>,
27
+ "children" | "value" | "defaultValue"
28
+ >;
29
+
30
+ /** The unselected state a `placeholder` occupies — an empty value, as HTML spells it. */
31
+ type Unselected = "";
8
32
 
9
33
  /**
10
- * Token-styled select use instead of a raw `<select>` (the module-boundary rule). Pass
11
- * `<option>` children as usual. The `data-terp="input"` marker opts the element into the
12
- * shared control surface and focus ring; the sheet replaces the native affordance with an
13
- * SVG chevron so the control looks the same on every platform (ADR 0094).
34
+ * Props: the shared surface, plus a union over the two ways to give a `<select>` its choices.
35
+ *
36
+ * Four things here were each arrived at by breaking the alternative and measuring it, so they
37
+ * are worth stating rather than rediscovering.
38
+ *
39
+ * **`onValueChange` sits outside the union, and its parameter is `NoInfer`.** Inside the union
40
+ * the prop has two signatures and TypeScript cannot contextually type a parameter across a
41
+ * union of signatures: `onValueChange={(value) => …}` came back as an implicit `any`, which
42
+ * under `noImplicitAny` is an error the caller must paper over with an annotation — very nearly
43
+ * the cast this prop exists to remove. Outside the union it infers, but then the *callback*
44
+ * becomes an inference site for `T`: with raw children,
45
+ * `<Select onValueChange={setStatus}><option value="dong"/></Select>` took `T` from `setStatus`
46
+ * and never compared the children to it, so the one thing the prop promises to prevent was
47
+ * exactly what it allowed. `NoInfer` leaves the options list as the only inference site.
48
+ *
49
+ * **The value narrowing is on the options branch only.** Narrowing `value` to `T` is what
50
+ * removes `as Status`, but `T` can only come from an options list — so on the raw-children
51
+ * branch the same narrowing buys nothing and costs two shapes a `<select>` genuinely has:
52
+ * `multiple` with a `readonly string[]` value, and a numeric `value`.
53
+ *
54
+ * **A placeholder widens the value to `""`.** HTML's own selectedness algorithm picks the first
55
+ * option that is *not disabled*, so a disabled placeholder row is skipped and the control opens
56
+ * on the first real choice — measured: `value=open, selectedIndex=1`. Showing the placeholder
57
+ * means the select's value is the empty string, which is therefore part of the type rather than
58
+ * something a caller has to cast to.
59
+ *
60
+ * **The two forms are a union.** Rendering `options` while silently ignoring `children` would be
61
+ * a prop that works in one branch and does nothing in the other. `children` stays optional,
62
+ * though: a `<Select>` with no choices yet was legal before this component took an options list
63
+ * and still is.
14
64
  */
15
- export function Select(props: SelectProps) {
16
- return <select data-terp="input" {...props} />;
65
+ export type SelectProps<T extends string = string> = SelectBase & {
66
+ /**
67
+ * The selected value, typed.
68
+ *
69
+ * With `options` given, `T` is inferred from them, so the callback is checked against the
70
+ * choices. With raw children there is nothing to infer from and `T` stays `string` — which
71
+ * is deliberate: nothing checks hand-written `<option>` values, so a narrower promise here
72
+ * would be a false one.
73
+ */
74
+ onValueChange?: (value: NoInfer<T>) => void;
75
+ } & (
76
+ | {
77
+ /** The choices, as data. Mutually exclusive with `children`. */
78
+ options: readonly SelectOption<T>[];
79
+ /**
80
+ * A leading, disabled, empty-valued row — the "choose one" case.
81
+ *
82
+ * `placeholder` is not an attribute a `<select>` honours, so this renders the
83
+ * `<option value="" disabled>` every app was writing by hand, AND selects it when the
84
+ * caller has pinned no value of their own. Both halves are needed: without the second
85
+ * the row exists and is never shown.
86
+ */
87
+ placeholder?: UiText;
88
+ /**
89
+ * `NoInfer` is load-bearing. Without it `value` is a second inference site for `T`, so
90
+ * `value="dong"` beside a `SelectOption<Status>[]` list widens the parameter to
91
+ * `Status | "dong"` and compiles — the value checked against a type it helped choose.
92
+ */
93
+ value?: NoInfer<T> | Unselected;
94
+ defaultValue?: NoInfer<T> | Unselected;
95
+ children?: never;
96
+ }
97
+ | {
98
+ options?: never;
99
+ value?: SelectHTMLAttributes<HTMLSelectElement>["value"];
100
+ defaultValue?: SelectHTMLAttributes<HTMLSelectElement>["defaultValue"];
101
+ children?: ReactNode;
102
+ }
103
+ );
104
+
105
+ /**
106
+ * Token-styled select — use instead of a raw `<select>` (the module-boundary rule).
107
+ *
108
+ * Two forms. Pass `options` (with `onValueChange` for a typed callback) when the choices are
109
+ * data, which is the common case and the one that had no support: a closed enum needed a
110
+ * hand-written `<option>` per member plus a cast on the way out, so every app grew its own
111
+ * `EnumSelect<T>` wrapper. Or pass `<option>` children as before, unchanged.
112
+ *
113
+ * The `data-terp="input"` marker opts the element into the shared control surface and focus
114
+ * ring; the sheet replaces the native affordance with an SVG chevron so the control looks the
115
+ * same on every platform (ADR 0094). Neither form renders an inline style.
116
+ */
117
+ export function Select<T extends string = string>(props: SelectProps<T>) {
118
+ const resolve = useUiText();
119
+ // Destructured from a cast rather than in the signature: the branches of the union do not
120
+ // both declare these keys, so naming them as parameters would widen the parameter type to
121
+ // the intersection and lose the exclusivity the union exists to enforce.
122
+ const {
123
+ options,
124
+ placeholder,
125
+ children,
126
+ onChange,
127
+ onValueChange,
128
+ value,
129
+ defaultValue,
130
+ ...rest
131
+ } = props as SelectBase & {
132
+ options?: readonly SelectOption<T>[];
133
+ placeholder?: UiText;
134
+ children?: ReactNode;
135
+ onValueChange?: (value: string) => void;
136
+ value?: string | number | readonly string[];
137
+ defaultValue?: string | number | readonly string[];
138
+ };
139
+
140
+ // A placeholder the browser never selects is the row existing without the feature working,
141
+ // so an unpinned select with one starts empty. Only when the caller pinned neither, and only
142
+ // as `defaultValue` — passing both is a React warning, and pinning `value` would make every
143
+ // placeholder select controlled.
144
+ const startsUnselected =
145
+ placeholder !== undefined && value === undefined && defaultValue === undefined;
146
+
147
+ // Attached only when there is something to call. An unconditional handler silences React's
148
+ // own "you provided a `value` prop to a form field without an `onChange` handler" guard, so a
149
+ // caller who forgot the handler would get a control that looks editable, never updates, and
150
+ // says nothing about it.
151
+ const handlesChange = onChange !== undefined || onValueChange !== undefined;
152
+
153
+ return (
154
+ <select
155
+ data-terp="input"
156
+ {...rest}
157
+ value={value}
158
+ defaultValue={startsUnselected ? "" : defaultValue}
159
+ onChange={
160
+ handlesChange
161
+ ? (event: ChangeEvent<HTMLSelectElement>) => {
162
+ onChange?.(event);
163
+ onValueChange?.(event.target.value);
164
+ }
165
+ : undefined
166
+ }
167
+ >
168
+ {options === undefined ? (
169
+ children
170
+ ) : (
171
+ <>
172
+ {placeholder !== undefined && (
173
+ <option value="" disabled>
174
+ {resolve(placeholder)}
175
+ </option>
176
+ )}
177
+ {options.map((option) => (
178
+ <option key={option.value} value={option.value} disabled={option.disabled}>
179
+ {resolve(option.label)}
180
+ </option>
181
+ ))}
182
+ </>
183
+ )}
184
+ </select>
185
+ );
17
186
  }
package/src/ui/Switch.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { InputHTMLAttributes } from "react";
1
+ import type { ChangeEvent, InputHTMLAttributes } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
4
  import { useUiText } from "../uiText";
@@ -26,7 +26,15 @@ export function Switch({ label, checked, defaultChecked, onChange, style, ...res
26
26
  data-terp="switch"
27
27
  checked={checked}
28
28
  defaultChecked={defaultChecked}
29
- onChange={(event) => onChange?.(event.currentTarget.checked)}
29
+ // Attached only when there is something to call, exactly as Select does and for the
30
+ // reason stated there: an unconditional handler silences React's own "you provided a
31
+ // `checked` prop to a form field without an `onChange` handler" guard, so a caller who
32
+ // pinned `checked` and forgot the handler gets a control that looks operable, never
33
+ // changes, and says nothing about it. The spread form is what keeps the prop absent
34
+ // rather than present-and-undefined, which React treats as the same mistake.
35
+ {...(onChange !== undefined
36
+ ? { onChange: (event: ChangeEvent<HTMLInputElement>) => onChange(event.currentTarget.checked) }
37
+ : {})}
30
38
  />
31
39
  <span>{resolve(label)}</span>
32
40
  </label>