@terpjs/react-core 0.10.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 +1 -1
- package/package.json +2 -2
- package/src/AppShell.test.tsx +9 -4
- package/src/AppShell.tsx +17 -3
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +30 -18
- package/src/locale.tsx +9 -0
- package/src/markers.test.ts +2 -0
- package/src/router.tsx +11 -1
- package/src/styles.test.ts +52 -34
- package/src/styles.ts +175 -27
- package/src/tokens.guard.test.ts +49 -2
- package/src/ui/Combobox.test.tsx +90 -0
- package/src/ui/Combobox.tsx +247 -41
- package/src/ui/DatePicker.tsx +16 -7
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +14 -0
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +27 -0
|
@@ -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
|
@@ -210,9 +210,36 @@ export interface TerpStrings {
|
|
|
210
210
|
saved: string;
|
|
211
211
|
/** Generic failure toast when a request did not go through. */
|
|
212
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;
|
|
213
231
|
}
|
|
214
232
|
|
|
215
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",
|
|
216
243
|
loading: "Loading...",
|
|
217
244
|
emptyList: "Nothing here yet.",
|
|
218
245
|
add: "Add",
|