@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
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
70
70
|
## Page archetypes (the three-level screen pattern)
|
|
71
71
|
|
|
72
72
|
Every routed view **must** render one of the archetypes (`Page`, or `OverviewPage` /
|
|
73
|
-
`DetailPage` / `HubPage`, which compose it) — `buildAppRouter` refuses an unframed view at
|
|
73
|
+
`DetailPage` / `HubPage` / `FormPage` / `SettingsPage` / `SplitPage`, which compose it) — `buildAppRouter` refuses an unframed view at
|
|
74
74
|
runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/error frame.
|
|
75
75
|
|
|
76
76
|
| Export | Use |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
|
|
6
6
|
"exports": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@tanstack/react-router": "^1.170.16",
|
|
17
|
-
"@terpjs/contract": "^0.
|
|
17
|
+
"@terpjs/contract": "^0.11.0"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"react": "^19.0.0",
|
package/src/AppShell.test.tsx
CHANGED
|
@@ -43,15 +43,20 @@ function stubMobileViewport() {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
describe("AppShell", () => {
|
|
46
|
-
it("renders the landmarks, brand,
|
|
46
|
+
it("renders the landmarks, brand, and the nav via renderLink — and NO footer", () => {
|
|
47
47
|
renderShell();
|
|
48
48
|
|
|
49
49
|
expect(screen.getByRole("banner")).toBeInTheDocument();
|
|
50
50
|
expect(screen.getByRole("navigation", { name: "Primary" })).toBeInTheDocument();
|
|
51
51
|
expect(screen.getByRole("main")).toBeInTheDocument();
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
52
|
+
// No `contentinfo` unless the app asks for one. The default used to be a strip
|
|
53
|
+
// restating the app title already in the header and the browser tab — on every screen
|
|
54
|
+
// of every app, costing vertical space on exactly the viewports with least of it. An
|
|
55
|
+
// empty landmark is worse than none: it is somewhere a screen-reader user can navigate
|
|
56
|
+
// to and find nothing.
|
|
57
|
+
expect(screen.queryByRole("contentinfo")).toBeNull();
|
|
58
|
+
// The brand is the standard home affordance.
|
|
59
|
+
expect(screen.getAllByText("Terp").length).toBeGreaterThanOrEqual(1);
|
|
55
60
|
expect(screen.getByRole("link", { name: "Terp" })).toHaveAttribute("href", "/");
|
|
56
61
|
expect(screen.getByRole("link", { name: "Notes" })).toHaveAttribute("href", "/notes");
|
|
57
62
|
expect(screen.getByText("page content")).toBeInTheDocument();
|
package/src/AppShell.tsx
CHANGED
|
@@ -125,7 +125,11 @@ interface AppShellBaseProps {
|
|
|
125
125
|
density?: "comfortable" | "compact";
|
|
126
126
|
/** Pinned to the bottom of the sidebar (the {@link UserMenu}); may read the rail state. */
|
|
127
127
|
navFooter?: ReactNode | ((context: AppShellSlotContext) => ReactNode);
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Footer content under the routed view. **Omit it and no footer renders** — there is no
|
|
130
|
+
* default, because the default was a strip restating the app title already in the header
|
|
131
|
+
* and the browser tab, on every screen of every app, and nobody chose it.
|
|
132
|
+
*/
|
|
129
133
|
footer?: ReactNode;
|
|
130
134
|
/**
|
|
131
135
|
* The current URL path, so the shell can decide which nav item is current.
|
|
@@ -298,7 +302,7 @@ function PanelIcon() {
|
|
|
298
302
|
* nav and the same user menu render in the header, and the drawer still handles mobile;
|
|
299
303
|
* - a **sticky** header over the content: the sidebar toggle on the left, then
|
|
300
304
|
* `headerActions` and the standard theme + language controls on the right;
|
|
301
|
-
* - the routed `children` in a `main` landmark, with
|
|
305
|
+
* - the routed `children` in a `main` landmark, with an optional `footer` underneath.
|
|
302
306
|
*
|
|
303
307
|
* Router-agnostic: `renderLink` wraps the shell-styled icon + label in the active
|
|
304
308
|
* stack's link. Landmarks (`header` / `nav` / `main` / `footer`) keep it accessible.
|
|
@@ -688,7 +692,17 @@ export function AppShell({
|
|
|
688
692
|
<main id={mainId} data-terp="appshell-main" tabIndex={-1}>
|
|
689
693
|
{children}
|
|
690
694
|
</main>
|
|
691
|
-
|
|
695
|
+
{/* Rendered only when the app asks for one. It used to default to the app's own
|
|
696
|
+
title, which meant every screen in every app carried a footer restating the
|
|
697
|
+
name already in the header and the browser tab — a permanent strip of chrome
|
|
698
|
+
nobody chose, costing vertical space on exactly the small viewports that have
|
|
699
|
+
least of it. `footer` is now the switch: pass content to get a footer, pass
|
|
700
|
+
nothing to get none. The landmark goes with it, which is correct — an empty
|
|
701
|
+
`contentinfo` is a landmark a screen-reader user can navigate to and find
|
|
702
|
+
nothing in. */}
|
|
703
|
+
{footer !== undefined && (
|
|
704
|
+
<footer data-terp="appshell-footer">{footer}</footer>
|
|
705
|
+
)}
|
|
692
706
|
</div>
|
|
693
707
|
</div>
|
|
694
708
|
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { EmptyState } from "./EmptyState";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("EmptyState size", () => {
|
|
10
|
+
it("stamps no attribute at the default size", () => {
|
|
11
|
+
// The full-page block's geometry IS the base rule, so the default matches no
|
|
12
|
+
// attribute selector — the same shape Button's sizes and the shell's density take.
|
|
13
|
+
render(<EmptyState title="Nothing yet" />);
|
|
14
|
+
expect(screen.getByText("Nothing yet").closest("[data-terp='empty-state']")).not.toHaveAttribute(
|
|
15
|
+
"data-size",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("stamps compact, and keeps the frame and the wording", () => {
|
|
20
|
+
// Two default blocks stacked on one screen were 480px of chrome repeating a sentence:
|
|
21
|
+
// the emptiness of one section is not the page's headline. Compact takes the space back
|
|
22
|
+
// without changing what the block says or that it is recognisably an empty state.
|
|
23
|
+
render(
|
|
24
|
+
<EmptyState size="compact" title="No connections" description="Add one to begin." />,
|
|
25
|
+
);
|
|
26
|
+
const block = screen.getByText("No connections").closest("[data-terp='empty-state']");
|
|
27
|
+
expect(block).toHaveAttribute("data-size", "compact");
|
|
28
|
+
expect(screen.getByText("Add one to begin.")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/EmptyState.tsx
CHANGED
|
@@ -19,6 +19,17 @@ export interface EmptyStateProps {
|
|
|
19
19
|
description?: ReactNode;
|
|
20
20
|
/** Optional call to action (typically a `Button`). */
|
|
21
21
|
action?: ReactNode;
|
|
22
|
+
/**
|
|
23
|
+
* `"compact"` for an empty block that is not the whole screen.
|
|
24
|
+
*
|
|
25
|
+
* The default is sized to be the only thing on a page — generous padding, a 2rem
|
|
26
|
+
* glyph, centred. That is right for an empty list and wrong the moment a screen has two
|
|
27
|
+
* of them: stacked, they were 480px of chrome repeating a sentence, and the emptiness of
|
|
28
|
+
* one section is not the page's headline. Compact keeps the frame and the wording and
|
|
29
|
+
* takes back the space — tighter padding, a smaller glyph, left-aligned, because a
|
|
30
|
+
* block that is one item among several reads as a row rather than a poster.
|
|
31
|
+
*/
|
|
32
|
+
size?: "default" | "compact";
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
/**
|
|
@@ -27,15 +38,24 @@ export interface EmptyStateProps {
|
|
|
27
38
|
* UX platform-wide tells the user "this is not an error — there is just nothing to show",
|
|
28
39
|
* and the `action` slot turns the dead end into the obvious next step.
|
|
29
40
|
*/
|
|
30
|
-
export function EmptyState({
|
|
41
|
+
export function EmptyState({
|
|
42
|
+
icon,
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
action,
|
|
46
|
+
size = "default",
|
|
47
|
+
}: EmptyStateProps) {
|
|
31
48
|
const resolve = useUiText();
|
|
49
|
+
const compact = size === "compact";
|
|
32
50
|
const leading = icon ?? (
|
|
33
51
|
<span data-terp="empty-state-icon">
|
|
34
|
-
<Icon name="inbox" size="2rem" />
|
|
52
|
+
<Icon name="inbox" size={compact ? "1.25rem" : "2rem"} />
|
|
35
53
|
</span>
|
|
36
54
|
);
|
|
55
|
+
// Stamped only for `compact`: the full-page block's geometry IS the base rule, the same
|
|
56
|
+
// shape `Button`'s sizes and the shell's density take.
|
|
37
57
|
return (
|
|
38
|
-
<div data-terp="empty-state">
|
|
58
|
+
<div data-terp="empty-state" data-size={compact ? "compact" : undefined}>
|
|
39
59
|
{leading}
|
|
40
60
|
<p data-terp="empty-state-title">{resolve(title)}</p>
|
|
41
61
|
{description !== undefined && <div data-terp="empty-state-description">{description}</div>}
|
package/src/LoginView.test.tsx
CHANGED
|
@@ -51,7 +51,39 @@ describe("LoginView dev credentials", () => {
|
|
|
51
51
|
</TerpProvider>,
|
|
52
52
|
);
|
|
53
53
|
fireEvent.click(await screen.findByRole("button", { name: "Fill dev credentials" }));
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// By label, not by placeholder. Reaching for a placeholder here was itself a symptom:
|
|
55
|
+
// it was the only handle these inputs had.
|
|
56
|
+
expect(screen.getByLabelText("Email")).toHaveValue("admin@example.test");
|
|
57
|
+
expect(screen.getByLabelText("Password")).toHaveValue("correct horse battery staple");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("LoginView accessible names", () => {
|
|
62
|
+
it("labels both credentials so they survive typing and can be addressed by name", async () => {
|
|
63
|
+
// The first screen of every Terp app, and it was labelled by placeholder alone. A
|
|
64
|
+
// placeholder is not an accessible name and it disappears the moment someone types, so
|
|
65
|
+
// the field a user is halfway through filling had nothing identifying it (WCAG 3.3.2)
|
|
66
|
+
// — and `getByLabel` could not find either input, which made the one screen every app
|
|
67
|
+
// ships the one screen its own tests could not address by name.
|
|
68
|
+
stubFetch();
|
|
69
|
+
render(
|
|
70
|
+
<TerpProvider baseUrl="https://api.test">
|
|
71
|
+
<LoginView />
|
|
72
|
+
</TerpProvider>,
|
|
73
|
+
);
|
|
74
|
+
await screen.findByRole("heading", { name: "Sign in" });
|
|
75
|
+
|
|
76
|
+
const email = screen.getByLabelText("Email");
|
|
77
|
+
const password = screen.getByLabelText("Password");
|
|
78
|
+
expect(email).toHaveAttribute("type", "email");
|
|
79
|
+
expect(password).toHaveAttribute("type", "password");
|
|
80
|
+
|
|
81
|
+
// The name has to survive typing, which is the whole difference from a placeholder.
|
|
82
|
+
fireEvent.change(email, { target: { value: "someone@example.test" } });
|
|
83
|
+
expect(screen.getByLabelText("Email")).toHaveValue("someone@example.test");
|
|
84
|
+
|
|
85
|
+
// And the autocomplete tokens stay, so a password manager still offers to fill.
|
|
86
|
+
expect(email).toHaveAttribute("autocomplete", "username");
|
|
87
|
+
expect(password).toHaveAttribute("autocomplete", "current-password");
|
|
56
88
|
});
|
|
57
89
|
});
|
package/src/LoginView.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { TerpMark } from "./icons";
|
|
|
5
5
|
import { useAuth, useSso } from "./TerpProvider";
|
|
6
6
|
import { useStrings } from "./uiText";
|
|
7
7
|
import { Button } from "./ui/Button";
|
|
8
|
+
import { Field } from "./Field";
|
|
8
9
|
import { Input } from "./ui/Input";
|
|
9
10
|
import type { SsoProvider } from "./sso";
|
|
10
11
|
|
|
@@ -92,24 +93,35 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
92
93
|
<h1 data-terp="login-title">{strings.signIn}</h1>
|
|
93
94
|
</div>
|
|
94
95
|
<form data-terp="login-form" onSubmit={onSubmit}>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
96
|
+
{/* Labelled through `Field`, not by placeholder. A placeholder is not an
|
|
97
|
+
accessible name and it disappears the moment someone types, so the field a
|
|
98
|
+
user is halfway through filling has nothing identifying it — WCAG 3.3.2 asks
|
|
99
|
+
for a label that survives typing. It also made these two inputs unreachable
|
|
100
|
+
by `getByLabel`, so every app's very first screen was the one place its own
|
|
101
|
+
tests could not address by name. The framework's own answer was already here:
|
|
102
|
+
`Field` wraps the control in a `<label>`, which is why it needs no id wiring.
|
|
103
|
+
The comment below is left because it dates the omission — an autocomplete
|
|
104
|
+
token was considered for these fields and a label was not. */}
|
|
105
|
+
<Field label={strings.email}>
|
|
106
|
+
<Input
|
|
107
|
+
type="email"
|
|
108
|
+
// Neither field declared an autocomplete token, so no password manager offered to
|
|
109
|
+
// fill or save this form — the one place in the framework where that matters most.
|
|
110
|
+
autoComplete="username"
|
|
111
|
+
value={email}
|
|
112
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
113
|
+
required
|
|
114
|
+
/>
|
|
115
|
+
</Field>
|
|
116
|
+
<Field label={strings.password}>
|
|
117
|
+
<Input
|
|
118
|
+
type="password"
|
|
119
|
+
autoComplete="current-password"
|
|
120
|
+
value={password}
|
|
121
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
122
|
+
required
|
|
123
|
+
/>
|
|
124
|
+
</Field>
|
|
113
125
|
<Button type="submit" fullWidth loading={busy}>
|
|
114
126
|
{busy ? strings.signingIn : strings.signIn}
|
|
115
127
|
</Button>
|
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",
|
package/src/markers.test.ts
CHANGED
package/src/router.tsx
CHANGED
|
@@ -461,8 +461,18 @@ export function buildAppRouter(
|
|
|
461
461
|
// render it through useNavLink), so an unstable identity is worse than a re-render: it
|
|
462
462
|
// remounts every in-app link in the tree on each navigation.
|
|
463
463
|
const renderNavLink = useCallback<NavLinkRenderer>(
|
|
464
|
+
// `activeOptions={{ exact: true }}` for the same reason the shell's `renderLink`
|
|
465
|
+
// below carries it, and it matters MORE here: this renderer is what `Breadcrumbs`
|
|
466
|
+
// and `HubCard` use, so its job is rendering ANCESTORS. Under the router's default
|
|
467
|
+
// prefix matching every ancestor link matched the URL and was marked active, which
|
|
468
|
+
// on any detail route emitted a second `aria-current="page"` — one on the crumb for
|
|
469
|
+
// `/definitions` and one on the current crumb — plus a stray `.active` class and
|
|
470
|
+
// `data-status="active"` that made an ancestor look like the current page. The fix
|
|
471
|
+
// was applied to the nav and missed on the component whose whole purpose is the
|
|
472
|
+
// trail; exact matching means a crumb is only ever current when it IS the URL, and
|
|
473
|
+
// the current crumb is a span rather than a link.
|
|
464
474
|
({ to, children, attributes }) => (
|
|
465
|
-
<Link to={to} {...attributes}>
|
|
475
|
+
<Link to={to} activeOptions={{ exact: true }} {...attributes}>
|
|
466
476
|
{children}
|
|
467
477
|
</Link>
|
|
468
478
|
),
|
package/src/styles.test.ts
CHANGED
|
@@ -366,20 +366,30 @@ describe("cascade structure", () => {
|
|
|
366
366
|
).toContain("height: 100%");
|
|
367
367
|
});
|
|
368
368
|
|
|
369
|
-
it("puts the DataView's surface on the full variant, not on the
|
|
369
|
+
it("puts the DataView's surface on the full variant's TABLE, not on the root or the marker", () => {
|
|
370
370
|
// Both values of data-variant are stamped and only one has a rule, which is what keeps
|
|
371
371
|
// the embedded variant a bare grid. The alternative — surface on the marker, un-declared
|
|
372
372
|
// under [data-variant="embedded"] — needs background: transparent, border: 0 and
|
|
373
373
|
// border-radius: 0, the un-declaring shape ADR 0094 exists to avoid.
|
|
374
374
|
//
|
|
375
375
|
// dataview-embedded is the negative evidence this rests on: deleting the full-variant
|
|
376
|
-
// rule must move
|
|
376
|
+
// rule must move the full-variant baselines and leave that one untouched.
|
|
377
|
+
//
|
|
378
|
+
// The OWNER changed and the guarantee did not. The surface used to sit on the root, which
|
|
379
|
+
// made one card of the toolbar, the table and the pagination, divided by internal borders
|
|
380
|
+
// — and left the table's cells flush against the outer frame. It now sits on whatever
|
|
381
|
+
// occupies the table's slot, so the table is the object and the controls above and below
|
|
382
|
+
// it float on the page. Still keyed on the variant, for the reason above.
|
|
377
383
|
const base = layerBody("terp.base");
|
|
378
384
|
expect(declaresRuleFor(base, '[data-terp="dataview"]'), "the root needs a display").toBe(true);
|
|
379
385
|
expect(
|
|
380
|
-
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
|
|
381
|
-
"the surface belongs to the full variant",
|
|
386
|
+
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"]'),
|
|
387
|
+
"the surface belongs to the full variant's table slot",
|
|
382
388
|
).toBe(true);
|
|
389
|
+
expect(
|
|
390
|
+
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
|
|
391
|
+
"the root must NOT carry a surface — that is the card this change removed",
|
|
392
|
+
).toBe(false);
|
|
383
393
|
expect(
|
|
384
394
|
base,
|
|
385
395
|
"un-declaring a surface under the embedded variant is the shape ADR 0094 avoids",
|
|
@@ -402,38 +412,30 @@ describe("cascade structure", () => {
|
|
|
402
412
|
);
|
|
403
413
|
});
|
|
404
414
|
|
|
405
|
-
it("keeps the toolbar
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// neutral-0 rather than leaving it to the host. Against every composed DataView specimen
|
|
412
|
-
// dropping it moves nothing, because the full variant's root and the workbench's specimen
|
|
413
|
-
// card are both neutral-0; it breaks the EMBEDDED variant in a real app, whose root
|
|
414
|
-
// declares nothing but a display, and the band would show the page canvas through it.
|
|
415
|
-
// `dataview-toolbar-bare` renders on a neutral-50 host so that mutation fails a baseline.
|
|
415
|
+
it("keeps the toolbar FLOATING — no surface, no divider, and still no ink", () => {
|
|
416
|
+
// This assertion reversed, and the reversal is the change: the band used to declare
|
|
417
|
+
// neutral-0 and two top radii because it was the top third of a card, and nothing else
|
|
418
|
+
// kept the selection colour inside the root's rounded frame. There is no card now. A
|
|
419
|
+
// background on a strip that sits on the page canvas paints a rectangle the design does
|
|
420
|
+
// not have, and a border under it draws a line across nothing.
|
|
416
421
|
//
|
|
417
|
-
// The
|
|
418
|
-
//
|
|
419
|
-
//
|
|
422
|
+
// The radii went with the card for the same reason: there is no rounded frame above the
|
|
423
|
+
// toolbar to stay inside. Selection mode keeps a fill — it marks a MODE and losing that
|
|
424
|
+
// makes selection invisible — but as a surface of its own, with its own padding and
|
|
425
|
+
// radius, asserted below.
|
|
420
426
|
//
|
|
421
|
-
//
|
|
422
|
-
// (`children` and `trailing`),
|
|
423
|
-
//
|
|
424
|
-
// stop the framework doing.
|
|
427
|
+
// NO colour survives unchanged, and it is the invariant with the sharpest edge: two of
|
|
428
|
+
// this element's direct children are arbitrary caller slots (`children` and `trailing`),
|
|
429
|
+
// so a muted ink here would inherit into app-authored filter controls — a silent restyle
|
|
430
|
+
// of app DOM, which is what the styling migration exists to stop the framework doing.
|
|
425
431
|
const base = layerBody("terp.base");
|
|
426
432
|
const at = base.indexOf('[data-terp="dataview-toolbar"]');
|
|
427
433
|
expect(at, "the toolbar band should have a base rule").toBeGreaterThan(-1);
|
|
428
434
|
const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
|
|
429
|
-
expect(block, "
|
|
430
|
-
|
|
435
|
+
expect(block, "a floating strip paints no surface of its own").not.toContain("background:");
|
|
436
|
+
expect(block, "a divider under a floating strip is a line across nothing").not.toContain(
|
|
437
|
+
"border-block-end:",
|
|
431
438
|
);
|
|
432
|
-
for (const radius of ["border-top-left-radius", "border-top-right-radius"]) {
|
|
433
|
-
expect(block, `${radius} keeps the selection band inside the root's rounded frame`).toContain(
|
|
434
|
-
`${radius}: var(--radius-lg)`,
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
439
|
expect(
|
|
438
440
|
/(^|;)\s*color:/.test(block),
|
|
439
441
|
"a colour here inherits into the caller's filter slot and trailing slot",
|
|
@@ -449,11 +451,27 @@ describe("cascade structure", () => {
|
|
|
449
451
|
layerBody("terp.state"),
|
|
450
452
|
"selection mode is a resting surface, not an interaction state",
|
|
451
453
|
).not.toContain('[data-variant="selection"]');
|
|
452
|
-
// The
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
454
|
+
// The band used to read --density-cell-pad-x for its INLINE padding, so a compact view's
|
|
455
|
+
// band lined up with its first cell. That alignment target is gone with the card: the
|
|
456
|
+
// toolbar now aligns with the table's outer frame, whose position does not move with
|
|
457
|
+
// density, so inline padding here would push the controls off it. Block padding stays on
|
|
458
|
+
// the spacing scale — vertical rhythm was never the density question.
|
|
459
|
+
expect(block, "the band's block padding stays on the spacing scale").toContain(
|
|
460
|
+
"padding-block: var(--space-2)",
|
|
461
|
+
);
|
|
462
|
+
expect(
|
|
463
|
+
/padding-inline|padding:/.test(block),
|
|
464
|
+
"inline padding would push the controls off the table frame they now align to",
|
|
465
|
+
).toBe(false);
|
|
466
|
+
// Selection mode is the exception and declares its own inline padding, because there it
|
|
467
|
+
// IS a surface and its fill needs to clear its text.
|
|
468
|
+
const selectionAt = base.indexOf('[data-terp="dataview-toolbar"][data-variant="selection"]');
|
|
469
|
+
const selectionBlock = base.slice(
|
|
470
|
+
base.indexOf("{", selectionAt) + 1,
|
|
471
|
+
base.indexOf("}", selectionAt),
|
|
472
|
+
);
|
|
473
|
+
expect(selectionBlock, "the selection surface reads density for its own inset").toContain(
|
|
474
|
+
"padding-inline: var(--density-cell-pad-x)",
|
|
457
475
|
);
|
|
458
476
|
});
|
|
459
477
|
|