@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.
- package/README.md +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +12 -0
- package/src/markers.test.ts +27 -5
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/router.test.tsx +575 -2
- package/src/router.tsx +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +239 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +69 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- 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(
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
80
|
-
//
|
|
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}
|
package/src/ui/Popover.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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>
|