@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
@@ -16,10 +16,14 @@ import { useStrings } from "../uiText";
16
16
  import { unwrap } from "../unwrap";
17
17
 
18
18
  import { adminCrumb, renderAdminCrumb } from "./crumbs";
19
+ import { routeFieldErrors } from "./fieldErrors";
19
20
  import { adminRoleOptions } from "./roles";
20
21
 
21
22
  const FORM_ID = "terp-admin-user-create";
22
23
 
24
+ /** The inputs this form renders, and so the only reasons it can put anywhere the user will see. */
25
+ const RENDERED_FIELDS = ["email", "password", "role"] as const;
26
+
23
27
  /** Dedicated account-provisioning page (`/admin/users/new`). */
24
28
  export function UserCreate() {
25
29
  const client = useTerpClient();
@@ -30,11 +34,16 @@ export function UserCreate() {
30
34
  const [password, setPassword] = useState("");
31
35
  const [role, setRole] = useState("10");
32
36
  const [creating, setCreating] = useState(false);
37
+ const [fieldErrors, setFieldErrors] = useState<Readonly<Record<string, string>>>({});
33
38
  const roles = adminRoleOptions(strings);
34
39
 
35
40
  async function onSubmit(event: FormEvent) {
36
41
  event.preventDefault();
37
42
  setCreating(true);
43
+ // Cleared on every attempt, not merged: the server re-validates the whole body, so its
44
+ // answer is the complete set of what is wrong. Keeping a stale key would leave an error
45
+ // under a field the user just fixed.
46
+ setFieldErrors({});
38
47
  try {
39
48
  const user = unwrap(
40
49
  await client.POST("/api/v1/users/", {
@@ -47,7 +56,15 @@ export function UserCreate() {
47
56
  params: { userId: user.id },
48
57
  });
49
58
  } catch (error) {
50
- toast.warning(error instanceof Error ? error.message : strings.requestFailed);
59
+ // A reason that names a field belongs on that field, not floating above the form in a
60
+ // toast the user has to hold in their head while looking for the input it means. Anything
61
+ // the server did not attribute — or attributed to something this form has no input for —
62
+ // stays a toast, which is where the delete and revoke handlers correctly leave it.
63
+ const { shown, leftover } = routeFieldErrors(error, RENDERED_FIELDS);
64
+ setFieldErrors(shown);
65
+ if (Object.keys(shown).length === 0 || leftover) {
66
+ toast.warning(error instanceof Error ? error.message : strings.requestFailed);
67
+ }
51
68
  } finally {
52
69
  setCreating(false);
53
70
  }
@@ -83,7 +100,7 @@ export function UserCreate() {
83
100
  >
84
101
  <div data-terp="admin-form">
85
102
  <Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
86
- <Field label={strings.email}>
103
+ <Field label={strings.email} error={fieldErrors.email}>
87
104
  <Input
88
105
  type="email"
89
106
  value={email}
@@ -91,22 +108,34 @@ export function UserCreate() {
91
108
  required
92
109
  />
93
110
  </Field>
94
- <Field label={strings.password}>
111
+ <Field label={strings.password} error={fieldErrors.password}>
95
112
  <Input
96
113
  type="password"
114
+ // The admin is setting someone else's password, so this is never the browser's saved
115
+ // credential: `new-password` stops a manager offering the admin's own.
116
+ autoComplete="new-password"
97
117
  value={password}
98
118
  onChange={(event) => setPassword(event.target.value)}
99
119
  required
100
120
  />
101
121
  </Field>
102
- <Field label={strings.role}>
103
- <Select value={role} onChange={(event) => setRole(event.target.value)}>
104
- {roles.map((option) => (
105
- <option key={option.rank} value={option.rank}>
106
- {option.label}
107
- </option>
108
- ))}
109
- </Select>
122
+ <Field label={strings.role} error={fieldErrors.role}>
123
+ {/* `options` + `onValueChange` rather than an `<option>` per rank and a raw
124
+ change event: the same three rows, as data. No cast is removed here — this file
125
+ never had one, because `role` was already a plain string — and `T` degrades to
126
+ `string` for the same reason, since the option values are `String(rank)` rather
127
+ than members of a declared union. What this conversion buys is the list, not the
128
+ type: the rank is a number in the ladder and a string in the DOM, which is why
129
+ `String(rank)` is the option value and `role` stays a string until the POST body
130
+ coerces it. The typed half has its exhibit in an app that owns an enum. */}
131
+ <Select
132
+ options={roles.map((option) => ({
133
+ value: String(option.rank),
134
+ label: option.label,
135
+ }))}
136
+ value={role}
137
+ onValueChange={setRole}
138
+ />
110
139
  </Field>
111
140
  </Stack>
112
141
  </div>
@@ -13,6 +13,7 @@ import { useRecord } from "../useRecord";
13
13
  import { useToast } from "../toast";
14
14
  import { Button } from "../ui/Button";
15
15
  import { Input } from "../ui/Input";
16
+ import { useFormatDateTime } from "../format";
16
17
  import { useStrings } from "../uiText";
17
18
  import { unwrap } from "../unwrap";
18
19
 
@@ -30,6 +31,7 @@ export function UserDetail() {
30
31
  const userId = useDeclaredParam("userId");
31
32
  const client = useTerpClient();
32
33
  const strings = useStrings();
34
+ const formatDateTime = useFormatDateTime();
33
35
  const toast = useToast();
34
36
  const [pendingLifecycle, setPendingLifecycle] = useState<PendingLifecycle | null>(null);
35
37
  const [mutating, setMutating] = useState(false);
@@ -173,7 +175,7 @@ export function UserDetail() {
173
175
  label: strings.statusColumn,
174
176
  value: record.is_active ? strings.statusActive : strings.statusDeactivated,
175
177
  },
176
- { label: strings.createdColumn, value: new Date(record.created_at).toLocaleString() },
178
+ { label: strings.createdColumn, value: formatDateTime(record.created_at) },
177
179
  ]}
178
180
  />
179
181
  )}
@@ -211,6 +213,7 @@ export function UserDetail() {
211
213
  <Field label={strings.newPassword}>
212
214
  <Input
213
215
  type="password"
216
+ autoComplete="new-password"
214
217
  value={resetPassword}
215
218
  onChange={(event) => setResetPassword(event.target.value)}
216
219
  required
@@ -9,6 +9,7 @@ import { useTerpClient } from "../TerpProvider";
9
9
  import { DataView, HttpDataViewRepository, useServerDataView } from "../dataview";
10
10
  import type { DataViewColumn } from "../dataview";
11
11
  import { Button } from "../ui/Button";
12
+ import { useFormatDate } from "../format";
12
13
  import { useStrings } from "../uiText";
13
14
  import type { TerpStrings } from "../uiText";
14
15
  import { unwrap } from "../unwrap";
@@ -18,7 +19,10 @@ import { adminRoleLabel } from "./roles";
18
19
 
19
20
  type UserRead = components["schemas"]["UserRead"];
20
21
 
21
- function buildColumns(strings: TerpStrings): DataViewColumn<UserRead>[] {
22
+ function buildColumns(
23
+ strings: TerpStrings,
24
+ formatDate: (value: string) => string,
25
+ ): DataViewColumn<UserRead>[] {
22
26
  return [
23
27
  { id: "email", header: strings.email, accessor: (u) => u.email, meta: { mobileSlot: "title" } },
24
28
  {
@@ -26,20 +30,20 @@ function buildColumns(strings: TerpStrings): DataViewColumn<UserRead>[] {
26
30
  header: strings.role,
27
31
  accessor: (u) => u.role,
28
32
  cell: (u) => adminRoleLabel(strings, u.role),
29
- meta: { mobileSlot: "subtitle", width: 100 },
33
+ meta: { mobileSlot: "subtitle", width: "xs" },
30
34
  },
31
35
  {
32
36
  id: "is_active",
33
37
  header: strings.statusColumn,
34
38
  accessor: (u) => (u.is_active ? strings.statusActive : strings.statusDeactivated),
35
- meta: { mobileSlot: "status", width: 110 },
39
+ meta: { mobileSlot: "status", width: "sm" },
36
40
  },
37
41
  {
38
42
  id: "created_at",
39
43
  header: strings.createdColumn,
40
44
  accessor: (u) => u.created_at,
41
- cell: (u) => new Date(u.created_at).toLocaleDateString(),
42
- meta: { mobileSlot: "date", width: 120 },
45
+ cell: (u) => formatDate(u.created_at),
46
+ meta: { mobileSlot: "date", width: "sm" },
43
47
  },
44
48
  ];
45
49
  }
@@ -55,7 +59,11 @@ export function UsersAdmin() {
55
59
  const navigate = useNavigate();
56
60
  const serverQuery = useServerDataView({ initialPageSize: 10 });
57
61
 
58
- const columns = useMemo(() => buildColumns(strings), [strings]);
62
+ const formatDate = useFormatDate();
63
+ const columns = useMemo(
64
+ () => buildColumns(strings, formatDate),
65
+ [strings, formatDate],
66
+ );
59
67
  const repository = useMemo(
60
68
  () =>
61
69
  new HttpDataViewRepository<UserRead>({
@@ -8,14 +8,15 @@ import type { ComponentType } from "react";
8
8
 
9
9
  import { withAdminArea } from "../bootstrap";
10
10
  import type { AdminAreaSections } from "../bootstrap";
11
+ import { formatDateTime } from "../format";
11
12
  import { buildAppRouter } from "../router";
12
13
  import { Page } from "../Page";
13
14
  import { TerpProvider, useAuth } from "../TerpProvider";
14
15
  import { ToastProvider } from "../toast";
15
16
 
16
- function jsonResponse(body: unknown): Response {
17
+ function jsonResponse(body: unknown, status = 200): Response {
17
18
  return new Response(JSON.stringify(body), {
18
- status: 200,
19
+ status,
19
20
  headers: { "content-type": "application/json" },
20
21
  });
21
22
  }
@@ -313,7 +314,27 @@ function stubAdminFetch() {
313
314
  });
314
315
  }
315
316
  if (path.endsWith("/api/v1/audit/")) {
316
- return jsonResponse(emptyPage);
317
+ // One row, and it earns its place rather than padding the fixture: the audit screen's
318
+ // expanded panel is the only place the payload renders, so with an empty page that
319
+ // <pre> — and the `tabIndex` that keeps its scroll container reachable — could not be
320
+ // asserted anywhere. The comment below this fixture used to say exactly that.
321
+ return jsonResponse({
322
+ items: [
323
+ {
324
+ id: "e1",
325
+ created_at: "2026-08-21T09:30:00Z",
326
+ action: "update",
327
+ target_type: "sync_definition",
328
+ target_id: "4d2c1b7e-0000-4000-8000-000000000001",
329
+ actor_id: "9f2c1b7e-0000-4000-8000-000000000002",
330
+ request_id: "req_01HQ8ZK4",
331
+ payload: { window: "02:00-04:00 UTC", retention_days: 90 },
332
+ },
333
+ ],
334
+ total: 1,
335
+ skip: 0,
336
+ limit: 25,
337
+ });
317
338
  }
318
339
  return jsonResponse(emptyPage);
319
340
  });
@@ -440,6 +461,48 @@ describe("the packaged admin area", () => {
440
461
  })).toBe(true);
441
462
  });
442
463
 
464
+ it("puts a 422's reason under the field it names instead of floating it in a toast", async () => {
465
+ // The failure path had no test at all, which is how the framework shipped `Field.error` with
466
+ // no production consumer for two releases: the rendering half was gated, the producing half
467
+ // did not exist, and nothing exercised the seam between them.
468
+ //
469
+ // The two assertions are deliberately different strings. `Field` shows the server's bare
470
+ // `msg`; the toast shows the joined `path: msg` sentence that `unwrap` has always produced.
471
+ // Asserting only the first would stay green if BOTH appeared, which is the failure mode worth
472
+ // guarding — three channels for one problem is what this commit set out to stop.
473
+ const { fetchMock } = renderAdminApp("/admin/users/new");
474
+ await screen.findByRole("heading", { level: 1, name: "Provision user" });
475
+ // A uniqueness violation, not a malformed address, and the choice is not incidental: the
476
+ // browser rejects a malformed one before any request leaves, so `type="email"` would have
477
+ // caught it and the POST would never happen (jsdom enforces that too, which is how the first
478
+ // draft of this test failed). What is left over after the four HTML constraint attributes
479
+ // have done their work is exactly what the server alone knows, and that is the class of
480
+ // reason this whole channel exists to carry.
481
+ const passthrough = fetchMock.getMockImplementation()!;
482
+ fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
483
+ const request = input as Request;
484
+ if (request.method === "POST" && request.url.endsWith("/api/v1/users/")) {
485
+ return jsonResponse(
486
+ { detail: [{ loc: ["body", "email"], msg: "Email address is already registered" }] },
487
+ 422,
488
+ );
489
+ }
490
+ return passthrough(input, init);
491
+ });
492
+
493
+ fireEvent.change(screen.getByLabelText("Email"), { target: { value: "taken@example.com" } });
494
+ fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } });
495
+ fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
496
+
497
+ const shown = await screen.findByText("Email address is already registered");
498
+ expect(shown.getAttribute("data-terp")).toBe("field-error");
499
+ expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
500
+ expect(screen.queryByText("email: Email address is already registered")).toBeNull();
501
+ // Still on the create page: a rejected submit must not navigate away from the input it is
502
+ // asking the user to fix.
503
+ expect(screen.getByRole("heading", { level: 1, name: "Provision user" })).toBeInTheDocument();
504
+ });
505
+
443
506
  it("confirms lifecycle mutations from the user detail action slot", async () => {
444
507
  const { fetchMock } = renderAdminApp("/admin/users/u1");
445
508
  await screen.findByRole("heading", { level: 1, name: "jane.doe@example.com" });
@@ -519,16 +582,126 @@ describe("the packaged admin area", () => {
519
582
  );
520
583
  });
521
584
 
585
+ it("still says something when the 422 names a field the form does not render", async () => {
586
+ // The hole in the obvious version of this pattern: a non-empty `fields` suppressed the toast
587
+ // on the way out, and if no key matched an input, the state it set was read by nothing. The
588
+ // user pressed Save and NOTHING happened — no field lit up, no message, no navigation. A
589
+ // failed write that reports nothing is worse than the floating toast it replaced.
590
+ //
591
+ // This also proves the sibling test's `queryByText(...).toBeNull()` is not vacuous: the toast
592
+ // really does render the joined `path: msg` sentence as findable text, so an assertion that
593
+ // it is absent is an assertion about something that could otherwise have been there.
594
+ const { fetchMock } = renderAdminApp("/admin/users/new");
595
+ await screen.findByRole("heading", { level: 1, name: "Provision user" });
596
+ const passthrough = fetchMock.getMockImplementation()!;
597
+ fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
598
+ const request = input as Request;
599
+ if (request.method === "POST" && request.url.endsWith("/api/v1/users/")) {
600
+ return jsonResponse(
601
+ { detail: [{ loc: ["body", "organization_id"], msg: "Not allowed for this tenant" }] },
602
+ 422,
603
+ );
604
+ }
605
+ return passthrough(input, init);
606
+ });
607
+
608
+ fireEvent.change(screen.getByLabelText("Email"), {
609
+ target: { value: "new.account@example.com" },
610
+ });
611
+ fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } });
612
+ fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
613
+
614
+ expect(
615
+ await screen.findByText("organization_id: Not allowed for this tenant"),
616
+ ).toBeInTheDocument();
617
+ // And nothing was marked invalid, because none of these inputs is the one the server meant.
618
+ expect(screen.getByLabelText("Email")).not.toHaveAttribute("aria-invalid");
619
+ expect(screen.getByLabelText("Password")).not.toHaveAttribute("aria-invalid");
620
+ });
621
+
622
+ it("shows the field it can and still toasts the reason it cannot, when a 422 names both", async () => {
623
+ // The mixed envelope is the case the `leftover` flag exists for, and the only one that
624
+ // distinguishes it: with no renderable reason at all the toast fires anyway because `shown` is
625
+ // empty, so a mutation neutering `leftover` stayed green until this test existed. Here `email`
626
+ // finds its input and `organization_id` does not, and BOTH have to reach the user.
627
+ const { fetchMock } = renderAdminApp("/admin/users/new");
628
+ await screen.findByRole("heading", { level: 1, name: "Provision user" });
629
+ const passthrough = fetchMock.getMockImplementation()!;
630
+ fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
631
+ const request = input as Request;
632
+ if (request.method === "POST" && request.url.endsWith("/api/v1/users/")) {
633
+ return jsonResponse(
634
+ {
635
+ detail: [
636
+ { loc: ["body", "email"], msg: "Email address is already registered" },
637
+ { loc: ["body", "organization_id"], msg: "Not allowed for this tenant" },
638
+ ],
639
+ },
640
+ 422,
641
+ );
642
+ }
643
+ return passthrough(input, init);
644
+ });
645
+
646
+ fireEvent.change(screen.getByLabelText("Email"), { target: { value: "taken@example.com" } });
647
+ fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } });
648
+ fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
649
+
650
+ const shown = await screen.findByText("Email address is already registered");
651
+ expect(shown.getAttribute("data-terp")).toBe("field-error");
652
+ expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
653
+ // The reason with nowhere to go is still said out loud, in the joined sentence.
654
+ expect(
655
+ screen.getByText(
656
+ "email: Email address is already registered; organization_id: Not allowed for this tenant",
657
+ ),
658
+ ).toBeInTheDocument();
659
+ });
660
+
661
+ it("renders its dates through the framework helper, not the built-in", async () => {
662
+ // What this gates is the CONVERSION, not the locale channel, and the distinction is worth
663
+ // naming: `renderAdminApp` mounts no `LocaleProvider`, so `useFormatDateTime` resolves an
664
+ // undefined locale and both sides of the assertion below say `undefined`. That the hook reads
665
+ // a provider at all is gated in format.test.tsx, by rendering two locales against each other.
666
+ //
667
+ // Here the teeth are in the negative half. Both spellings render the same instant and differ
668
+ // only in shape, so asserting the new one alone would stay green on a host whose default
669
+ // happened to agree with it. Asserting the old one is absent cannot be satisfied that way.
670
+ //
671
+ // Only one admin screen has a specimen (`admin-user-create`) and it renders no date, so
672
+ // nothing pictures these cells and nothing asserted their text before this — which is how
673
+ // seven of them sat on a locale-less built-in through two releases.
674
+ const when = "2026-08-21T09:30:00Z";
675
+ renderAdminApp("/admin/audit");
676
+ expect(await screen.findByText(formatDateTime(when, undefined))).toBeInTheDocument();
677
+ expect(screen.queryByText(new Date(when).toLocaleString())).toBeNull();
678
+ });
679
+
680
+ it("puts an unresolvable email under the member input rather than in a toast", async () => {
681
+ // Not a 422 — the directory simply has no match — but it is a statement about the email the
682
+ // user just typed, on a form whose only input is that email. Answering the server's reasons
683
+ // on the field and this one above it would be a distinction the user cannot perceive, so the
684
+ // routing follows what the message is about rather than where it came from.
685
+ renderAdminApp("/admin/groups/g1");
686
+ await screen.findByRole("heading", { level: 1, name: "Finance" });
687
+ fireEvent.change(screen.getByPlaceholderText("Email"), {
688
+ target: { value: "nobody@example.com" },
689
+ });
690
+ fireEvent.submit(screen.getByRole("button", { name: "Add member" }).closest("form")!);
691
+ const shown = await screen.findByText("No account matches that email.");
692
+ expect(shown.getAttribute("data-terp")).toBe("field-error");
693
+ });
694
+
522
695
  it("marks the detail sections it no longer styles inline", async () => {
523
696
  // The second of the three surfaces the admin views used to style at the call site. Same
524
697
  // reasoning as the create form above: markers plus the absence of a style attribute,
525
698
  // because no admin screen has a specimen.
526
699
  //
527
- // The third, the audit payload, has no assertion here and it is worth saying why rather
528
- // than quietly having none: the audit fixture serves an empty page, so no row exists to
529
- // expand and the <pre> never renders in any test. Giving it one means adding rows to a
530
- // fixture several tests share, which is not a change a styling migration should make.
531
- // It is held by styles.test.ts (the rule exists) and by the values being a verbatim copy.
700
+ // The third, the audit payload, has its own test below now. It had none for a while, and
701
+ // the reason was recorded here rather than left implicit: the audit fixture served an empty
702
+ // page, so no row existed to expand and the <pre> never rendered in any test. That stayed
703
+ // true until the payload gained a `tabIndex` a claim about the real component that no
704
+ // workbench specimen can make, because the specimen writes its own markup.
532
705
  renderAdminApp("/admin/groups/g1");
533
706
  const headings = await waitFor(() => {
534
707
  const found = document.querySelectorAll('[data-terp="admin-section-title"]');
@@ -541,6 +714,37 @@ describe("the packaged admin area", () => {
541
714
  }
542
715
  });
543
716
 
717
+ it("keeps the audit payload's scroll container reachable by keyboard", async () => {
718
+ // The gate for the SC 2.1.1 fix, and it has to be here rather than in the workbench.
719
+ // `code-block` declares `overflow-x: auto`, so a wide payload is a scroll container, and a
720
+ // scroll container no keyboard can reach is what axe reports as
721
+ // `scrollable-region-focusable`. The workbench specimen renders its own markup, so every lane
722
+ // there would stay green with the attribute gone from what this screen renders — the specimen
723
+ // would be asserting its own fixture back to itself. This reads the packaged screen.
724
+ //
725
+ // It also pins the substitution: the audit panel renders `Code block` now rather than a
726
+ // hand-rolled <pre> under a marker of its own, and if that reverted this would find the wrong
727
+ // marker rather than nothing at all.
728
+ renderAdminApp("/admin/audit");
729
+ // Expanding the row is what renders the panel; the trigger is the row's own expand control.
730
+ const expand = await waitFor(() => {
731
+ const found = document.querySelector('[data-terp="dataview-expand-cell"] button');
732
+ expect(found).not.toBeNull();
733
+ return found as HTMLButtonElement;
734
+ });
735
+ fireEvent.click(expand);
736
+ const payload = await waitFor(() => {
737
+ const found = document.querySelector('[data-terp="code-block"]');
738
+ expect(found).not.toBeNull();
739
+ return found as HTMLElement;
740
+ });
741
+ expect(payload.tagName).toBe("PRE");
742
+ expect(payload.tabIndex).toBe(0);
743
+ // And still no inline style — the third of the three surfaces the admin views used to
744
+ // style at the call site, which is what the note above refers to.
745
+ expect(payload.getAttribute("style")).toBeNull();
746
+ });
747
+
544
748
  it("clears group destructive state when navigating between detail records in place", async () => {
545
749
  const { router } = renderAdminApp("/admin/groups/g1");
546
750
  await screen.findByRole("heading", { level: 1, name: "Finance" });
@@ -0,0 +1,45 @@
1
+ import { ApiError } from "../unwrap";
2
+
3
+ /** What a form should do with a failure: what to put on inputs, and whether anything is left over. */
4
+ export interface RoutedFieldErrors {
5
+ /** Reasons naming a field this form actually renders, ready for `Field`'s `error` prop. */
6
+ shown: Record<string, string>;
7
+ /** At least one reason named something this form has no input for. */
8
+ leftover: boolean;
9
+ }
10
+
11
+ /**
12
+ * Split a failure's per-field reasons into the ones a form can show and the ones it cannot.
13
+ *
14
+ * The naive version of this — "if `fields` is non-empty, set them and return" — has a hole that is
15
+ * invisible until it happens: a reason naming a field the form does not render sets state nobody
16
+ * reads and suppresses the toast on the way out, so the user presses Save and *nothing appears*.
17
+ * No field lights up, no message, no navigation. A failed write that reports nothing is worse than
18
+ * the floating toast this replaced.
19
+ *
20
+ * The packaged forms cannot hit it today, because FastAPI's 422 can only name a key of the body
21
+ * they submitted and they render every one of those. That is a fact about these three endpoints,
22
+ * not about the pattern: a `terp.core.AppError` carrying `details` addresses whatever the rule
23
+ * checked, an app's form is free to submit a field it does not display, and this is the shape apps
24
+ * copy. `GroupDetail` already had it right by naming its one key and falling through to the toast
25
+ * when the server named a different one; this is that discipline, for a form with several.
26
+ *
27
+ * Not exported from the package. It becomes public API when something outside `admin/` needs it.
28
+ */
29
+ export function routeFieldErrors(
30
+ error: unknown,
31
+ rendered: readonly string[],
32
+ ): RoutedFieldErrors {
33
+ const shown: Record<string, string> = {};
34
+ let leftover = false;
35
+ if (error instanceof ApiError) {
36
+ for (const [key, message] of Object.entries(error.fields)) {
37
+ if (rendered.includes(key)) {
38
+ shown[key] = message;
39
+ } else {
40
+ leftover = true;
41
+ }
42
+ }
43
+ }
44
+ return { shown, leftover };
45
+ }