@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
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import type { NavGroup } from "@terpjs/contract";
|
|
2
|
+
|
|
3
|
+
import { THEMES } from "./themes";
|
|
4
|
+
import type { Theme } from "./themes";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The app's checked-in layout declaration — `frontend/layout-contract.json` — as the ONE
|
|
8
|
+
* source for what it declares.
|
|
9
|
+
*
|
|
10
|
+
* That file already existed and already governed something: the `terp/layout-contract` lint
|
|
11
|
+
* rule walks up from each linted file, reads `contract`, and enforces the slot table for it.
|
|
12
|
+
* The runtime half read nothing — it took a `layoutContract` string passed in TypeScript. So
|
|
13
|
+
* an app declared the same fact twice, and the template said so out loud: "keep the two in
|
|
14
|
+
* sync", with `layoutContract: "standard"` in `main.tsx` beside `{"contract": "standard"}` in
|
|
15
|
+
* the file. Delete one and the app keeps a build-time rule with no runtime check, or a runtime
|
|
16
|
+
* check no lint agrees with, and nothing says which.
|
|
17
|
+
*
|
|
18
|
+
* So the file is imported and passed in, and it carries the shell's declarations too. Three
|
|
19
|
+
* shell choices shipped in 0.10.0 as TypeScript options only — density, navigation placement
|
|
20
|
+
* and the content measure — which made them unreachable to anything that edits files rather
|
|
21
|
+
* than code. Moving them into the declaration is what lets a project's own checked-in document
|
|
22
|
+
* say how its shell is shaped, and therefore what lets a tool read and rewrite it.
|
|
23
|
+
*
|
|
24
|
+
* **Opt-in and non-exclusive** (the authoring-model rule the platform holds itself to): an app
|
|
25
|
+
* that passes no declaration behaves exactly as it did, and hand-writing every option in
|
|
26
|
+
* TypeScript stays legal and complete. What is refused is declaring one fact in both places —
|
|
27
|
+
* see {@link resolveLayoutDeclaration}, and the reason is not tidiness.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The shell's own declarations. Every key optional; an absent key changes nothing.
|
|
32
|
+
*
|
|
33
|
+
* **The value types are `string`, not the unions, and that is forced rather than sloppy.**
|
|
34
|
+
* `resolveJsonModule` types an imported JSON string as `string` — never as its literal — so
|
|
35
|
+
* `{ "shell": { "density": "compact" } }` imports as `{ shell: { density: string } }`. Declaring
|
|
36
|
+
* the unions here would make the template stop typechecking the moment an app filled the shell
|
|
37
|
+
* section in, which is the one thing this declaration is for. Measured before choosing: tsc
|
|
38
|
+
* reports "Type 'string' is not assignable to type '\"compact\" | \"comfortable\"'".
|
|
39
|
+
*
|
|
40
|
+
* So the legal values live in {@link resolveLayoutDeclaration}'s runtime check, and that check
|
|
41
|
+
* is not belt-and-braces — it is the ONLY check this key ever gets. The unions survive on
|
|
42
|
+
* {@link ResolvedLayout}, which is what the shell is handed.
|
|
43
|
+
*/
|
|
44
|
+
export interface LayoutShellDeclaration {
|
|
45
|
+
/** App-wide density (`AppShell.density`): `"comfortable"` or `"compact"`. */
|
|
46
|
+
readonly density?: string;
|
|
47
|
+
/** Navigation placement on desktop (`AppShell.navPlacement`): `"sidebar"` or `"header"`. */
|
|
48
|
+
readonly navPlacement?: string;
|
|
49
|
+
/** Content measure (`AppShell.contentWidth`): `"full"` or `"measured"`. */
|
|
50
|
+
readonly contentWidth?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The app's navigation groups, which a module's `NavItem.group` names by id.
|
|
53
|
+
*
|
|
54
|
+
* Declarable here and nowhere else in a file, for the reason groups exist at all: a group
|
|
55
|
+
* spans modules, so no module can own one — which left the app's own code as the only place
|
|
56
|
+
* one could be declared, and therefore left the order of an app's navigation out of reach of
|
|
57
|
+
* anything that edits files.
|
|
58
|
+
*
|
|
59
|
+
* Unlike the three keys above, `id` and `label` are typed as REQUIRED here, and that is not
|
|
60
|
+
* a lapse in the measured `string` rule beside it. That rule is about a value's TYPE — a JSON
|
|
61
|
+
* string never narrows to its literal, so a union would stop an app's own file typechecking.
|
|
62
|
+
* Presence is a different question, and one `resolveJsonModule` answers accurately: an
|
|
63
|
+
* imported file missing a label is a compile error naming the field, which is strictly better
|
|
64
|
+
* than the runtime refusal below. Both exist, because a declaration handed in at runtime gets
|
|
65
|
+
* only the second.
|
|
66
|
+
*/
|
|
67
|
+
readonly navGroups?: readonly LayoutNavGroupDeclaration[];
|
|
68
|
+
/**
|
|
69
|
+
* The app's own mark, as paths its static-asset directory serves.
|
|
70
|
+
*
|
|
71
|
+
* Paths rather than elements, because a file is what a tool can put somewhere and a
|
|
72
|
+
* `ReactNode` is not. The bootstrap option stays a `ReactNode` and stays legal — an app
|
|
73
|
+
* that wants an inline SVG or a component keeps writing one — and declaring the same slot
|
|
74
|
+
* both ways is refused where both halves are visible, in `buildAppRouter`.
|
|
75
|
+
*/
|
|
76
|
+
readonly brand?: LayoutBrandDeclaration;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The mark, and its dark counterpart.
|
|
81
|
+
*
|
|
82
|
+
* The counterpart is declared rather than derived: a mark with fixed colours cannot survive a
|
|
83
|
+
* dark background, and nothing here can tell whether this one can. An app with one mark
|
|
84
|
+
* declares one and keeps it everywhere, which is the right answer for a mark that does survive.
|
|
85
|
+
*/
|
|
86
|
+
export interface LayoutBrandDeclaration {
|
|
87
|
+
/** The mark inside the app's own frame; the shell sizes it, so an oversized file is scaled. */
|
|
88
|
+
readonly logo?: string;
|
|
89
|
+
/** The mark for a dark palette. The stylesheet picks, by the appearance of the palette in
|
|
90
|
+
* force rather than by the viewer's platform — an app may pin a dark palette on a light
|
|
91
|
+
* platform, and a mark chosen by the platform would then be the wrong one. */
|
|
92
|
+
readonly logoDark?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One navigation group as the file spells it.
|
|
97
|
+
*
|
|
98
|
+
* `label` is a plain `string`, not `string | null`, and the empty string is how the document
|
|
99
|
+
* says "render no label element at all". Two reasons, and the second is the real one. The
|
|
100
|
+
* standard's schema is validated by a deliberately minimal validator with no way to express
|
|
101
|
+
* "string or null". And required-with-an-empty-value keeps the property the runtime
|
|
102
|
+
* {@link NavGroup} chose `string | null` for in the first place: having no label is a decision
|
|
103
|
+
* the declaration STATES, rather than a key someone forgot. Optional-with-absent-meaning-none
|
|
104
|
+
* would have handed it straight back to omission.
|
|
105
|
+
*/
|
|
106
|
+
export interface LayoutNavGroupDeclaration {
|
|
107
|
+
/** Referenced by `NavItem.group`. Non-empty — a group with no id is one nothing can name. */
|
|
108
|
+
readonly id: string;
|
|
109
|
+
/** Rendered above the group's list; `""` declares a positioning-only group with no label. */
|
|
110
|
+
readonly label: string;
|
|
111
|
+
/** Ascending sort key against sibling groups; absent is 0 and the sort is stable. */
|
|
112
|
+
readonly order?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The contents of `frontend/layout-contract.json`.
|
|
117
|
+
*
|
|
118
|
+
* The file keeps its name. It is the file the lint rule already looks for, every scaffolded app
|
|
119
|
+
* already has one, and renaming it would break the one consumer that worked — for a nicer noun.
|
|
120
|
+
*/
|
|
121
|
+
export interface LayoutDeclaration {
|
|
122
|
+
/** The slot-typed layout contract the app opts into, e.g. `"standard"`. */
|
|
123
|
+
readonly contract?: string;
|
|
124
|
+
/**
|
|
125
|
+
* The palette the app opens on, until a person chooses another (`ThemeProvider.defaultTheme`):
|
|
126
|
+
* one of the shipped theme names, or `"system"` to follow the viewer's own platform preference.
|
|
127
|
+
*
|
|
128
|
+
* Top level rather than under `shell`, because a palette paints the frame and the page alike
|
|
129
|
+
* while `shell` is where the frame's geometry is declared — and because the standard splits
|
|
130
|
+
* the two the other way round: shell vocabulary is fixed normatively, palette names are a
|
|
131
|
+
* stack's own to publish, so this follows `contract`'s half of that split.
|
|
132
|
+
*
|
|
133
|
+
* `string` for the same measured reason every shell value is (see
|
|
134
|
+
* {@link LayoutShellDeclaration}), so the runtime check below is the only one it gets.
|
|
135
|
+
*/
|
|
136
|
+
readonly defaultTheme?: string;
|
|
137
|
+
/** The shell's shape. Grouped, because `contract` governs page bodies and this governs chrome. */
|
|
138
|
+
readonly shell?: LayoutShellDeclaration;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What the resolver hands the router: one value per key, from whichever source declared it. */
|
|
142
|
+
export interface ResolvedLayout {
|
|
143
|
+
readonly contract?: string;
|
|
144
|
+
readonly defaultTheme?: Theme;
|
|
145
|
+
readonly density?: "comfortable" | "compact";
|
|
146
|
+
readonly navPlacement?: "sidebar" | "header";
|
|
147
|
+
readonly contentWidth?: "full" | "measured";
|
|
148
|
+
readonly navGroups?: readonly NavGroup[];
|
|
149
|
+
/** The declared brand PATHS. Not merged with the `logo` option, which is a rendered
|
|
150
|
+
* element rather than a path; `buildAppRouter` refuses the two together. */
|
|
151
|
+
readonly brand?: LayoutBrandDeclaration;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The legal values per shell key, as data.
|
|
156
|
+
*
|
|
157
|
+
* One table, so the refusal message can name the alternatives and a new key cannot be added
|
|
158
|
+
* without its enum. This is the ONLY place the legal values are enforced for a declaration —
|
|
159
|
+
* the interface above cannot carry them (see LayoutShellDeclaration) — and it is also what
|
|
160
|
+
* narrows a `string` from the file to the union {@link ResolvedLayout} promises.
|
|
161
|
+
*/
|
|
162
|
+
export const SHELL_VALUES = {
|
|
163
|
+
density: ["comfortable", "compact"],
|
|
164
|
+
navPlacement: ["sidebar", "header"],
|
|
165
|
+
contentWidth: ["full", "measured"],
|
|
166
|
+
} as const;
|
|
167
|
+
|
|
168
|
+
type ShellEnumKey = keyof typeof SHELL_VALUES;
|
|
169
|
+
|
|
170
|
+
const SHELL_ENUM_KEYS = Object.keys(SHELL_VALUES) as readonly ShellEnumKey[];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Shell keys that are a shape rather than a choice between fixed values.
|
|
174
|
+
*
|
|
175
|
+
* Written out beside {@link SHELL_VALUES} rather than folded into it, because the two are read
|
|
176
|
+
* differently and by different readers: the enum table is what narrows a `string` from the file
|
|
177
|
+
* to the union {@link ResolvedLayout} promises, and this one only says the key exists. The
|
|
178
|
+
* architecture suite parses both literals to hold the whole shell vocabulary against the
|
|
179
|
+
* standard's schema, so a key added to neither is a key the standard would not know about.
|
|
180
|
+
*/
|
|
181
|
+
export const SHELL_STRUCTURED_KEYS = ["brand", "navGroups"] as const;
|
|
182
|
+
|
|
183
|
+
/** Every key `shell` admits, in the order a refusal offers them. */
|
|
184
|
+
const SHELL_KEYS: readonly string[] = [...SHELL_ENUM_KEYS, ...SHELL_STRUCTURED_KEYS];
|
|
185
|
+
|
|
186
|
+
/** Every field a declared navigation group may carry. */
|
|
187
|
+
export const NAV_GROUP_FIELDS = ["id", "label", "order"] as const;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The fields a group must carry.
|
|
191
|
+
*
|
|
192
|
+
* Named rather than written inline in the loop that reads it, for the reason the other four
|
|
193
|
+
* vocabulary literals are named: the published manifest mirrors this one too, and a mirror
|
|
194
|
+
* compared against a copy of itself in a test proves nothing.
|
|
195
|
+
*/
|
|
196
|
+
export const NAV_GROUP_REQUIRED = ["id", "label"] as const;
|
|
197
|
+
|
|
198
|
+
/** Every slot the brand declares. */
|
|
199
|
+
export const BRAND_FIELDS = ["logo", "logoDark"] as const;
|
|
200
|
+
|
|
201
|
+
/** Where a refusal points the reader. The file, not the option, because the file is the source. */
|
|
202
|
+
const FILE = "frontend/layout-contract.json";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Every key the document itself admits.
|
|
206
|
+
*
|
|
207
|
+
* Hoisted out of the refusal that reads it so that all four vocabulary literals in this module
|
|
208
|
+
* are named constants: the published `layout.manifest.json` mirrors them for tools, and both
|
|
209
|
+
* parity tests — this stack's against the standard, and the manifest's against this module —
|
|
210
|
+
* find them by name.
|
|
211
|
+
*/
|
|
212
|
+
export const TOP_LEVEL_KEYS = ["contract", "defaultTheme", "shell"] as const;
|
|
213
|
+
|
|
214
|
+
/** A JSON object — not null, not an array, not a scalar. */
|
|
215
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
216
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** What the reader actually wrote, for a message that saves them opening the file.
|
|
220
|
+
*
|
|
221
|
+
* `an object` is spelled out rather than left to `a ${typeof value}`, which reported
|
|
222
|
+
* `"navGroups": {}` as "got a object" — a typo in the message rather than a fact about the
|
|
223
|
+
* file. The array case predates this and is reached by `resolveLayoutDeclaration([])` and by
|
|
224
|
+
* `"shell": []`, neither of which involves a group; it is listed here because both are article
|
|
225
|
+
* exceptions, not because either is new. */
|
|
226
|
+
function describe(value: unknown): string {
|
|
227
|
+
if (value === null) return "null";
|
|
228
|
+
if (value === undefined) return "nothing";
|
|
229
|
+
if (Array.isArray(value)) return "an array";
|
|
230
|
+
if (typeof value === "object") return "an object";
|
|
231
|
+
return `a ${typeof value}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The declared brand, checked.
|
|
236
|
+
*
|
|
237
|
+
* Every refusal is the same kind as the enum refusal on a shell value: a path the shell would
|
|
238
|
+
* hand to an `<img>` that cannot load it, which renders as a broken mark rather than as the
|
|
239
|
+
* placeholder an app with no mark gets — worse than declaring nothing, and silent until someone
|
|
240
|
+
* looks at the header.
|
|
241
|
+
*/
|
|
242
|
+
function resolveBrand(declared: unknown): LayoutBrandDeclaration {
|
|
243
|
+
if (!isPlainObject(declared)) {
|
|
244
|
+
throw new Error(`${FILE}: shell.brand must be a JSON object, got ${describe(declared)}.`);
|
|
245
|
+
}
|
|
246
|
+
const unknown = Object.keys(declared).filter(
|
|
247
|
+
(key) => !(BRAND_FIELDS as readonly string[]).includes(key),
|
|
248
|
+
);
|
|
249
|
+
if (unknown.length > 0) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`${FILE}: shell.brand has unknown ${unknown.length === 1 ? "mark" : "marks"} ` +
|
|
252
|
+
`${unknown.map((key) => `"${key}"`).join(", ")}; a brand is ` +
|
|
253
|
+
`${BRAND_FIELDS.map((key) => `"${key}"`).join(" and ")}.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const brand: { logo?: string; logoDark?: string } = {};
|
|
257
|
+
for (const slot of BRAND_FIELDS) {
|
|
258
|
+
const value = declared[slot];
|
|
259
|
+
if (value === undefined) continue;
|
|
260
|
+
if (typeof value !== "string") {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`${FILE}: shell.brand.${slot} must be a path, got ${describe(value)}.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (value.trim() === "") {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`${FILE}: shell.brand.${slot} is empty; omit it to keep one mark for every palette.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
brand[slot] = value;
|
|
271
|
+
}
|
|
272
|
+
return brand;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A group list as a refusal names it: the ids, in order, or the fact that there are none.
|
|
276
|
+
*
|
|
277
|
+
* Each id quoted separately, like every other list this module emits. Wrapping the joined
|
|
278
|
+
* string in one pair of quotes rendered two groups as `"work, admin"`, which reads as a single
|
|
279
|
+
* group whose id contains a comma — the opposite of the reason the ids are in the message at
|
|
280
|
+
* all, which is that they are the part a reader can match up against either source by eye. */
|
|
281
|
+
function describeGroups(groups: readonly NavGroup[]): string {
|
|
282
|
+
if (groups.length === 0) return "no groups";
|
|
283
|
+
return groups.map((group) => `"${group.id}"`).join(", ");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The file's navigation groups as the runtime {@link NavGroup}s the shell is handed.
|
|
288
|
+
*
|
|
289
|
+
* Every refusal here is the same kind as the enum refusal on a shell value: a group the shell
|
|
290
|
+
* would accept and then render as nothing, or not render at all. A group with a numeric id is
|
|
291
|
+
* one no `NavItem.group` string can ever match, so its items fall into the trailing unlabelled
|
|
292
|
+
* bucket and the group never appears — a declaration that silently does nothing, which is the
|
|
293
|
+
* failure this whole document exists to remove.
|
|
294
|
+
*
|
|
295
|
+
* Duplicate ids are deliberately NOT refused here. `buildAppRouter` already refuses them, with
|
|
296
|
+
* a message this one could only restate, and routing the resolved list through that check means
|
|
297
|
+
* one refusal covers a group declared in the file and a group passed as an option alike.
|
|
298
|
+
*/
|
|
299
|
+
function resolveNavGroups(declared: unknown): readonly NavGroup[] {
|
|
300
|
+
if (!Array.isArray(declared)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`${FILE}: shell.navGroups must be an array, got ${describe(declared)}.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return declared.map((entry: unknown, index: number): NavGroup => {
|
|
306
|
+
const at = `shell.navGroups[${index}]`;
|
|
307
|
+
if (!isPlainObject(entry)) {
|
|
308
|
+
throw new Error(`${FILE}: ${at} must be a JSON object, got ${describe(entry)}.`);
|
|
309
|
+
}
|
|
310
|
+
const unknown = Object.keys(entry).filter(
|
|
311
|
+
(key) => !(NAV_GROUP_FIELDS as readonly string[]).includes(key),
|
|
312
|
+
);
|
|
313
|
+
if (unknown.length > 0) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`${FILE}: ${at} has unknown ${unknown.length === 1 ? "field" : "fields"} ` +
|
|
316
|
+
`${unknown.map((key) => `"${key}"`).join(", ")}; a group is ` +
|
|
317
|
+
`${NAV_GROUP_FIELDS.map((key) => `"${key}"`).join(", ")}.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
// A missing required field is reported as missing, not as a type. "must be a string, got
|
|
321
|
+
// nothing" sends the reader looking for a value they never wrote; the schema requires both
|
|
322
|
+
// fields, so the message should say which one is absent.
|
|
323
|
+
for (const field of NAV_GROUP_REQUIRED) {
|
|
324
|
+
if (entry[field] === undefined) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`${FILE}: ${at} is missing "${field}"; every group declares ` +
|
|
327
|
+
`${NAV_GROUP_REQUIRED.map((name) => `"${name}"`).join(" and ")}` +
|
|
328
|
+
(field === "label" ? ', and "" is a group that renders no label at all.' : "."),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (typeof entry.id !== "string") {
|
|
333
|
+
throw new Error(`${FILE}: ${at}.id must be a string, got ${describe(entry.id)}.`);
|
|
334
|
+
}
|
|
335
|
+
if (entry.id === "") {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`${FILE}: ${at}.id is the empty string; a group with no id is one no navigation ` +
|
|
338
|
+
"item can name.",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (typeof entry.label !== "string") {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`${FILE}: ${at}.label must be a string, got ${describe(entry.label)}; use "" for a ` +
|
|
344
|
+
"group that renders no label at all.",
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
// The one line where the document's spelling and the runtime's meet: `""` is how a file
|
|
348
|
+
// says "render no label element", and `null` is how NavGroup says the same thing.
|
|
349
|
+
const group: NavGroup = { id: entry.id, label: entry.label === "" ? null : entry.label };
|
|
350
|
+
if (entry.order === undefined) {
|
|
351
|
+
return group;
|
|
352
|
+
}
|
|
353
|
+
// Split from the guard above rather than folded into one condition, because a compound
|
|
354
|
+
// negation over `unknown` does not narrow: `order` stayed `{} | null` and the assignment
|
|
355
|
+
// below did not compile. Two guards narrow it to `number` with no cast.
|
|
356
|
+
if (typeof entry.order !== "number" || !Number.isInteger(entry.order)) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`${FILE}: ${at}.order must be a whole number, got ` +
|
|
359
|
+
`${typeof entry.order === "number" ? entry.order : describe(entry.order)}; it is a ` +
|
|
360
|
+
"sort key against sibling groups, and absent means 0.",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return { ...group, order: entry.order };
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Resolve the declaration against the options an app passed in TypeScript.
|
|
369
|
+
*
|
|
370
|
+
* Returns *explicit* untouched when there is no declaration, so an app that passes none is on
|
|
371
|
+
* the exact path it was on before this existed.
|
|
372
|
+
*
|
|
373
|
+
* Three things are refused, each fail-closed at compose time rather than absorbed:
|
|
374
|
+
*
|
|
375
|
+
* 1. **A value outside its enum, or a malformed navigation group.** A JSON file is not
|
|
376
|
+
* typechecked, so `"density": "compakt"` would otherwise reach the shell as an attribute
|
|
377
|
+
* value nothing styles, and a group with a numeric id would reach it as a group no item can
|
|
378
|
+
* name — declarations that silently do nothing, which is the failure this whole declaration
|
|
379
|
+
* exists to remove.
|
|
380
|
+
* 2. **A key the framework does not know.** Same reasoning one level up: a hand-written or
|
|
381
|
+
* newer-framework key would sit in the file looking effective. Refusing names it instead.
|
|
382
|
+
* This is the cost of the choice and it is the right way round — an app pinned to a release
|
|
383
|
+
* is told the release cannot honour a key, rather than being told nothing.
|
|
384
|
+
* 3. **One fact declared twice.** If the file says `"density": "compact"` and the app also
|
|
385
|
+
* passes `density: "comfortable"`, one of them loses silently. Whichever way that
|
|
386
|
+
* precedence went, a person editing the losing source would watch their change do nothing —
|
|
387
|
+
* and once a file is what tools edit, the tool becomes the one making a change that does
|
|
388
|
+
* nothing. So it is an authoring error, named with both sources.
|
|
389
|
+
*/
|
|
390
|
+
export function resolveLayoutDeclaration(
|
|
391
|
+
declaration: LayoutDeclaration | undefined,
|
|
392
|
+
explicit: ResolvedLayout,
|
|
393
|
+
): ResolvedLayout {
|
|
394
|
+
if (declaration === undefined) {
|
|
395
|
+
return explicit;
|
|
396
|
+
}
|
|
397
|
+
// The shape first, because this comes from a FILE and a file can hold anything JSON can.
|
|
398
|
+
// Every branch below was reached by probing rather than imagined: `null` threw a bare
|
|
399
|
+
// "Cannot convert undefined or null to object" with no mention of which file; a string or an
|
|
400
|
+
// array had its character/element indices reported as unknown KEYS; and an array was
|
|
401
|
+
// accepted outright — `Object.keys([])` is empty, so `[]` declared nothing and returned
|
|
402
|
+
// silently, which is the exact failure this module exists to prevent, in this module.
|
|
403
|
+
if (!isPlainObject(declaration)) {
|
|
404
|
+
throw new Error(`${FILE}: expected a JSON object, got ${describe(declaration)}.`);
|
|
405
|
+
}
|
|
406
|
+
const known = new Set<string>(TOP_LEVEL_KEYS);
|
|
407
|
+
const unknownTop = Object.keys(declaration).filter((key) => !known.has(key));
|
|
408
|
+
if (unknownTop.length > 0) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`${FILE}: unknown ${unknownTop.length === 1 ? "key" : "keys"} ` +
|
|
411
|
+
`${unknownTop.map((key) => `"${key}"`).join(", ")}; this release reads ` +
|
|
412
|
+
// Comma-joined like the shell message beside it. It read "a and b" while there were
|
|
413
|
+
// exactly two keys, which does not survive a third: "a and b and c".
|
|
414
|
+
`${[...known].map((key) => `"${key}"`).join(", ")}.`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (declaration.contract !== undefined && typeof declaration.contract !== "string") {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`${FILE}: "contract" must be a string, got ${describe(declaration.contract)}.`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if (declaration.defaultTheme !== undefined && typeof declaration.defaultTheme !== "string") {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`${FILE}: "defaultTheme" must be a string, got ${describe(declaration.defaultTheme)}.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
// Checked BEFORE defaulting, not after: `?? {}` swallows `null`, so `"shell": null` would
|
|
429
|
+
// have been read as "declared nothing" while `"shell": []` was refused — and the standard's
|
|
430
|
+
// own schema types this as an object, so it rejects null. Two consumers disagreeing about one
|
|
431
|
+
// document is the thing the declaration exists to stop.
|
|
432
|
+
if (declaration.shell !== undefined && !isPlainObject(declaration.shell)) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`${FILE}: "shell" must be a JSON object, got ${describe(declaration.shell)}.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const shell = declaration.shell ?? {};
|
|
438
|
+
const unknownShell = Object.keys(shell).filter((key) => !SHELL_KEYS.includes(key));
|
|
439
|
+
if (unknownShell.length > 0) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`${FILE}: unknown shell ${unknownShell.length === 1 ? "key" : "keys"} ` +
|
|
442
|
+
`${unknownShell.map((key) => `"${key}"`).join(", ")}; this release reads ` +
|
|
443
|
+
`${SHELL_KEYS.map((key) => `"${key}"`).join(", ")}.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const resolved: Record<string, unknown> = { ...explicit };
|
|
448
|
+
const conflicts: string[] = [];
|
|
449
|
+
|
|
450
|
+
if (declaration.contract !== undefined) {
|
|
451
|
+
if (explicit.contract !== undefined) {
|
|
452
|
+
conflicts.push(`"contract" (file: "${declaration.contract}", code: "${explicit.contract}")`);
|
|
453
|
+
}
|
|
454
|
+
resolved.contract = declaration.contract;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (declaration.defaultTheme !== undefined) {
|
|
458
|
+
// The enum this key gets, and the list is imported rather than restated: `THEMES` is the
|
|
459
|
+
// same array the theme control offers and the provider validates a stored choice against,
|
|
460
|
+
// held to the compiled stylesheet by `theme.themes.test.ts`. A copy here would be a third
|
|
461
|
+
// place a shipped palette could go missing from.
|
|
462
|
+
//
|
|
463
|
+
// Refused rather than fallen back to a palette that does exist, which the standard states
|
|
464
|
+
// outright: a fallback is how a declaration ends up doing nothing while looking like it
|
|
465
|
+
// works — `data-theme="midnite"` matches no block, so the app renders the base palette and
|
|
466
|
+
// nothing anywhere says the file was ignored.
|
|
467
|
+
if (!(THEMES as readonly string[]).includes(declaration.defaultTheme)) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`${FILE}: "defaultTheme" is "${declaration.defaultTheme}"; expected one of ` +
|
|
470
|
+
`${THEMES.map((theme) => `"${theme}"`).join(", ")}.`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
if (explicit.defaultTheme !== undefined) {
|
|
474
|
+
conflicts.push(
|
|
475
|
+
`"defaultTheme" (file: "${declaration.defaultTheme}", code: "${explicit.defaultTheme}")`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
resolved.defaultTheme = declaration.defaultTheme;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const declaredBrand = shell.brand;
|
|
482
|
+
if (declaredBrand !== undefined) {
|
|
483
|
+
resolved.brand = resolveBrand(declaredBrand);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const declaredGroups = shell.navGroups;
|
|
487
|
+
if (declaredGroups !== undefined) {
|
|
488
|
+
const groups = resolveNavGroups(declaredGroups);
|
|
489
|
+
if (explicit.navGroups !== undefined) {
|
|
490
|
+
// Not the value, which is a list of objects nobody wants stringified into an error, but
|
|
491
|
+
// the ids — the one part of a group a reader can match up against either source by eye.
|
|
492
|
+
conflicts.push(
|
|
493
|
+
`"shell.navGroups" (file: ${describeGroups(groups)}, ` +
|
|
494
|
+
`code: ${describeGroups(explicit.navGroups)})`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
resolved.navGroups = groups;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
for (const key of SHELL_ENUM_KEYS) {
|
|
501
|
+
const declared = shell[key];
|
|
502
|
+
if (declared === undefined) continue;
|
|
503
|
+
// A file can put a number or an object here. Refused with the type named rather than
|
|
504
|
+
// stringified into the enum message, where `shell.density is "7"` would read as a typo in
|
|
505
|
+
// a string the author never wrote.
|
|
506
|
+
if (typeof declared !== "string") {
|
|
507
|
+
throw new Error(`${FILE}: shell.${key} must be a string, got ${describe(declared)}.`);
|
|
508
|
+
}
|
|
509
|
+
const legal = SHELL_VALUES[key] as readonly string[];
|
|
510
|
+
if (!legal.includes(declared)) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`${FILE}: shell.${key} is "${declared}"; expected one of ` +
|
|
513
|
+
`${legal.map((value) => `"${value}"`).join(", ")}.`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const fromCode = explicit[key];
|
|
517
|
+
if (fromCode !== undefined) {
|
|
518
|
+
conflicts.push(`"shell.${key}" (file: "${declared}", code: "${fromCode}")`);
|
|
519
|
+
}
|
|
520
|
+
resolved[key] = declared;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (conflicts.length > 0) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`${FILE} and the bootstrap options both declare ${conflicts.join("; ")}. ` +
|
|
526
|
+
"Declare each in one place: the file is what a tool can read and rewrite, so " +
|
|
527
|
+
"prefer it and drop the option.",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
return resolved as ResolvedLayout;
|
|
531
|
+
}
|
package/src/locale.tsx
CHANGED
|
@@ -29,6 +29,15 @@ export const LOCALE_EN: LocaleCatalog = { label: "English" };
|
|
|
29
29
|
export const LOCALE_NL: LocaleCatalog = {
|
|
30
30
|
label: "Nederlands",
|
|
31
31
|
strings: {
|
|
32
|
+
clearSelection: "Selectie wissen",
|
|
33
|
+
clearAllSelections: "Alle selecties wissen",
|
|
34
|
+
comboboxRemove: "Verwijderen",
|
|
35
|
+
comboboxLoading: "Laden…",
|
|
36
|
+
comboboxNoOptions: "Geen opties",
|
|
37
|
+
previousMonth: "Vorige maand",
|
|
38
|
+
selectDate: "Kies een datum",
|
|
39
|
+
selectDateRange: "Kies een periode",
|
|
40
|
+
nextMonth: "Volgende maand",
|
|
32
41
|
loading: "Laden...",
|
|
33
42
|
emptyList: "Nog niets te zien.",
|
|
34
43
|
add: "Toevoegen",
|
|
@@ -37,6 +46,8 @@ export const LOCALE_NL: LocaleCatalog = {
|
|
|
37
46
|
signingIn: "Bezig met inloggen…",
|
|
38
47
|
email: "E-mailadres",
|
|
39
48
|
password: "Wachtwoord",
|
|
49
|
+
showPassword: "Wachtwoord tonen",
|
|
50
|
+
hidePassword: "Wachtwoord verbergen",
|
|
40
51
|
signInFailed: "Inloggen mislukt. Controleer je gegevens.",
|
|
41
52
|
fillDevCredentials: "Vul dev-inloggegevens in",
|
|
42
53
|
continueWith: "Doorgaan met",
|
|
@@ -60,6 +71,7 @@ export const LOCALE_NL: LocaleCatalog = {
|
|
|
60
71
|
role: "Rol",
|
|
61
72
|
home: "Start",
|
|
62
73
|
primaryNavigationLabel: "Hoofdnavigatie",
|
|
74
|
+
skipToContent: "Naar de inhoud",
|
|
63
75
|
collapseSidebar: "Zijbalk inklappen",
|
|
64
76
|
expandSidebar: "Zijbalk uitklappen",
|
|
65
77
|
openNavigation: "Navigatie openen",
|
package/src/markers.test.ts
CHANGED
|
@@ -40,7 +40,6 @@ const STYLESHEET = "./styles.ts";
|
|
|
40
40
|
*/
|
|
41
41
|
const MARKERS = [
|
|
42
42
|
"admin-form",
|
|
43
|
-
"admin-payload",
|
|
44
43
|
"admin-section-title",
|
|
45
44
|
"alert",
|
|
46
45
|
"alert-body",
|
|
@@ -56,10 +55,15 @@ const MARKERS = [
|
|
|
56
55
|
"appshell-header",
|
|
57
56
|
"appshell-header-group",
|
|
58
57
|
"appshell-main",
|
|
58
|
+
"appshell-mark",
|
|
59
59
|
"appshell-nav",
|
|
60
|
+
"appshell-nav-group",
|
|
61
|
+
"appshell-nav-group-label",
|
|
60
62
|
"appshell-nav-label",
|
|
61
63
|
"appshell-nav-list",
|
|
62
64
|
"appshell-sidebar",
|
|
65
|
+
"appshell-skip-link",
|
|
66
|
+
"avatar",
|
|
63
67
|
"badge",
|
|
64
68
|
"breadcrumbs",
|
|
65
69
|
"breadcrumbs-current",
|
|
@@ -80,11 +84,15 @@ const MARKERS = [
|
|
|
80
84
|
"card-heading",
|
|
81
85
|
"card-title",
|
|
82
86
|
"checkbox",
|
|
87
|
+
"code",
|
|
88
|
+
"code-block",
|
|
83
89
|
"combobox",
|
|
84
90
|
"combobox-empty",
|
|
85
91
|
"combobox-field",
|
|
86
92
|
"combobox-list",
|
|
87
93
|
"combobox-option",
|
|
94
|
+
"combobox-token",
|
|
95
|
+
"combobox-token-remove",
|
|
88
96
|
"control-label",
|
|
89
97
|
"dataview",
|
|
90
98
|
"dataview-actions-cell",
|
|
@@ -124,6 +132,7 @@ const MARKERS = [
|
|
|
124
132
|
"dataview-toolbar-spacer",
|
|
125
133
|
"dataview-toolbar-status",
|
|
126
134
|
"detail-list",
|
|
135
|
+
"detail-list-row",
|
|
127
136
|
"detail-list-term",
|
|
128
137
|
"detail-list-value",
|
|
129
138
|
"dialog",
|
|
@@ -131,6 +140,7 @@ const MARKERS = [
|
|
|
131
140
|
"dialog-body",
|
|
132
141
|
"dialog-description",
|
|
133
142
|
"dialog-title",
|
|
143
|
+
"divider",
|
|
134
144
|
"drawer-focus-end",
|
|
135
145
|
"drawer-focus-start",
|
|
136
146
|
"empty-state",
|
|
@@ -146,6 +156,8 @@ const MARKERS = [
|
|
|
146
156
|
"field-hint",
|
|
147
157
|
"field-label",
|
|
148
158
|
"field-label-text",
|
|
159
|
+
"grid",
|
|
160
|
+
"heading",
|
|
149
161
|
"hubcard",
|
|
150
162
|
"hubcard-body",
|
|
151
163
|
"hubcard-description",
|
|
@@ -158,8 +170,10 @@ const MARKERS = [
|
|
|
158
170
|
"icon",
|
|
159
171
|
"iconbutton",
|
|
160
172
|
"input",
|
|
173
|
+
"input-password",
|
|
161
174
|
"language-switcher",
|
|
162
175
|
"language-switcher-label",
|
|
176
|
+
"link",
|
|
163
177
|
"loading-state",
|
|
164
178
|
"loading-state-spinner",
|
|
165
179
|
"login-brand",
|
|
@@ -190,7 +204,6 @@ const MARKERS = [
|
|
|
190
204
|
"page-title",
|
|
191
205
|
"popover",
|
|
192
206
|
"popover-panel",
|
|
193
|
-
"profile-avatar",
|
|
194
207
|
"profile-card",
|
|
195
208
|
"profile-email",
|
|
196
209
|
"profile-role",
|
|
@@ -205,12 +218,15 @@ const MARKERS = [
|
|
|
205
218
|
"resource-list-items",
|
|
206
219
|
"resource-list-row",
|
|
207
220
|
"spinner-ring",
|
|
221
|
+
"splitpage-panes",
|
|
222
|
+
"splitpane",
|
|
208
223
|
"stack",
|
|
209
224
|
"switch",
|
|
210
225
|
"tab",
|
|
211
226
|
"tab-list",
|
|
212
227
|
"tab-panel",
|
|
213
228
|
"tabs",
|
|
229
|
+
"text",
|
|
214
230
|
"theme-toggle",
|
|
215
231
|
"theme-toggle-label",
|
|
216
232
|
"toast",
|
|
@@ -221,7 +237,6 @@ const MARKERS = [
|
|
|
221
237
|
"tooltip",
|
|
222
238
|
"tooltip-anchor",
|
|
223
239
|
"user-menu",
|
|
224
|
-
"user-menu-avatar",
|
|
225
240
|
"user-menu-email",
|
|
226
241
|
"user-menu-header",
|
|
227
242
|
"user-menu-identity",
|
|
@@ -549,8 +564,15 @@ describe("data-terp markers", () => {
|
|
|
549
564
|
// property, and nothing asserted it — a `sideEffects: false` added for bundle size, plus
|
|
550
565
|
// tree-shaking, would remove it silently and the first symptom would be Markdown's blocks
|
|
551
566
|
// collapsing into one grid item. So the property itself is what this pins.
|
|
552
|
-
|
|
553
|
-
|
|
567
|
+
// One CODE entry point, which is the property. Data subpaths are allowed and do not
|
|
568
|
+
// weaken it: `./layout.manifest.json` is JSON a tool reads out of node_modules, no module
|
|
569
|
+
// imports it, and importing JSON loads no JavaScript at all — so there is still exactly one
|
|
570
|
+
// way into this package's modules. Asserted by extension rather than by key count, so that
|
|
571
|
+
// publishing a second entry point is what fails and publishing another data file is not.
|
|
572
|
+
const codeEntries = Object.entries(manifest.exports).filter(([, target]) =>
|
|
573
|
+
/\.(ts|tsx|js|mjs|cjs)$/.test(target),
|
|
574
|
+
);
|
|
575
|
+
expect(codeEntries).toEqual([[".", "./src/index.ts"]]);
|
|
554
576
|
expect(
|
|
555
577
|
"sideEffects" in manifest,
|
|
556
578
|
"declaring sideEffects would let a bundler drop the modules that inject the stylesheet",
|