@terpjs/react-core 0.9.0 → 0.10.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 +56 -20
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +17 -4
- 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 +3 -0
- package/src/markers.test.ts +25 -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 +202 -19
- package/src/styles.test.ts +483 -24
- package/src/styles.ts +956 -85
- 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 +192 -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 +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- 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.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "The vocabulary of frontend/layout-contract.json as THIS release reads it — the standard's layout-declaration.schema.json with the per-stack value sets filled in. Published so a tool that edits an app's files can offer exactly the keys and values the app's own pinned framework will honour, rather than the ones the tool happens to know about; that inversion is the whole point, because an unknown key is refused at compose time rather than ignored. Hand-written rather than generated: every machine fact here — the keys at all three levels, the enums, the declared types, the emptiness floors, the required pair and the unknown-key refusals — mirrors a literal in layoutDeclaration.ts, themes.ts or layoutContract.ts, and layout.manifest.test.ts holds each of them to its source. The titles and descriptions are the part no generator could produce, and are held only to being present. The copy stays a copy, and the copy is checked.",
|
|
3
|
+
"title": "The app's layout declaration",
|
|
4
|
+
"description": "What this app declares once, in a file both its build-time checker and its running shell read. Every key is optional and an absent key is not a default: it is the app declining to declare, leaving whatever was already in force alone. Declaring a key here and passing the matching bootstrap option in code is refused when the router is composed.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"contract": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"minLength": 1,
|
|
11
|
+
"title": "Page contract",
|
|
12
|
+
"enum": [
|
|
13
|
+
"standard"
|
|
14
|
+
],
|
|
15
|
+
"description": "Constrain every page archetype's body to the contract's own components, checked at lint time and again on the rendered page. Leaving it unset keeps archetype-only enforcement. Adopting one on an app that has none can turn a green build red, because it starts governing screens that were legal a moment earlier."
|
|
16
|
+
},
|
|
17
|
+
"defaultTheme": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"minLength": 1,
|
|
20
|
+
"title": "Starting palette",
|
|
21
|
+
"enum": [
|
|
22
|
+
"light",
|
|
23
|
+
"dark",
|
|
24
|
+
"midnight",
|
|
25
|
+
"twilight",
|
|
26
|
+
"contrast",
|
|
27
|
+
"system"
|
|
28
|
+
],
|
|
29
|
+
"description": "The palette the app opens on, until a person picks another from the theme menu and for as long as they have not. \"system\" opens on whatever light or dark preference the viewer's own device reports."
|
|
30
|
+
},
|
|
31
|
+
"shell": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": false,
|
|
34
|
+
"title": "Shell",
|
|
35
|
+
"description": "The frame around every page: how dense its controls are, where its navigation sits, how wide its content runs, and which groups that navigation is arranged into.",
|
|
36
|
+
"properties": {
|
|
37
|
+
"density": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"title": "Density",
|
|
40
|
+
"enum": [
|
|
41
|
+
"comfortable",
|
|
42
|
+
"compact"
|
|
43
|
+
],
|
|
44
|
+
"description": "Control height and table cell padding for the whole app, from one attribute on the shell. Compact fits more rows on a screen; comfortable is easier to hit on a touch device."
|
|
45
|
+
},
|
|
46
|
+
"navPlacement": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"title": "Navigation placement",
|
|
49
|
+
"enum": [
|
|
50
|
+
"sidebar",
|
|
51
|
+
"header"
|
|
52
|
+
],
|
|
53
|
+
"description": "Where the primary navigation sits on a wide screen: a full-height rail beside the content, or a row inside the header with no rail at all. On a narrow screen both become the same drawer."
|
|
54
|
+
},
|
|
55
|
+
"contentWidth": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"title": "Content width",
|
|
58
|
+
"enum": [
|
|
59
|
+
"full",
|
|
60
|
+
"measured"
|
|
61
|
+
],
|
|
62
|
+
"description": "Whether a page's content runs the full width of the track or is capped at the shell's reading measure. Measured keeps long prose readable; full suits wide tables."
|
|
63
|
+
},
|
|
64
|
+
"brand": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"additionalProperties": false,
|
|
67
|
+
"title": "Brand mark",
|
|
68
|
+
"description": "The image your app is recognised by, shown beside its name in the header. Put the file in the app's frontend/public folder and name it here the way a browser would ask for it, starting with a slash.",
|
|
69
|
+
"properties": {
|
|
70
|
+
"logo": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"minLength": 1,
|
|
73
|
+
"title": "Mark",
|
|
74
|
+
"description": "The mark shown inside the app. It is sized to the header's own box, so a large file is scaled down rather than cut off."
|
|
75
|
+
},
|
|
76
|
+
"logoDark": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"minLength": 1,
|
|
79
|
+
"title": "Mark for dark palettes",
|
|
80
|
+
"description": "A second file for the dark palettes. Only worth setting when the first one has fixed colours that disappear on a dark background — most company marks do. Leave it empty and the same mark is used everywhere."
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"navGroups": {
|
|
85
|
+
"type": "array",
|
|
86
|
+
"title": "Navigation groups",
|
|
87
|
+
"description": "The named sections the navigation is arranged into. A module's own navigation item points at one by id; an item naming a group that is not declared here is not an error, it simply lands in a trailing section with no heading. Declared here rather than on a module, because a group spans modules and no module can own one. Two groups may not share an id: every field of the second one is legal on its own, and the app still refuses to start.",
|
|
88
|
+
"items": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"additionalProperties": false,
|
|
91
|
+
"required": [
|
|
92
|
+
"id",
|
|
93
|
+
"label"
|
|
94
|
+
],
|
|
95
|
+
"properties": {
|
|
96
|
+
"id": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"minLength": 1,
|
|
99
|
+
"title": "Id",
|
|
100
|
+
"description": "What a module's navigation item names to join this group. Never shown to anyone, and no two groups may share one."
|
|
101
|
+
},
|
|
102
|
+
"label": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"title": "Heading",
|
|
105
|
+
"description": "The heading shown above this group's links. Leave it empty for a group that shows no heading at all — a way to place items somewhere other than the end without inventing a title for them."
|
|
106
|
+
},
|
|
107
|
+
"order": {
|
|
108
|
+
"type": "integer",
|
|
109
|
+
"title": "Position",
|
|
110
|
+
"description": "Where this group sits against its siblings, lowest first. Groups that share a position keep the order they are written in, so a list that is already in the right order needs no positions at all."
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { LAYOUT_CONTRACTS } from "./layoutContract";
|
|
6
|
+
import {
|
|
7
|
+
BRAND_FIELDS,
|
|
8
|
+
NAV_GROUP_FIELDS,
|
|
9
|
+
NAV_GROUP_REQUIRED,
|
|
10
|
+
SHELL_STRUCTURED_KEYS,
|
|
11
|
+
SHELL_VALUES,
|
|
12
|
+
TOP_LEVEL_KEYS,
|
|
13
|
+
} from "./layoutDeclaration";
|
|
14
|
+
import { THEMES } from "./themes";
|
|
15
|
+
|
|
16
|
+
// The published layout vocabulary against the module that enforces it.
|
|
17
|
+
//
|
|
18
|
+
// `layout.manifest.json` exists so that a tool editing an app's files can offer exactly the keys
|
|
19
|
+
// and values THAT APP'S OWN pinned framework will honour. That inversion is the point: an unknown
|
|
20
|
+
// key is refused when the router is composed, so a tool working from its own idea of the
|
|
21
|
+
// vocabulary can hand an app a file its framework will not start on. Reading the vocabulary out
|
|
22
|
+
// of the app's own node_modules removes the guess.
|
|
23
|
+
//
|
|
24
|
+
// Which makes every drift here a live defect rather than a tidiness problem, and both directions
|
|
25
|
+
// fail differently:
|
|
26
|
+
//
|
|
27
|
+
// * A key or value the resolver reads and the manifest omits is a choice no tool will ever
|
|
28
|
+
// offer — declarable, documented, and invisible to the audience the file was published for.
|
|
29
|
+
// * A key or value the manifest offers and the resolver does not read is worse: a tool writes
|
|
30
|
+
// it in good faith and the app refuses to start, naming a key the tool was told to use.
|
|
31
|
+
//
|
|
32
|
+
// The manifest is hand-written rather than generated, for the reason `theme.themes.test.ts` gives
|
|
33
|
+
// about the theme union: generating it would need a TypeScript loader in a build step this package
|
|
34
|
+
// does not have, and half its content — the titles and descriptions an operator reads — is not
|
|
35
|
+
// derivable from the source at all. So the copy stays a copy, and the copy is checked.
|
|
36
|
+
//
|
|
37
|
+
// What "checked" covers, precisely, because an earlier version of this comment claimed more than
|
|
38
|
+
// the assertions delivered: every key at all three levels, every enum, every declared type, every
|
|
39
|
+
// emptiness floor, the required pair, the unknown-key refusals, and the export subpath. What it
|
|
40
|
+
// does NOT cover is prose — a title or description is asserted present, never compared to
|
|
41
|
+
// anything, because there is nothing to compare it to.
|
|
42
|
+
|
|
43
|
+
interface ManifestProperty {
|
|
44
|
+
type?: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
enum?: string[];
|
|
48
|
+
properties?: Record<string, ManifestProperty>;
|
|
49
|
+
items?: ManifestProperty;
|
|
50
|
+
required?: string[];
|
|
51
|
+
additionalProperties?: boolean;
|
|
52
|
+
minLength?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const manifest: ManifestProperty = JSON.parse(
|
|
56
|
+
readFileSync(new URL("./layout.manifest.json", import.meta.url), "utf-8"),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const properties = manifest.properties ?? {};
|
|
60
|
+
const shell = properties.shell?.properties ?? {};
|
|
61
|
+
const group = shell.navGroups?.items?.properties ?? {};
|
|
62
|
+
|
|
63
|
+
/** Every property in the document, by the path a refusal would name it with. */
|
|
64
|
+
function everyProperty(): [string, ManifestProperty][] {
|
|
65
|
+
return [
|
|
66
|
+
...Object.entries(properties).map(([key, value]): [string, ManifestProperty] => [key, value]),
|
|
67
|
+
...Object.entries(shell).map(([key, value]): [string, ManifestProperty] => [
|
|
68
|
+
`shell.${key}`,
|
|
69
|
+
value,
|
|
70
|
+
]),
|
|
71
|
+
...Object.entries(group).map(([key, value]): [string, ManifestProperty] => [
|
|
72
|
+
`shell.navGroups.items.${key}`,
|
|
73
|
+
value,
|
|
74
|
+
]),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("the published layout vocabulary", () => {
|
|
79
|
+
it("reads the manifest it is asserting about", () => {
|
|
80
|
+
// Every assertion below compares against something parsed out of the file. A file that
|
|
81
|
+
// failed to parse into the expected shape would compare empty objects and report green,
|
|
82
|
+
// which is the one failure mode a parity test cannot afford.
|
|
83
|
+
expect(Object.keys(properties).length).toBeGreaterThan(2);
|
|
84
|
+
expect(Object.keys(shell).length).toBeGreaterThan(2);
|
|
85
|
+
expect(Object.keys(group).length).toBeGreaterThan(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("offers exactly the keys the document admits", () => {
|
|
89
|
+
expect(Object.keys(properties).sort()).toEqual([...TOP_LEVEL_KEYS].sort());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("offers exactly the keys the shell admits", () => {
|
|
93
|
+
const declared = [...Object.keys(SHELL_VALUES), ...SHELL_STRUCTURED_KEYS];
|
|
94
|
+
expect(Object.keys(shell).sort()).toEqual(declared.sort());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("offers exactly the values each shell key accepts", () => {
|
|
98
|
+
for (const [key, values] of Object.entries(SHELL_VALUES)) {
|
|
99
|
+
expect(shell[key]?.enum, `shell.${key}`).toEqual([...values]);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("offers every palette this release ships, and no other", () => {
|
|
104
|
+
// Including "system", which is a real thing to declare rather than the absence of one — an
|
|
105
|
+
// absent key leaves whatever was in force alone, this one pins the platform preference.
|
|
106
|
+
expect(properties.defaultTheme?.enum).toEqual([...THEMES]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("offers every layout contract this release knows, and no other", () => {
|
|
110
|
+
// A contract offered here that `buildAppRouter` cannot find is an app that refuses to
|
|
111
|
+
// start with "Unknown layout contract", pointing at a value a tool was told was legal.
|
|
112
|
+
expect(properties.contract?.enum?.slice().sort()).toEqual(Object.keys(LAYOUT_CONTRACTS).sort());
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("offers exactly the fields a navigation group carries", () => {
|
|
116
|
+
expect(Object.keys(group).sort()).toEqual([...NAV_GROUP_FIELDS].sort());
|
|
117
|
+
// Against the resolver's own list, not against a copy of it written here. Comparing a
|
|
118
|
+
// mirror to a second copy of the thing it mirrors proves nothing about either.
|
|
119
|
+
expect(shell.navGroups?.items?.required?.slice().sort()).toEqual(
|
|
120
|
+
[...NAV_GROUP_REQUIRED].sort(),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("offers exactly the slots a brand declares", () => {
|
|
125
|
+
const brand = shell.brand?.properties ?? {};
|
|
126
|
+
expect(Object.keys(brand).sort()).toEqual([...BRAND_FIELDS].sort());
|
|
127
|
+
expect(shell.brand?.type).toBe("object");
|
|
128
|
+
expect(shell.brand?.additionalProperties).toBe(false);
|
|
129
|
+
// Both floored and neither required: an empty path is a mark that fails to load, while an
|
|
130
|
+
// absent one is the app saying it has no second file.
|
|
131
|
+
for (const slot of BRAND_FIELDS) {
|
|
132
|
+
expect(brand[slot]?.type, slot).toBe("string");
|
|
133
|
+
expect(brand[slot]?.minLength, slot).toBe(1);
|
|
134
|
+
}
|
|
135
|
+
expect(shell.brand?.required).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("refuses an unknown key at every level it has one", () => {
|
|
139
|
+
// The manifest describes a document whose consumer refuses unknown keys at both levels and
|
|
140
|
+
// on a group entry. A manifest that said otherwise would tell a tool it may write anything.
|
|
141
|
+
expect(manifest.additionalProperties).toBe(false);
|
|
142
|
+
expect(properties.shell?.additionalProperties).toBe(false);
|
|
143
|
+
expect(shell.navGroups?.items?.additionalProperties).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("gives every property a title and a description", () => {
|
|
147
|
+
// The half of the file no generator could produce, and the reason it is hand-written. A
|
|
148
|
+
// property with neither renders in a form as its own key and nothing else, which for
|
|
149
|
+
// `navPlacement` is a shrug and for `contract` is a choice that can turn a build red.
|
|
150
|
+
for (const [path, property] of everyProperty()) {
|
|
151
|
+
expect(property.title?.trim(), `${path}: title`).toBeTruthy();
|
|
152
|
+
expect(property.description?.trim(), `${path}: description`).toBeTruthy();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("declares a type for every property, so a renderer never has to guess", () => {
|
|
157
|
+
for (const [path, property] of everyProperty()) {
|
|
158
|
+
expect(property.type, `${path}: type`).toBeTruthy();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("declares the type the resolver actually refuses anything else for", () => {
|
|
163
|
+
// Truthiness was the whole of this check, and truthiness is not a type: with `navGroups`
|
|
164
|
+
// retyped to "object" and `order` to "string" every assertion in this file stayed green,
|
|
165
|
+
// while `resolveNavGroups` refuses a non-array and a non-integer by name. That is exactly
|
|
166
|
+
// the failure the header calls worse — a tool writes `"navGroups": {}` in good faith,
|
|
167
|
+
// reading a type the manifest offered, and the app will not compose.
|
|
168
|
+
expect(shell.navGroups?.type).toBe("array");
|
|
169
|
+
expect(shell.navGroups?.items?.type).toBe("object");
|
|
170
|
+
expect(group.order?.type).toBe("integer");
|
|
171
|
+
expect(group.id?.type).toBe("string");
|
|
172
|
+
expect(group.label?.type).toBe("string");
|
|
173
|
+
for (const key of Object.keys(SHELL_VALUES)) {
|
|
174
|
+
expect(shell[key]?.type, `shell.${key}`).toBe("string");
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("declares the emptiness floors the resolver refuses, and no others", () => {
|
|
179
|
+
// Each of these is a value the resolver rejects, so a manifest offering it without the
|
|
180
|
+
// floor tells a tool the empty string is legal. And `label` must NOT carry one: the empty
|
|
181
|
+
// label is how the document declares a group that renders no heading, and a floor there
|
|
182
|
+
// would tell a tool to refuse the one way of stating that.
|
|
183
|
+
expect(properties.contract?.minLength).toBe(1);
|
|
184
|
+
expect(properties.defaultTheme?.minLength).toBe(1);
|
|
185
|
+
expect(group.id?.minLength).toBe(1);
|
|
186
|
+
expect(group.label?.minLength).toBeUndefined();
|
|
187
|
+
expect(group.order?.minLength).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("is reachable by the subpath a consumer imports it from", () => {
|
|
191
|
+
// The file only does its job from inside an app's node_modules, and the only thing that
|
|
192
|
+
// puts it there under a stable name is the package's own export map. A rename that moved
|
|
193
|
+
// the file would leave every gate above green while the published path 404s in the one
|
|
194
|
+
// place the file is read — and this package's own suite reads it by relative URL, so
|
|
195
|
+
// nothing else here would notice.
|
|
196
|
+
const pkg: { exports: Record<string, string> } = JSON.parse(
|
|
197
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
|
|
198
|
+
);
|
|
199
|
+
const subpath = pkg.exports["./layout.manifest.json"];
|
|
200
|
+
expect(subpath).toBe("./src/layout.manifest.json");
|
|
201
|
+
expect(
|
|
202
|
+
readFileSync(new URL(`../${subpath!.slice(2)}`, import.meta.url), "utf-8").length,
|
|
203
|
+
).toBeGreaterThan(0);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/layout.test.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
-
import { DetailList, Stack } from "./layout";
|
|
5
|
+
import { DetailList, Divider, Grid, Stack } from "./layout";
|
|
6
6
|
|
|
7
7
|
afterEach(cleanup);
|
|
8
8
|
|
|
@@ -51,6 +51,77 @@ describe("Stack", () => {
|
|
|
51
51
|
expect(el.style.display).toBe("");
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it("splits a responsive prop into a narrow attribute and a wide one", () => {
|
|
55
|
+
// jsdom evaluates no media query, so the unit-level claim is the attributes and nothing
|
|
56
|
+
// more — which is the honest boundary. That the rules then apply at the right width is a
|
|
57
|
+
// browser fact, held by the `stack-responsive-*` specimen pair at 420 and 900.
|
|
58
|
+
render(
|
|
59
|
+
<Stack
|
|
60
|
+
data-testid="toolbar"
|
|
61
|
+
direction={{ narrow: "column", wide: "row" }}
|
|
62
|
+
gap={{ narrow: 2, wide: 4 }}
|
|
63
|
+
>
|
|
64
|
+
<span>a</span>
|
|
65
|
+
</Stack>,
|
|
66
|
+
);
|
|
67
|
+
const el = screen.getByTestId("toolbar");
|
|
68
|
+
expect(el).toHaveAttribute("data-direction", "column");
|
|
69
|
+
expect(el).toHaveAttribute("data-direction-wide", "row");
|
|
70
|
+
expect(el).toHaveAttribute("data-gap", "2");
|
|
71
|
+
expect(el).toHaveAttribute("data-gap-wide", "4");
|
|
72
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("stamps no wide attribute when the prop is not responsive", () => {
|
|
76
|
+
// The back-compatibility claim, asserted as absence: every Stack in every consuming app
|
|
77
|
+
// passes scalars, so it must render exactly the attributes it always did — which is why
|
|
78
|
+
// adding the feature moved no baseline anywhere.
|
|
79
|
+
render(
|
|
80
|
+
<Stack data-testid="plain" direction="row" gap={4}>
|
|
81
|
+
<span>a</span>
|
|
82
|
+
</Stack>,
|
|
83
|
+
);
|
|
84
|
+
const el = screen.getByTestId("plain");
|
|
85
|
+
expect(el).toHaveAttribute("data-direction", "row");
|
|
86
|
+
expect(el).toHaveAttribute("data-gap", "4");
|
|
87
|
+
expect(el.hasAttribute("data-direction-wide")).toBe(false);
|
|
88
|
+
expect(el.hasAttribute("data-gap-wide")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("accepts a responsive gap of 0, which a falsy check would drop", () => {
|
|
92
|
+
// `gap: 0` is a legal step and the only one that is falsy, so a `value.wide || undefined`
|
|
93
|
+
// anywhere in the split would silently render the narrow gap at every width.
|
|
94
|
+
render(
|
|
95
|
+
<Stack data-testid="zero" gap={{ narrow: 4, wide: 0 }}>
|
|
96
|
+
<span>a</span>
|
|
97
|
+
</Stack>,
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByTestId("zero")).toHaveAttribute("data-gap-wide", "0");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("names padding on the token scale, including the falsy zero step", () => {
|
|
103
|
+
// `padding={0}` is a legal step and the only falsy one, so a truthiness check anywhere in
|
|
104
|
+
// the stamp would silently drop it and inherit the container's inset instead.
|
|
105
|
+
const { rerender } = render(
|
|
106
|
+
<Stack data-testid="padded" padding={4}>
|
|
107
|
+
<span>a</span>
|
|
108
|
+
</Stack>,
|
|
109
|
+
);
|
|
110
|
+
expect(screen.getByTestId("padded")).toHaveAttribute("data-padding", "4");
|
|
111
|
+
rerender(
|
|
112
|
+
<Stack data-testid="padded" padding={0}>
|
|
113
|
+
<span>a</span>
|
|
114
|
+
</Stack>,
|
|
115
|
+
);
|
|
116
|
+
expect(screen.getByTestId("padded")).toHaveAttribute("data-padding", "0");
|
|
117
|
+
rerender(
|
|
118
|
+
<Stack data-testid="padded">
|
|
119
|
+
<span>a</span>
|
|
120
|
+
</Stack>,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByTestId("padded").hasAttribute("data-padding")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
54
125
|
it("works as a form (submit handler fires)", () => {
|
|
55
126
|
let submitted = false;
|
|
56
127
|
render(
|
|
@@ -70,7 +141,133 @@ describe("Stack", () => {
|
|
|
70
141
|
});
|
|
71
142
|
});
|
|
72
143
|
|
|
144
|
+
describe("Grid", () => {
|
|
145
|
+
it("leaves every default unstamped, because the defaults ARE the base rule", () => {
|
|
146
|
+
// auto columns, the sm track floor and stretched cells are what the base rule declares, so
|
|
147
|
+
// their attributes would describe the standard shape a second time. Same idiom as density's
|
|
148
|
+
// "comfortable" and Button's md — and asserted as absence, deliberately, because a later
|
|
149
|
+
// change that starts stamping them would leave the sheet with two accounts of one default.
|
|
150
|
+
render(
|
|
151
|
+
<Grid data-testid="grid">
|
|
152
|
+
<span>a</span>
|
|
153
|
+
</Grid>,
|
|
154
|
+
);
|
|
155
|
+
const el = screen.getByTestId("grid");
|
|
156
|
+
expect(el.tagName).toBe("DIV");
|
|
157
|
+
expect(el).toHaveAttribute("data-terp", "grid");
|
|
158
|
+
expect(el).toHaveAttribute("data-gap", "4");
|
|
159
|
+
expect(el.hasAttribute("data-columns")).toBe(false);
|
|
160
|
+
expect(el.hasAttribute("data-min-column")).toBe(false);
|
|
161
|
+
expect(el.hasAttribute("data-align")).toBe(false);
|
|
162
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("names a fixed count, the element and the gap", () => {
|
|
166
|
+
render(
|
|
167
|
+
<Grid data-testid="grid" as="ul" columns={3} gap={2}>
|
|
168
|
+
<li>a</li>
|
|
169
|
+
</Grid>,
|
|
170
|
+
);
|
|
171
|
+
const el = screen.getByTestId("grid");
|
|
172
|
+
expect(el.tagName).toBe("UL");
|
|
173
|
+
// A number prop, a text attribute: `columns={3}` has to produce "3" or the rule misses.
|
|
174
|
+
expect(el).toHaveAttribute("data-columns", "3");
|
|
175
|
+
expect(el).toHaveAttribute("data-gap", "2");
|
|
176
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("names a non-default track floor, and only while the columns are auto", () => {
|
|
180
|
+
// `minColumn` is meaningless at a fixed count — the tracks are counted, not floored — so
|
|
181
|
+
// stamping it there would put an attribute on the element that no rule reads and that a
|
|
182
|
+
// reader would take for an active choice.
|
|
183
|
+
const { rerender } = render(
|
|
184
|
+
<Grid data-testid="grid" minColumn="lg">
|
|
185
|
+
<span>a</span>
|
|
186
|
+
</Grid>,
|
|
187
|
+
);
|
|
188
|
+
expect(screen.getByTestId("grid")).toHaveAttribute("data-min-column", "lg");
|
|
189
|
+
rerender(
|
|
190
|
+
<Grid data-testid="grid" columns={2} minColumn="lg">
|
|
191
|
+
<span>a</span>
|
|
192
|
+
</Grid>,
|
|
193
|
+
);
|
|
194
|
+
const fixed = screen.getByTestId("grid");
|
|
195
|
+
expect(fixed).toHaveAttribute("data-columns", "2");
|
|
196
|
+
expect(fixed.hasAttribute("data-min-column")).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("keeps alignment an attribute, unlike Stack's, and takes no style", () => {
|
|
200
|
+
// The deliberate divergence: Stack's align is an open set of CSS keywords and stays inline
|
|
201
|
+
// (ADR 0094 §3); Grid's is a closed four, so it is an attribute and Grid renders no inline
|
|
202
|
+
// style at all. That is what keeps a new primitive out of the inline-style ledger.
|
|
203
|
+
render(
|
|
204
|
+
<Grid data-testid="grid" align="center">
|
|
205
|
+
<span>a</span>
|
|
206
|
+
</Grid>,
|
|
207
|
+
);
|
|
208
|
+
const el = screen.getByTestId("grid");
|
|
209
|
+
expect(el).toHaveAttribute("data-align", "center");
|
|
210
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("Divider", () => {
|
|
215
|
+
it("is an hr, so the separation reaches the accessibility tree", () => {
|
|
216
|
+
// A bordered div is what a module reaches for without a primitive, and it says nothing to
|
|
217
|
+
// a screen reader. The element is the point of the component.
|
|
218
|
+
render(<Divider data-testid="rule" />);
|
|
219
|
+
const el = screen.getByTestId("rule");
|
|
220
|
+
expect(el.tagName).toBe("HR");
|
|
221
|
+
expect(el).toHaveAttribute("data-terp", "divider");
|
|
222
|
+
expect(el.hasAttribute("data-orientation")).toBe(false);
|
|
223
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("announces a vertical rule as one, which hr does not imply", () => {
|
|
227
|
+
render(<Divider data-testid="rule" orientation="vertical" />);
|
|
228
|
+
const el = screen.getByTestId("rule");
|
|
229
|
+
expect(el).toHaveAttribute("data-orientation", "vertical");
|
|
230
|
+
expect(el).toHaveAttribute("aria-orientation", "vertical");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
73
234
|
describe("DetailList", () => {
|
|
235
|
+
it("leaves the inline default unstamped, and marks each row", () => {
|
|
236
|
+
// `inline` and one column are the base rule. The row marker is new and load-bearing: the
|
|
237
|
+
// aligned layout turns the wrapper into `display: contents` so the dt and dd become grid
|
|
238
|
+
// items of the dl itself, which is the only way to align labels across rows without
|
|
239
|
+
// changing the DOM — and a rule cannot reach an unmarked wrapper.
|
|
240
|
+
render(<DetailList data-testid="dl" items={[{ label: "Owner", value: "Ada" }]} />);
|
|
241
|
+
const el = screen.getByTestId("dl");
|
|
242
|
+
expect(el.hasAttribute("data-layout")).toBe(false);
|
|
243
|
+
expect(el.hasAttribute("data-columns")).toBe(false);
|
|
244
|
+
expect(el.querySelector('[data-terp="detail-list-row"]')).not.toBeNull();
|
|
245
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("names a layout and a column count", () => {
|
|
249
|
+
render(
|
|
250
|
+
<DetailList
|
|
251
|
+
data-testid="dl"
|
|
252
|
+
layout="aligned"
|
|
253
|
+
columns={2}
|
|
254
|
+
items={[{ label: "Owner", value: "Ada" }]}
|
|
255
|
+
/>,
|
|
256
|
+
);
|
|
257
|
+
const el = screen.getByTestId("dl");
|
|
258
|
+
expect(el).toHaveAttribute("data-layout", "aligned");
|
|
259
|
+
expect(el).toHaveAttribute("data-columns", "2");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("puts no colon in the markup, because two layouts must not have one", () => {
|
|
263
|
+
// The colon is a rule on the inline layout, not a text node — `aligned` and `stacked` must
|
|
264
|
+
// not carry one, and no rule can withdraw a text node. It is decorative either way: the
|
|
265
|
+
// dt/dd pairing is what carries the relationship to assistive tech, so moving it out of the
|
|
266
|
+
// markup also stops it being read aloud.
|
|
267
|
+
render(<DetailList items={[{ label: "Owner", value: "Ada" }]} />);
|
|
268
|
+
expect(screen.getByText("Owner").textContent).toBe("Owner");
|
|
269
|
+
});
|
|
270
|
+
|
|
74
271
|
it("renders label/value pairs as a definition list", () => {
|
|
75
272
|
render(
|
|
76
273
|
<DetailList
|