@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/Tabs.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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-${
|
|
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-${
|
|
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-${
|
|
118
|
+
id={`${baseId}-panel-${selectedIndex}`}
|
|
95
119
|
role="tabpanel"
|
|
96
|
-
aria-labelledby={`${baseId}-tab-${
|
|
120
|
+
aria-labelledby={`${baseId}-tab-${selectedIndex}`}
|
|
97
121
|
data-terp="tab-panel"
|
|
98
122
|
>
|
|
99
123
|
{selectedTab.content}
|
package/src/ui/Tooltip.test.tsx
CHANGED
|
@@ -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
|
});
|
package/src/ui/Tooltip.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
-
|
|
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(
|
|
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={() =>
|
|
37
|
-
|
|
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",
|