@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
@@ -27,3 +27,31 @@ describe("Tabs", () => {
27
27
  expect(onChange).toHaveBeenCalledWith("audit");
28
28
  });
29
29
  });
30
+
31
+ describe("Tabs with a single tab", () => {
32
+ it("renders the content bare, with no tablist to choose from", () => {
33
+ // A tab set over one tab costs a row of the screen to offer nothing, and to a screen
34
+ // reader it is worse than decorative: it announces "tab 1 of 1" and the only
35
+ // affordance is already selected.
36
+ render(<Tabs label="Sections" tabs={[{ value: "only", label: "Only", content: "Body" }]} />);
37
+ expect(screen.queryByRole("tablist")).toBeNull();
38
+ expect(screen.queryByRole("tab")).toBeNull();
39
+ // The panel goes too: a tabpanel exists to be labelled by the tab that reveals it, and
40
+ // there is nothing left to reveal.
41
+ expect(screen.queryByRole("tabpanel")).toBeNull();
42
+ expect(screen.getByText("Body")).toBeInTheDocument();
43
+ });
44
+
45
+ it("keeps the chrome when that single tab is disabled", () => {
46
+ // Here the tab set carries real information — this section exists and is unavailable —
47
+ // and rendering its content bare would show what the caller marked unreachable.
48
+ render(
49
+ <Tabs
50
+ label="Sections"
51
+ tabs={[{ value: "only", label: "Only", content: "Body", disabled: true }]}
52
+ />,
53
+ );
54
+ expect(screen.getByRole("tablist")).toBeInTheDocument();
55
+ expect(screen.getByRole("tab", { name: "Only" })).toBeDisabled();
56
+ });
57
+ });
package/src/ui/Tabs.tsx CHANGED
@@ -31,6 +31,26 @@ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps)
31
31
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? firstValue);
32
32
  const selectedValue = value ?? uncontrolledValue;
33
33
  const selectedTab = tabs.find((tab) => tab.value === selectedValue) ?? enabledTabs[0] ?? tabs[0];
34
+ // Element ids are built from the tab's INDEX, never from its `value`. A value is caller data
35
+ // and an id is an IDREF: a value containing whitespace turns `aria-labelledby` into a list of
36
+ // two tokens, neither of which resolves, and the tabpanel silently loses its accessible name.
37
+ // Nothing reports that — axe sees a well-formed reference to nothing. The same refusal to
38
+ // interpolate caller strings into ids is written out at AppShell's nav-group labels.
39
+ const selectedIndex = tabs.findIndex((tab) => tab.value === selectedTab?.value);
40
+
41
+ // One usable tab is not a choice, so it gets no chrome. A tablist over a single tab costs
42
+ // a row of the screen to offer nothing, and it is worse than decorative to a screen
43
+ // reader: the set announces "tab 1 of 1" and the only affordance is already selected.
44
+ // Rendering the content bare also drops the tabpanel, which is correct rather than
45
+ // convenient — a panel exists to be labelled by the tab that reveals it, and there is
46
+ // no revealing left to do.
47
+ //
48
+ // A single DISABLED tab keeps the chrome, deliberately: there the tab set is carrying
49
+ // real information (this section exists and is unavailable), and silently rendering its
50
+ // content would show what the caller marked unreachable.
51
+ if (tabs.length === 1 && !tabs[0]?.disabled) {
52
+ return <>{tabs[0]?.content}</>;
53
+ }
34
54
 
35
55
  function select(next: string) {
36
56
  if (value === undefined) {
@@ -62,24 +82,28 @@ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps)
62
82
  const next = enabledTabs[nextIndex];
63
83
  if (next) {
64
84
  select(next.value);
65
- document.getElementById(`${baseId}-tab-${next.value}`)?.focus();
85
+ // Looked up by the tab's position in `tabs`, matching the id it was rendered with.
86
+ // `enabledTabs` is a filtered view, so its index is not the rendered one.
87
+ document
88
+ .getElementById(`${baseId}-tab-${tabs.findIndex((tab) => tab.value === next.value)}`)
89
+ ?.focus();
66
90
  }
67
91
  }
68
92
 
69
93
  return (
70
94
  <div data-terp="tabs">
71
95
  <div role="tablist" data-terp="tab-list" aria-label={label === undefined ? undefined : resolve(label)} onKeyDown={onKeyDown}>
72
- {tabs.map((tab) => {
96
+ {tabs.map((tab, index) => {
73
97
  const selected = tab.value === selectedTab?.value;
74
98
  return (
75
99
  <button
76
100
  key={tab.value}
77
- id={`${baseId}-tab-${tab.value}`}
101
+ id={`${baseId}-tab-${index}`}
78
102
  type="button"
79
103
  role="tab"
80
104
  data-terp="tab"
81
105
  aria-selected={selected}
82
- aria-controls={`${baseId}-panel-${tab.value}`}
106
+ aria-controls={`${baseId}-panel-${index}`}
83
107
  tabIndex={selected ? 0 : -1}
84
108
  disabled={tab.disabled}
85
109
  onClick={() => select(tab.value)}
@@ -91,9 +115,9 @@ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps)
91
115
  </div>
92
116
  {selectedTab && (
93
117
  <div
94
- id={`${baseId}-panel-${selectedTab.value}`}
118
+ id={`${baseId}-panel-${selectedIndex}`}
95
119
  role="tabpanel"
96
- aria-labelledby={`${baseId}-tab-${selectedTab.value}`}
120
+ aria-labelledby={`${baseId}-tab-${selectedIndex}`}
97
121
  data-terp="tab-panel"
98
122
  >
99
123
  {selectedTab.content}
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment jsdom
2
- import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { vi } from "vitest";
3
4
  import { afterEach, describe, expect, it } from "vitest";
4
5
 
5
6
  import { Button } from "./Button";
@@ -25,4 +26,58 @@ describe("Tooltip", () => {
25
26
  fireEvent.mouseEnter(trigger.parentElement!);
26
27
  expect(tooltip).toBeVisible();
27
28
  });
29
+
30
+ it("dismisses on Escape without moving the pointer or focus", () => {
31
+ // WCAG 1.4.13, Dismissible. There was no key handler of any kind, so a bubble covering the
32
+ // content under it could only be escaped by moving away from the control the user was
33
+ // reading about. Bound on the document, because the pointer-opened case has no focus
34
+ // anywhere near this component and a trigger-bound handler would never see the key.
35
+ // Mutation: delete the keydown effect.
36
+ render(
37
+ <Tooltip content="More information" defaultOpen>
38
+ <Button>Help</Button>
39
+ </Tooltip>,
40
+ );
41
+ const tooltip = screen.getByRole("tooltip");
42
+ expect(tooltip).toBeVisible();
43
+ fireEvent.keyDown(document, { key: "Escape" });
44
+ expect(tooltip).not.toBeVisible();
45
+ });
46
+
47
+ it("stays open while the pointer crosses to the bubble", () => {
48
+ // WCAG 1.4.13, Hoverable. The bubble used to declare pointer-events: none, which makes
49
+ // reaching it impossible by construction; that is gone, and the close is delayed so the
50
+ // visual gap between trigger and bubble can be crossed. Re-entering cancels the close.
51
+ // Mutation: close synchronously on mouseleave, and this fails.
52
+ vi.useFakeTimers();
53
+ try {
54
+ render(
55
+ <Tooltip content="More information" defaultOpen>
56
+ <Button>Help</Button>
57
+ </Tooltip>,
58
+ );
59
+ const tooltip = screen.getByRole("tooltip");
60
+ const anchor = tooltip.parentElement!;
61
+ fireEvent.mouseLeave(anchor);
62
+ // Still open partway through the grace period...
63
+ act(() => {
64
+ vi.advanceTimersByTime(60);
65
+ });
66
+ expect(tooltip).toBeVisible();
67
+ // ...and re-entering cancels the close entirely.
68
+ fireEvent.mouseEnter(anchor);
69
+ act(() => {
70
+ vi.advanceTimersByTime(500);
71
+ });
72
+ expect(tooltip).toBeVisible();
73
+ // Leaving and staying away does close it.
74
+ fireEvent.mouseLeave(anchor);
75
+ act(() => {
76
+ vi.advanceTimersByTime(500);
77
+ });
78
+ expect(tooltip).not.toBeVisible();
79
+ } finally {
80
+ vi.useRealTimers();
81
+ }
82
+ });
28
83
  });
@@ -1,4 +1,4 @@
1
- import { cloneElement, isValidElement, useId, useState } from "react";
1
+ import { cloneElement, isValidElement, useEffect, useId, useRef, useState } from "react";
2
2
  import type { FocusEvent, MouseEvent, ReactElement } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
@@ -10,6 +10,16 @@ injectTerpStyles();
10
10
  export interface TooltipProps {
11
11
  content: UiText;
12
12
  children: ReactElement;
13
+ /**
14
+ * Start with the bubble shown.
15
+ *
16
+ * The same dev/specimen affordance `AppShell.defaultCollapsed` and `defaultDrawerOpen` are,
17
+ * added for the same reason: the panel's whole style block — its surface, its ink, its shadow
18
+ * and its measure — was painted by nothing. The one Tooltip specimen renders the trigger with
19
+ * the bubble closed, and neither browser lane can hover or focus, so a change to any of those
20
+ * declarations moved no pixel that any gate reads. An app has no reason to pin a tooltip open.
21
+ */
22
+ defaultOpen?: boolean;
13
23
  }
14
24
 
15
25
  interface TriggerHandlers {
@@ -20,11 +30,60 @@ interface TriggerHandlers {
20
30
  "aria-describedby"?: string;
21
31
  }
22
32
 
23
- /** Accessible focus/hover tooltip. */
24
- export function Tooltip({ content, children }: TooltipProps) {
33
+ /**
34
+ * Accessible focus/hover tooltip.
35
+ *
36
+ * Holds all three parts of WCAG 1.4.13 (Content on Hover or Focus, level AA), and two of them
37
+ * had to be added:
38
+ *
39
+ * - **Dismissible.** Escape closes the bubble without moving the pointer or focus. There was no
40
+ * key handler of any kind before, so a tooltip covering the content beneath it could only be
41
+ * escaped by moving away from the control the user was reading about.
42
+ * - **Hoverable.** The bubble is reachable with the pointer. It used to declare
43
+ * `pointer-events: none`, which makes hovering it impossible by construction — so a tooltip
44
+ * long enough to need reading could not be read by anyone tracking with a pointer or using
45
+ * magnification. The bubble is a DOM child of the anchor, so moving onto it does not fire the
46
+ * anchor's `mouseleave`; the close delay below covers the visual gap between the two, which
47
+ * the pointer does cross.
48
+ * - **Persistent.** It stays until dismissed, focus leaves or the pointer leaves — it has never
49
+ * had a timeout.
50
+ */
51
+ export function Tooltip({ content, children, defaultOpen = false }: TooltipProps) {
25
52
  const id = useId();
26
53
  const resolve = useUiText();
27
- const [open, setOpen] = useState(false);
54
+ const [open, setOpen] = useState(defaultOpen);
55
+ // Cleared on re-entry, which is what makes the gap between trigger and bubble crossable.
56
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
57
+
58
+ function cancelClose() {
59
+ if (closeTimer.current !== null) {
60
+ clearTimeout(closeTimer.current);
61
+ closeTimer.current = null;
62
+ }
63
+ }
64
+
65
+ function scheduleClose() {
66
+ cancelClose();
67
+ closeTimer.current = setTimeout(() => setOpen(false), 120);
68
+ }
69
+
70
+ useEffect(() => cancelClose, []);
71
+
72
+ useEffect(() => {
73
+ if (!open) {
74
+ return;
75
+ }
76
+ // On the document rather than the trigger: the pointer-opened case has no focus anywhere
77
+ // near this component, so a handler bound to the trigger would never see the key. The same
78
+ // placement Popover uses, for the same reason.
79
+ function onKeyDown(event: KeyboardEvent) {
80
+ if (event.key === "Escape") {
81
+ setOpen(false);
82
+ }
83
+ }
84
+ document.addEventListener("keydown", onKeyDown);
85
+ return () => document.removeEventListener("keydown", onKeyDown);
86
+ }, [open]);
28
87
 
29
88
  if (!isValidElement<TriggerHandlers>(children)) {
30
89
  return children;
@@ -33,13 +92,17 @@ export function Tooltip({ content, children }: TooltipProps) {
33
92
  return (
34
93
  <span
35
94
  data-terp="tooltip-anchor"
36
- onMouseEnter={() => setOpen(true)}
37
- onMouseLeave={() => setOpen(false)}
95
+ onMouseEnter={() => {
96
+ cancelClose();
97
+ setOpen(true);
98
+ }}
99
+ onMouseLeave={scheduleClose}
38
100
  >
39
101
  {cloneElement(children, {
40
102
  "aria-describedby": id,
41
103
  onFocus: (event: FocusEvent) => {
42
104
  children.props.onFocus?.(event);
105
+ cancelClose();
43
106
  setOpen(true);
44
107
  },
45
108
  onBlur: (event: FocusEvent) => {
@@ -0,0 +1,199 @@
1
+ // The hole the completeness guard cannot see.
2
+ //
3
+ // `locale.test.tsx` already asserts that every framework string is translated in every
4
+ // shipped locale, and it passes at 100%. It is only as wide as the string TABLE: a
5
+ // hardcoded `aria-label="Clear selection"` never becomes a `TerpStrings` key, so it is
6
+ // invisible to a check that walks keys — and "every framework string is translated" and
7
+ // "this control cannot be translated at all" were both true at the same time.
8
+ //
9
+ // This walks the SOURCE instead, so the two guards together cover the round trip: a
10
+ // user-facing string has to reach the table, and everything in the table has to be
11
+ // translated. Reported as "translations are not always present", which is exactly how it
12
+ // looks from an app — most of the chrome localises, a few controls stubbornly do not, and
13
+ // no gate anywhere goes red.
14
+ //
15
+ // Uses Vite's raw glob rather than an fs walk, the way the other scanning tests here do:
16
+ // this package's tsconfig declares no Node types on purpose.
17
+
18
+ import { describe, expect, it } from "vitest";
19
+
20
+ const sources = import.meta.glob("./**/*.{ts,tsx}", {
21
+ query: "?raw",
22
+ import: "default",
23
+ eager: true,
24
+ }) as Record<string, string>;
25
+
26
+ /** Attributes whose value a user reads or hears. */
27
+ const USER_FACING_ATTRIBUTES = ["aria-label", "placeholder", "title", "alt", "aria-description"];
28
+
29
+ /**
30
+ * Files where a bare literal is not a translation defect.
31
+ *
32
+ * Deliberately tiny, and each entry says why it is not one. This is NOT a migration
33
+ * baseline: the framework's chrome is already routed through `TerpStrings`, so there is no
34
+ * debt to ratchet down, and a growing list here would mean the opposite of what this test
35
+ * is for.
36
+ */
37
+ const ALLOWED: Record<string, string> = {
38
+ "./uiText.tsx": "the string table itself — these ARE the source-language defaults",
39
+ "./locale.tsx": "the shipped catalogues — every value here is a translation",
40
+ "./styles.ts":
41
+ "CSS in a template literal, so a match is an attribute SELECTOR " +
42
+ '(data-placeholder="true"), never a string a user reads',
43
+ };
44
+
45
+ /**
46
+ * A line that only *documents* code.
47
+ *
48
+ * These checks forbid a shape, and the clearest way to document a forbidden shape is to
49
+ * write it down — so the prose explaining the fix contains the defect verbatim. The first
50
+ * version of the widened default check duly reported DatePicker's own comment, which says
51
+ * `placeholder = "Select date"` while the code beside it does the right thing. Skipping
52
+ * comment-only lines is the fix; a literal inside a trailing comment on a line of real code
53
+ * is still matched, which is the rarer shape and the one worth a false positive.
54
+ */
55
+ function isComment(line: string): boolean {
56
+ const trimmed = line.trimStart();
57
+ return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
58
+ }
59
+
60
+ function scannable(): [string, string][] {
61
+ return Object.entries(sources).filter(
62
+ ([file]) => !/\.(test|spec)\.tsx?$/.test(file) && ALLOWED[file] === undefined,
63
+ );
64
+ }
65
+
66
+ describe("user-facing strings", () => {
67
+ it("never hardcodes an attribute a user reads", () => {
68
+ const offenders: string[] = [];
69
+
70
+ for (const [file, source] of scannable()) {
71
+ source.split("\n").forEach((line, index) => {
72
+ if (line.includes("i18n-ok") || isComment(line)) {
73
+ return;
74
+ }
75
+ for (const attribute of USER_FACING_ATTRIBUTES) {
76
+ // A double-quoted value starting with a letter — a literal a human reads. A
77
+ // `{expression}` value is not matched, and that is the compliant shape: it
78
+ // resolves a `UiText` or reads a `TerpStrings` key.
79
+ const match = new RegExp(`${attribute}="([A-Za-z][^"]*)"`).exec(line);
80
+ if (match !== null) {
81
+ offenders.push(`${file}:${index + 1} ${attribute}="${match[1]}"`);
82
+ }
83
+ }
84
+ });
85
+ }
86
+
87
+ expect(
88
+ offenders,
89
+ "these strings are rendered to a user and cannot be translated: they never enter " +
90
+ "TerpStrings, so locale.test.tsx's completeness guard passes over them and an app " +
91
+ "has no way to override them. Add a TerpStrings key (with its translations) and " +
92
+ "read it through useStrings() — or, for a caller-supplied string, take a UiText " +
93
+ "prop and resolve it with useUiText(). A genuinely non-linguistic value (a test id, " +
94
+ "a token name) takes an `i18n-ok` comment on the line.",
95
+ ).toEqual([]);
96
+ });
97
+
98
+ it("never hides a literal inside a braced attribute value", () => {
99
+ // The shape the check above is blind to by construction. Its comment claimed a
100
+ // `{expression}` value "is the compliant shape: it resolves a UiText or reads a
101
+ // TerpStrings key" — true of most, and not of a ternary, a `??` fallback or a default,
102
+ // any of which carries the untranslatable literal straight through the braces. Not
103
+ // hypothetical: `aria-label={multiple ? "Clear all selections" : "Clear selection"}`
104
+ // shipped while this very file was being written to forbid it, and the two met in a
105
+ // merge. Treating braces as proof of compliance makes the fix for one control the
106
+ // loophole for the next.
107
+ //
108
+ // Same line only, which is a stated limit rather than a claim: the shapes that carry a
109
+ // literal (ternary, fallback, default) fit on one line under this repo's formatting.
110
+ const offenders: string[] = [];
111
+
112
+ for (const [file, source] of scannable()) {
113
+ source.split("\n").forEach((line, index) => {
114
+ if (line.includes("i18n-ok") || isComment(line)) {
115
+ return;
116
+ }
117
+ for (const attribute of USER_FACING_ATTRIBUTES) {
118
+ // The name must stand alone: `data-placeholder={…}` is not `placeholder`, and
119
+ // matching it as a substring reported a data attribute's `"true"` as prose.
120
+ const opener = new RegExp(`(?<![\w-])${attribute}=\{`, "g");
121
+ for (const opened of line.matchAll(opener)) {
122
+ // Read to the MATCHING brace. Reading to end-of-line swept up whatever attribute
123
+ // came next — `data-terp="breadcrumbs"` on the same element was reported as a
124
+ // user-facing literal.
125
+ const start = opened.index + opened[0].length;
126
+ let cursor = start;
127
+ let depth = 1;
128
+ while (cursor < line.length && depth > 0) {
129
+ if (line[cursor] === "{") depth += 1;
130
+ else if (line[cursor] === "}") depth -= 1;
131
+ if (depth === 0) break;
132
+ cursor += 1;
133
+ }
134
+ for (const [literal, inner] of line.slice(start, cursor).matchAll(/"([A-Za-z][^"]*)"/g)) {
135
+ // Inside an expression most literals are not prose: a discriminant
136
+ // (`kind === "role"`), a placeholder token, a data value. Unlike the direct
137
+ // `attribute="…"` position — where a literal is user-facing almost by
138
+ // definition — this one asks whether the string LOOKS like a label.
139
+ //
140
+ // A heuristic, stated as one: a lowercase single-word label
141
+ // (`aria-label={open ? "close" : "open"}`) slips through. Every user-facing
142
+ // string this framework ships is a capitalised phrase, so that shape does not
143
+ // exist here; if one lands, it wants the direct check's discipline rather than
144
+ // a wider net here, which would flag every discriminant in the package.
145
+ if (/^[A-Z]/.test(inner!) || inner!.includes(" ")) {
146
+ offenders.push(`${file}:${index + 1} ${attribute}={… ${literal} …}`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ });
152
+ }
153
+
154
+ expect(
155
+ offenders,
156
+ "a user-facing attribute resolves an expression, and the expression still contains a " +
157
+ "hardcoded English string — most often a ternary picking between two literals, or a " +
158
+ "`??` fallback behind a translatable prop. Braces are not evidence of anything: move " +
159
+ "every branch to a TerpStrings key and read them through useStrings().",
160
+ ).toEqual([]);
161
+ });
162
+
163
+ it("never defaults a UiText prop to a bare string", () => {
164
+ // The subtler half, and the one that looks fine in review. A `UiText` prop defaulted to
165
+ // `"Select date"` IS overridable — and still untranslatable: a plain string resolves
166
+ // as-is, so an app that does not pass the prop shows English in every locale. The fix is
167
+ // not a descriptor default either; it is to fall back to a TerpStrings key at the use
168
+ // site, so the app's own catalogue answers when the caller says nothing.
169
+ const offenders: string[] = [];
170
+
171
+ for (const [file, source] of scannable()) {
172
+ const uiTextProps = new Set(
173
+ [...source.matchAll(/^\s*(\w+)\??:\s*UiText[;\s|]/gm)].map((match) => match[1]!),
174
+ );
175
+ source.split("\n").forEach((line, index) => {
176
+ if (line.includes("i18n-ok") || isComment(line)) {
177
+ return;
178
+ }
179
+ // Anywhere on the line, NOT anchored to own it. Anchoring made this depend on
180
+ // formatting: `removeLabel = "Remove"` inside a one-line destructuring
181
+ // (`const { value, onChange, removeLabel = "Remove", ...rest } = props`) was
182
+ // invisible, and became visible only when the line was split for unrelated reasons.
183
+ // A check that a reformat can switch on and off is not a check.
184
+ for (const assignment of line.matchAll(/(\w+)\s*=\s*"([A-Za-z][^"]*)"/g)) {
185
+ if (uiTextProps.has(assignment[1]!)) {
186
+ offenders.push(`${file}:${index + 1} ${assignment[1]} = "${assignment[2]}"`);
187
+ }
188
+ }
189
+ });
190
+ }
191
+
192
+ expect(
193
+ offenders,
194
+ "a UiText prop defaulted to a plain string renders that string in every locale for any " +
195
+ "app that does not override it — the prop is translatable and its default is not. " +
196
+ "Leave the default `undefined` and fall back to a TerpStrings key at the use site.",
197
+ ).toEqual([]);
198
+ });
199
+ });
package/src/uiText.tsx CHANGED
@@ -40,6 +40,10 @@ export interface TerpStrings {
40
40
  email: string;
41
41
  /** Login password placeholder. */
42
42
  password: string;
43
+ /** Password reveal toggle, while the value is hidden. */
44
+ showPassword: string;
45
+ /** Password reveal toggle, while the value is visible. */
46
+ hidePassword: string;
43
47
  /** Login failure message. */
44
48
  signInFailed: string;
45
49
  /** Label of the dev-only button that fills the seeded development credentials. */
@@ -86,6 +90,8 @@ export interface TerpStrings {
86
90
  home: string;
87
91
  /** Accessible name of the sidebar `nav` landmark. */
88
92
  primaryNavigationLabel: string;
93
+ /** The shell's skip link — the first thing a keyboard reaches, jumping past the chrome. */
94
+ skipToContent: string;
89
95
  /** Accessible label of the header toggle when it collapses the expanded sidebar. */
90
96
  collapseSidebar: string;
91
97
  /** Accessible label of the header toggle when it expands the collapsed sidebar. */
@@ -204,9 +210,36 @@ export interface TerpStrings {
204
210
  saved: string;
205
211
  /** Generic failure toast when a request did not go through. */
206
212
  requestFailed: string;
213
+ /** Combobox: clears the single selection. */
214
+ clearSelection: string;
215
+ /** Combobox: clears every selection in multiple mode. */
216
+ clearAllSelections: string;
217
+ /** Combobox: removes one chosen option in multiple mode, prefixed to its label. */
218
+ comboboxRemove: string;
219
+ /** Combobox: shown in the listbox while options are being fetched. */
220
+ comboboxLoading: string;
221
+ /** Combobox: shown in the listbox when the filter matches nothing. */
222
+ comboboxNoOptions: string;
223
+ /** DatePicker: steps the calendar back one month. */
224
+ previousMonth: string;
225
+ /** DatePicker: steps the calendar forward one month. */
226
+ nextMonth: string;
227
+ /** DatePicker: trigger text before a date is chosen. */
228
+ selectDate: string;
229
+ /** DateRangePicker: trigger text before a range is chosen. */
230
+ selectDateRange: string;
207
231
  }
208
232
 
209
233
  export const DEFAULT_STRINGS: TerpStrings = {
234
+ clearSelection: "Clear selection",
235
+ clearAllSelections: "Clear all selections",
236
+ comboboxRemove: "Remove",
237
+ comboboxLoading: "Loading…",
238
+ comboboxNoOptions: "No options",
239
+ previousMonth: "Previous month",
240
+ selectDate: "Select date",
241
+ selectDateRange: "Select date range",
242
+ nextMonth: "Next month",
210
243
  loading: "Loading...",
211
244
  emptyList: "Nothing here yet.",
212
245
  add: "Add",
@@ -215,6 +248,8 @@ export const DEFAULT_STRINGS: TerpStrings = {
215
248
  signingIn: "Signing in…",
216
249
  email: "Email",
217
250
  password: "Password",
251
+ showPassword: "Show password",
252
+ hidePassword: "Hide password",
218
253
  signInFailed: "Sign-in failed. Check your credentials.",
219
254
  fillDevCredentials: "Fill dev credentials",
220
255
  continueWith: "Continue with",
@@ -238,6 +273,7 @@ export const DEFAULT_STRINGS: TerpStrings = {
238
273
  role: "Role",
239
274
  home: "Home",
240
275
  primaryNavigationLabel: "Primary",
276
+ skipToContent: "Skip to content",
241
277
  collapseSidebar: "Collapse sidebar",
242
278
  expandSidebar: "Expand sidebar",
243
279
  openNavigation: "Open navigation",