@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 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.10.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.10.0"
17
+ "@terpjs/contract": "^0.11.0"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "react": "^19.0.0",
@@ -43,15 +43,20 @@ function stubMobileViewport() {
43
43
  }
44
44
 
45
45
  describe("AppShell", () => {
46
- it("renders the landmarks, brand, footer, and the nav via renderLink", () => {
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
- expect(screen.getByRole("contentinfo")).toBeInTheDocument();
53
- // The brand is the standard home affordance; the default footer echoes the title.
54
- expect(screen.getAllByText("Terp").length).toBeGreaterThanOrEqual(2);
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
- /** Footer line under the content; default: a muted line with the app title. */
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 a slim `footer` underneath.
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
- <footer data-terp="appshell-footer">{footer ?? <small>{resolvedTitle}</small>}</footer>
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
+ });
@@ -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({ icon, title, description, action }: EmptyStateProps) {
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>}
@@ -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
- expect(screen.getByPlaceholderText("Email")).toHaveValue("admin@example.test");
55
- expect(screen.getByPlaceholderText("Password")).toHaveValue("correct horse battery staple");
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
- <Input
96
- type="email"
97
- // Neither field declared an autocomplete token, so no password manager offered to
98
- // fill or save this form the one place in the framework where that matters most.
99
- autoComplete="username"
100
- placeholder={strings.email}
101
- value={email}
102
- onChange={(event) => setEmail(event.target.value)}
103
- required
104
- />
105
- <Input
106
- type="password"
107
- autoComplete="current-password"
108
- placeholder={strings.password}
109
- value={password}
110
- onChange={(event) => setPassword(event.target.value)}
111
- required
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",
@@ -91,6 +91,8 @@ const MARKERS = [
91
91
  "combobox-field",
92
92
  "combobox-list",
93
93
  "combobox-option",
94
+ "combobox-token",
95
+ "combobox-token-remove",
94
96
  "control-label",
95
97
  "dataview",
96
98
  "dataview-actions-cell",
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
  ),
@@ -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 bare marker", () => {
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 five baselines and leave that one untouched.
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 band declaring its own surface, and no ink", () => {
406
- // Three separate invariants about one element, and each has a way of going wrong that
407
- // nothing else in the suite can see.
408
- //
409
- // The background. The inline style this replaced read `selectionMode ? neutral-50 :
410
- // neutral-0` an explicit value in BOTH branches, so the resting rule has to carry
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 two top radii, which are load-bearing rather than decorative: the DataView root
418
- // rounds its border with no overflow: hidden, so nothing else keeps the selection band's
419
- // neutral-50 inside the rounded frame.
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
- // And NO colour. Two of this element's direct children are arbitrary caller slots
422
- // (`children` and `trailing`), so a muted ink here would inherit into app-authored filter
423
- // controls a silent restyle of app DOM, which is exactly what this migration exists to
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, "the band must declare its own surface, not inherit the host's").toContain(
430
- "background: var(--color-neutral-0)",
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 bar reads the density token rather than a literal --space-3, which is what makes a
453
- // compact view's band line up with its first cell. Zero-diff at comfortable, because
454
- // comfortable --density-cell-pad-x IS --space-3 so only dataview-compact can catch it.
455
- expect(block, "the band's inline padding must follow density").toContain(
456
- "padding: var(--space-2) var(--density-cell-pad-x)",
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