@terpjs/react-core 0.10.0 → 0.12.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 +7 -6
- package/package.json +2 -2
- package/src/AppShell.test.tsx +52 -4
- package/src/AppShell.tsx +35 -17
- package/src/ConfirmDialog.tsx +4 -4
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +31 -8
- package/src/ErrorState.tsx +8 -4
- package/src/Field.test.tsx +20 -0
- package/src/Field.tsx +2 -2
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +33 -20
- package/src/UserMenu.test.tsx +2 -2
- package/src/bootstrap.tsx +5 -2
- package/src/format.test.tsx +3 -3
- package/src/index.ts +24 -3
- package/src/locale.test.tsx +195 -3
- package/src/locale.tsx +196 -11
- package/src/markers.test.ts +2 -0
- package/src/nav.ts +2 -2
- package/src/router.tsx +13 -3
- package/src/sso.ts +2 -2
- package/src/styles.test.ts +84 -34
- package/src/styles.ts +233 -31
- 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.test.tsx +15 -1
- package/src/uiText.tsx +71 -2
package/README.md
CHANGED
|
@@ -31,9 +31,10 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
31
31
|
focus ring, a checkbox's `accent-color`. One token cannot do both: in a dark theme the
|
|
32
32
|
surface use needs a value dark enough to hold a white label and the ink use needs one light
|
|
33
33
|
enough to read on a dark canvas, and there is no value satisfying both.
|
|
34
|
-
- **
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
- **Static user-facing text is cataloged** — use an `{id, message}` `UiText`
|
|
35
|
+
descriptor for text props and `<Trans id message />` for JSX bodies. Plain strings are
|
|
36
|
+
reserved for dynamic business data, identifiers and product names. The boundary lint
|
|
37
|
+
refuses bare static copy and checks every id against every target in `frontend/i18n.json`.
|
|
37
38
|
- **Dependency-free UI** — react-core ships no icon/toast/i18n libraries. Glyphs are
|
|
38
39
|
inline SVG; transient feedback goes through `ToastProvider` / `useToast`.
|
|
39
40
|
- **Security defaults** — `dangerouslySetInnerHTML` and the DOM HTML-injection sinks
|
|
@@ -54,7 +55,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
54
55
|
| `useSso`, `parseSsoCallback`, `fetchSsoAuthorizationUrl`, `completeSsoCallback` | The SSO login seam (ADR 0058): `useSso().begin(provider)` opens an OIDC flow; `TerpProvider` completes the `/auth/callback/{provider}` redirect landing into a normal session on boot. `renderTerpApp({ ssoProviders })` wires the buttons in one line. |
|
|
55
56
|
| `RequireAuth` | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
|
|
56
57
|
| `ThemeProvider`, `ThemeToggle`, `useTheme` | Theming over the shipped palettes — `light`, `dark`, `midnight`, `twilight`, `contrast` — plus `system` to follow the OS preference. Applies `data-theme` on `<html>` (the token stylesheet carries every palette) and persists the choice. `defaultTheme` is how an app ships on a named theme — declare it in `layout-contract.json` so a tool can read and rewrite it, or pass the bootstrap option; both is refused. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
|
|
57
|
-
| `LocaleProvider`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`:
|
|
58
|
+
| `LocaleProvider`, `defineAppLocales`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`: `defineAppLocales(i18n, frameworkCatalogs)` validates and merges checked-in app messages with framework chrome, the active locale persists, and the shell offers a picker. Pass `sourceLocale` with `locales`; missing/empty target messages, undocumented source copies, invalid locale selection and a non-English locale without a complete framework-string catalog throw rather than silently falling back. |
|
|
58
59
|
| `UserMenu`, `userInitials` | The signed-in user's menu, pinned by `buildAppRouter` to the bottom of the sidebar: an initials avatar trigger opening the identity block, **Settings** (the built-in profile page) and sign-out. Collapses to the avatar in the icon rail. |
|
|
59
60
|
| `ProfileView` | The built-in profile / settings page (`/profile`): the server-validated identity, theme + language preferences, and sign-out. |
|
|
60
61
|
|
|
@@ -70,7 +71,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
70
71
|
## Page archetypes (the three-level screen pattern)
|
|
71
72
|
|
|
72
73
|
Every routed view **must** render one of the archetypes (`Page`, or `OverviewPage` /
|
|
73
|
-
`DetailPage` / `HubPage`, which compose it) — `buildAppRouter` refuses an unframed view at
|
|
74
|
+
`DetailPage` / `HubPage` / `FormPage` / `SettingsPage` / `SplitPage`, which compose it) — `buildAppRouter` refuses an unframed view at
|
|
74
75
|
runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/error frame.
|
|
75
76
|
|
|
76
77
|
| Export | Use |
|
|
@@ -266,7 +267,7 @@ single screen by claiming its path from an app module.
|
|
|
266
267
|
|
|
267
268
|
| Export | Use |
|
|
268
269
|
|---|---|
|
|
269
|
-
| `UiTextProvider`, `useUiText`, `useStrings`, `resolveUiText`, `DEFAULT_STRINGS` | The `UiText` seam:
|
|
270
|
+
| `UiTextProvider`, `Trans`, `useUiText`, `useStrings`, `resolveUiText`, `DEFAULT_STRINGS` | The `UiText` seam: descriptors for props, `Trans` for body copy, and framework strings through one resolver. `LocaleProvider` is the batteries-included catalog layer and refuses missing target-locale entries. |
|
|
270
271
|
|
|
271
272
|
## Testing components
|
|
272
273
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.12.0"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"react": "^19.0.0",
|
package/src/AppShell.test.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
4
4
|
import type { NavItem } from "@terpjs/contract";
|
|
5
5
|
|
|
6
6
|
import { AppShell, SIDEBAR_STORAGE_KEY } from "./AppShell";
|
|
7
|
+
import { LOCALE_NL, LocaleProvider } from "./locale";
|
|
7
8
|
|
|
8
9
|
afterEach(() => {
|
|
9
10
|
cleanup();
|
|
@@ -43,15 +44,20 @@ function stubMobileViewport() {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
describe("AppShell", () => {
|
|
46
|
-
it("renders the landmarks, brand,
|
|
47
|
+
it("renders the landmarks, brand, and the nav via renderLink — and NO footer", () => {
|
|
47
48
|
renderShell();
|
|
48
49
|
|
|
49
50
|
expect(screen.getByRole("banner")).toBeInTheDocument();
|
|
50
51
|
expect(screen.getByRole("navigation", { name: "Primary" })).toBeInTheDocument();
|
|
51
52
|
expect(screen.getByRole("main")).toBeInTheDocument();
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
53
|
+
// No `contentinfo` unless the app asks for one. The default used to be a strip
|
|
54
|
+
// restating the app title already in the header and the browser tab — on every screen
|
|
55
|
+
// of every app, costing vertical space on exactly the viewports with least of it. An
|
|
56
|
+
// empty landmark is worse than none: it is somewhere a screen-reader user can navigate
|
|
57
|
+
// to and find nothing.
|
|
58
|
+
expect(screen.queryByRole("contentinfo")).toBeNull();
|
|
59
|
+
// The brand is the standard home affordance.
|
|
60
|
+
expect(screen.getAllByText("Terp").length).toBeGreaterThanOrEqual(1);
|
|
55
61
|
expect(screen.getByRole("link", { name: "Terp" })).toHaveAttribute("href", "/");
|
|
56
62
|
expect(screen.getByRole("link", { name: "Notes" })).toHaveAttribute("href", "/notes");
|
|
57
63
|
expect(screen.getByText("page content")).toBeInTheDocument();
|
|
@@ -355,6 +361,48 @@ describe("AppShell navigation groups", () => {
|
|
|
355
361
|
{ label: "Loose", to: "/loose" },
|
|
356
362
|
];
|
|
357
363
|
|
|
364
|
+
it("resolves localized item and group descriptors before rendering navigation", () => {
|
|
365
|
+
render(
|
|
366
|
+
<LocaleProvider
|
|
367
|
+
locales={{
|
|
368
|
+
en: {},
|
|
369
|
+
nl: {
|
|
370
|
+
...LOCALE_NL,
|
|
371
|
+
messages: {
|
|
372
|
+
"nav.work": "Werkruimte",
|
|
373
|
+
"nav.notes": "Notities",
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
}}
|
|
377
|
+
defaultLocale="nl"
|
|
378
|
+
sourceLocale="en"
|
|
379
|
+
>
|
|
380
|
+
<AppShell
|
|
381
|
+
title="Terp"
|
|
382
|
+
nav={[
|
|
383
|
+
{
|
|
384
|
+
label: { id: "nav.notes", message: "Notes" },
|
|
385
|
+
to: "/notes",
|
|
386
|
+
group: "work",
|
|
387
|
+
},
|
|
388
|
+
]}
|
|
389
|
+
navGroups={[
|
|
390
|
+
{
|
|
391
|
+
id: "work",
|
|
392
|
+
label: { id: "nav.work", message: "Workspace" },
|
|
393
|
+
},
|
|
394
|
+
]}
|
|
395
|
+
renderLink={(item, children) => <a href={item.to}>{children}</a>}
|
|
396
|
+
>
|
|
397
|
+
<p>page content</p>
|
|
398
|
+
</AppShell>
|
|
399
|
+
</LocaleProvider>,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(screen.getByRole("list", { name: "Werkruimte" })).toBeInTheDocument();
|
|
403
|
+
expect(screen.getByRole("link", { name: "Notities" })).toHaveAttribute("href", "/notes");
|
|
404
|
+
});
|
|
405
|
+
|
|
358
406
|
it("labels each group's list with its own visible label", () => {
|
|
359
407
|
render(
|
|
360
408
|
<AppShell
|
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.
|
|
@@ -473,11 +477,12 @@ export function AppShell({
|
|
|
473
477
|
onClick={isMobile ? closeDrawer : undefined}
|
|
474
478
|
>
|
|
475
479
|
{groupNav(nav, navGroups).map((section, index) => {
|
|
480
|
+
const groupLabel = section.label === null ? null : resolve(section.label);
|
|
476
481
|
// Only a labelled section needs an id, and only a DECLARED section can be labelled — the
|
|
477
482
|
// default one has no declaration to carry a label. Keyed on the index rather than on
|
|
478
483
|
// `section.id`: a group id is an app-supplied string, and whitespace in one would
|
|
479
484
|
// silently break the IDREF rather than fail anywhere.
|
|
480
|
-
const labelId =
|
|
485
|
+
const labelId = groupLabel === null ? undefined : `${navGroupId}-${index}`;
|
|
481
486
|
return (
|
|
482
487
|
// No heading element, and this is the decision rather than an oversight. `Heading`
|
|
483
488
|
// refuses level 1 to reserve it for the routed view's title (see typography.tsx), and
|
|
@@ -500,22 +505,25 @@ export function AppShell({
|
|
|
500
505
|
<div key={index} data-terp="appshell-nav-group">
|
|
501
506
|
{labelId !== undefined && (
|
|
502
507
|
<span id={labelId} data-terp="appshell-nav-group-label">
|
|
503
|
-
{
|
|
508
|
+
{groupLabel}
|
|
504
509
|
</span>
|
|
505
510
|
)}
|
|
506
511
|
<ul data-terp="appshell-nav-list" aria-labelledby={labelId}>
|
|
507
|
-
{section.items.map((item) =>
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
512
|
+
{section.items.map((item) => {
|
|
513
|
+
const label = resolve(item.label);
|
|
514
|
+
return (
|
|
515
|
+
<li key={item.to} title={railCollapsed ? label : undefined}>
|
|
516
|
+
{renderLink(
|
|
517
|
+
item,
|
|
518
|
+
<>
|
|
519
|
+
<NavIcon name={item.icon} label={label} />
|
|
520
|
+
<span data-terp="appshell-nav-label">{label}</span>
|
|
521
|
+
</>,
|
|
522
|
+
{ collapsed: railCollapsed, active: item.to === currentTo },
|
|
523
|
+
)}
|
|
524
|
+
</li>
|
|
525
|
+
);
|
|
526
|
+
})}
|
|
519
527
|
</ul>
|
|
520
528
|
</div>
|
|
521
529
|
);
|
|
@@ -688,7 +696,17 @@ export function AppShell({
|
|
|
688
696
|
<main id={mainId} data-terp="appshell-main" tabIndex={-1}>
|
|
689
697
|
{children}
|
|
690
698
|
</main>
|
|
691
|
-
|
|
699
|
+
{/* Rendered only when the app asks for one. It used to default to the app's own
|
|
700
|
+
title, which meant every screen in every app carried a footer restating the
|
|
701
|
+
name already in the header and the browser tab — a permanent strip of chrome
|
|
702
|
+
nobody chose, costing vertical space on exactly the small viewports that have
|
|
703
|
+
least of it. `footer` is now the switch: pass content to get a footer, pass
|
|
704
|
+
nothing to get none. The landmark goes with it, which is correct — an empty
|
|
705
|
+
`contentinfo` is a landmark a screen-reader user can navigate to and find
|
|
706
|
+
nothing in. */}
|
|
707
|
+
{footer !== undefined && (
|
|
708
|
+
<footer data-terp="appshell-footer">{footer}</footer>
|
|
709
|
+
)}
|
|
692
710
|
</div>
|
|
693
711
|
</div>
|
|
694
712
|
);
|
package/src/ConfirmDialog.tsx
CHANGED
|
@@ -3,8 +3,8 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { Button } from "./ui/Button";
|
|
5
5
|
import { injectTerpStyles } from "./styles";
|
|
6
|
-
import { useStrings, useUiText } from "./uiText";
|
|
7
|
-
import type { UiText } from "./uiText";
|
|
6
|
+
import { resolveUiTextNode, useStrings, useUiText } from "./uiText";
|
|
7
|
+
import type { UiText, UiTextNode } from "./uiText";
|
|
8
8
|
|
|
9
9
|
injectTerpStyles();
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ export interface ConfirmDialogProps {
|
|
|
18
18
|
/** Short question — what is about to happen. */
|
|
19
19
|
title: UiText;
|
|
20
20
|
/** Optional consequence explanation. */
|
|
21
|
-
description?:
|
|
21
|
+
description?: UiTextNode;
|
|
22
22
|
/** Confirm-button label; defaults to the `confirm` string. */
|
|
23
23
|
confirmLabel?: UiText;
|
|
24
24
|
/** Cancel-button label; defaults to the `cancel` string. */
|
|
@@ -118,7 +118,7 @@ export function ConfirmDialog({
|
|
|
118
118
|
</h2>
|
|
119
119
|
{description !== undefined && (
|
|
120
120
|
<div id={descriptionId} data-terp="dialog-description">
|
|
121
|
-
{description}
|
|
121
|
+
{resolveUiTextNode(description, resolve)}
|
|
122
122
|
</div>
|
|
123
123
|
)}
|
|
124
124
|
<div data-terp="dialog-actions">
|
|
@@ -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
|
@@ -2,8 +2,8 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { Icon } from "./icons";
|
|
4
4
|
import { injectTerpStyles } from "./styles";
|
|
5
|
-
import { useUiText } from "./uiText";
|
|
6
|
-
import type { UiText } from "./uiText";
|
|
5
|
+
import { resolveUiTextNode, useUiText } from "./uiText";
|
|
6
|
+
import type { UiText, UiTextNode } from "./uiText";
|
|
7
7
|
|
|
8
8
|
injectTerpStyles();
|
|
9
9
|
|
|
@@ -16,9 +16,20 @@ export interface EmptyStateProps {
|
|
|
16
16
|
/** Short title — what is missing. */
|
|
17
17
|
title: UiText;
|
|
18
18
|
/** Optional explanation — why it's missing, or what to do next. */
|
|
19
|
-
description?:
|
|
19
|
+
description?: UiTextNode;
|
|
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,20 +38,32 @@ 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
|
-
{description !== undefined &&
|
|
61
|
+
{description !== undefined && (
|
|
62
|
+
<div data-terp="empty-state-description">
|
|
63
|
+
{resolveUiTextNode(description, resolve)}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
42
66
|
{action}
|
|
43
67
|
</div>
|
|
44
68
|
);
|
|
45
69
|
}
|
|
46
|
-
|
package/src/ErrorState.tsx
CHANGED
|
@@ -3,8 +3,8 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
import { useErrorMessage } from "./errorMessages";
|
|
4
4
|
import { Icon } from "./icons";
|
|
5
5
|
import { injectTerpStyles } from "./styles";
|
|
6
|
-
import { useStrings, useUiText } from "./uiText";
|
|
7
|
-
import type { UiText } from "./uiText";
|
|
6
|
+
import { resolveUiTextNode, useStrings, useUiText } from "./uiText";
|
|
7
|
+
import type { UiText, UiTextNode } from "./uiText";
|
|
8
8
|
|
|
9
9
|
injectTerpStyles();
|
|
10
10
|
|
|
@@ -43,7 +43,7 @@ export interface ErrorStateProps {
|
|
|
43
43
|
* copy for the error's stable `code` (see `useErrorMessage`), falling back to
|
|
44
44
|
* {@link describeError}, so the platform error envelope surfaces consistently.
|
|
45
45
|
*/
|
|
46
|
-
description?:
|
|
46
|
+
description?: UiTextNode;
|
|
47
47
|
/** The caught failure — used to derive `description` when none is given. */
|
|
48
48
|
error?: unknown;
|
|
49
49
|
/** Optional call to action (typically a retry `Button`). */
|
|
@@ -73,7 +73,11 @@ export function ErrorState({ icon, title, description, error, action }: ErrorSta
|
|
|
73
73
|
<div role="alert" data-terp="error-state">
|
|
74
74
|
{leading}
|
|
75
75
|
<p data-terp="error-state-title">{resolve(title ?? strings.errorTitle)}</p>
|
|
76
|
-
{message !== null && message !== undefined &&
|
|
76
|
+
{message !== null && message !== undefined && (
|
|
77
|
+
<div data-terp="error-state-description">
|
|
78
|
+
{resolveUiTextNode(message, resolve)}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
77
81
|
{action}
|
|
78
82
|
</div>
|
|
79
83
|
);
|
package/src/Field.test.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
|
|
3
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
5
|
import { Field } from "./Field";
|
|
6
|
+
import { UiTextProvider } from "./uiText";
|
|
6
7
|
import { Input } from "./ui/Input";
|
|
7
8
|
import { Select } from "./ui/Select";
|
|
8
9
|
import { Textarea } from "./ui/Textarea";
|
|
@@ -10,6 +11,25 @@ import { Textarea } from "./ui/Textarea";
|
|
|
10
11
|
afterEach(cleanup);
|
|
11
12
|
|
|
12
13
|
describe("Field", () => {
|
|
14
|
+
it("resolves a descriptor used as helper text", () => {
|
|
15
|
+
render(
|
|
16
|
+
<UiTextProvider
|
|
17
|
+
resolveText={(text) =>
|
|
18
|
+
typeof text === "string" ? text : `translated:${text.id}`
|
|
19
|
+
}
|
|
20
|
+
>
|
|
21
|
+
<Field
|
|
22
|
+
label="Email"
|
|
23
|
+
hint={{ id: "account.email.hint", message: "We never share it" }}
|
|
24
|
+
>
|
|
25
|
+
<Input />
|
|
26
|
+
</Field>
|
|
27
|
+
</UiTextProvider>,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText("translated:account.email.hint")).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
13
33
|
it("labels its control (accessible association) and shows hint + error", () => {
|
|
14
34
|
render(
|
|
15
35
|
<Field label="Email" hint="we never share it" error="required">
|
package/src/Field.tsx
CHANGED
|
@@ -15,7 +15,7 @@ export interface FieldProps {
|
|
|
15
15
|
/** A field-level error (e.g. mapped from a 422), shown under the control. */
|
|
16
16
|
error?: string | null;
|
|
17
17
|
/** Optional helper text under the control. */
|
|
18
|
-
hint?:
|
|
18
|
+
hint?: UiText;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -90,7 +90,7 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
90
90
|
</label>
|
|
91
91
|
{hint !== undefined && (
|
|
92
92
|
<span id={hintId} data-terp="field-hint">
|
|
93
|
-
{hint}
|
|
93
|
+
{resolve(hint)}
|
|
94
94
|
</span>
|
|
95
95
|
)}
|
|
96
96
|
{hasError && (
|
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
|
@@ -3,8 +3,9 @@ import type { FormEvent } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { TerpMark } from "./icons";
|
|
5
5
|
import { useAuth, useSso } from "./TerpProvider";
|
|
6
|
-
import { useStrings } from "./uiText";
|
|
6
|
+
import { useStrings, useUiText } 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
|
|
|
@@ -52,6 +53,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
52
53
|
const auth = useAuth();
|
|
53
54
|
const sso = useSso();
|
|
54
55
|
const strings = useStrings();
|
|
56
|
+
const resolve = useUiText();
|
|
55
57
|
const [email, setEmail] = useState("");
|
|
56
58
|
const [password, setPassword] = useState("");
|
|
57
59
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -92,24 +94,35 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
92
94
|
<h1 data-terp="login-title">{strings.signIn}</h1>
|
|
93
95
|
</div>
|
|
94
96
|
<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
|
-
|
|
97
|
+
{/* Labelled through `Field`, not by placeholder. A placeholder is not an
|
|
98
|
+
accessible name and it disappears the moment someone types, so the field a
|
|
99
|
+
user is halfway through filling has nothing identifying it — WCAG 3.3.2 asks
|
|
100
|
+
for a label that survives typing. It also made these two inputs unreachable
|
|
101
|
+
by `getByLabel`, so every app's very first screen was the one place its own
|
|
102
|
+
tests could not address by name. The framework's own answer was already here:
|
|
103
|
+
`Field` wraps the control in a `<label>`, which is why it needs no id wiring.
|
|
104
|
+
The comment below is left because it dates the omission — an autocomplete
|
|
105
|
+
token was considered for these fields and a label was not. */}
|
|
106
|
+
<Field label={strings.email}>
|
|
107
|
+
<Input
|
|
108
|
+
type="email"
|
|
109
|
+
// Neither field declared an autocomplete token, so no password manager offered to
|
|
110
|
+
// fill or save this form — the one place in the framework where that matters most.
|
|
111
|
+
autoComplete="username"
|
|
112
|
+
value={email}
|
|
113
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
114
|
+
required
|
|
115
|
+
/>
|
|
116
|
+
</Field>
|
|
117
|
+
<Field label={strings.password}>
|
|
118
|
+
<Input
|
|
119
|
+
type="password"
|
|
120
|
+
autoComplete="current-password"
|
|
121
|
+
value={password}
|
|
122
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
123
|
+
required
|
|
124
|
+
/>
|
|
125
|
+
</Field>
|
|
113
126
|
<Button type="submit" fullWidth loading={busy}>
|
|
114
127
|
{busy ? strings.signingIn : strings.signIn}
|
|
115
128
|
</Button>
|
|
@@ -146,7 +159,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
146
159
|
disabled={busy}
|
|
147
160
|
onClick={() => void onSso(provider)}
|
|
148
161
|
>
|
|
149
|
-
{`${strings.continueWith} ${provider.label ?? provider.name}`}
|
|
162
|
+
{`${strings.continueWith} ${resolve(provider.label ?? provider.name)}`}
|
|
150
163
|
</Button>
|
|
151
164
|
))}
|
|
152
165
|
</div>
|
package/src/UserMenu.test.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
7
7
|
import { UserMenu, userInitials } from "./UserMenu";
|
|
8
|
-
import { LOCALE_EN, LocaleProvider } from "./locale";
|
|
8
|
+
import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "./locale";
|
|
9
9
|
import { ThemeProvider } from "./theme";
|
|
10
10
|
|
|
11
11
|
function jsonResponse(body: unknown): Response {
|
|
@@ -73,7 +73,7 @@ describe("UserMenu", () => {
|
|
|
73
73
|
stubAuthFetch();
|
|
74
74
|
render(
|
|
75
75
|
<ThemeProvider>
|
|
76
|
-
<LocaleProvider locales={{ en: LOCALE_EN, nl:
|
|
76
|
+
<LocaleProvider locales={{ en: LOCALE_EN, nl: LOCALE_NL }}>
|
|
77
77
|
<TerpProvider baseUrl="https://api.test">
|
|
78
78
|
<LogInOnMount />
|
|
79
79
|
<UserMenu />
|
package/src/bootstrap.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";
|
|
|
2
2
|
import { StrictMode } from "react";
|
|
3
3
|
import type { ComponentType, ReactNode } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import type { ModuleManifest, NavGroup } from "@terpjs/contract";
|
|
5
|
+
import type { ModuleManifest, NavGroup, UiText } from "@terpjs/contract";
|
|
6
6
|
|
|
7
7
|
import { LoginView } from "./LoginView";
|
|
8
8
|
import type { DevCredentials } from "./LoginView";
|
|
@@ -95,7 +95,7 @@ export function collectModules(modules: Record<string, unknown>): {
|
|
|
95
95
|
|
|
96
96
|
export interface RenderTerpAppOptions {
|
|
97
97
|
/** App title shown in the shell's sidebar brand (and the default footer). */
|
|
98
|
-
title:
|
|
98
|
+
title: UiText;
|
|
99
99
|
/** Discovered modules from an import.meta.glob over "./modules/<name>/module.tsx" (eager). */
|
|
100
100
|
modules: Record<string, unknown>;
|
|
101
101
|
/** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
|
|
@@ -187,6 +187,8 @@ export interface RenderTerpAppOptions {
|
|
|
187
187
|
locales?: Record<string, LocaleCatalog>;
|
|
188
188
|
/** Starting locale when the user has not chosen one; default: the first `locales` key. */
|
|
189
189
|
defaultLocale?: string;
|
|
190
|
+
/** Source locale for app-authored UiText descriptors; default: the first locale key. */
|
|
191
|
+
sourceLocale?: string;
|
|
190
192
|
/**
|
|
191
193
|
* Starting theme when the user has not chosen one; default `"system"` (OS preference).
|
|
192
194
|
*
|
|
@@ -362,6 +364,7 @@ export function renderTerpApp(options: RenderTerpAppOptions): void {
|
|
|
362
364
|
<LocaleProvider
|
|
363
365
|
locales={options.locales ?? { en: {} }}
|
|
364
366
|
defaultLocale={options.defaultLocale}
|
|
367
|
+
sourceLocale={options.sourceLocale}
|
|
365
368
|
>
|
|
366
369
|
<TerpProvider baseUrl={options.baseUrl ?? ""} ssoCallbackPath={options.ssoCallbackPath}>
|
|
367
370
|
<ToastProvider>
|