@terpjs/react-core 0.7.0 → 0.9.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 +6 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/PageActions.tsx +5 -10
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +15 -15
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +7 -4
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +354 -25
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +81 -18
- package/src/sso.test.tsx +6 -3
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +855 -6
- package/src/styles.ts +3049 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +19 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/ui/controlStyles.ts +0 -9
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
63
63
|
| Export | Use |
|
|
64
64
|
|---|---|
|
|
65
65
|
| `Authorized`, `useCan` | Gate UI on `can(module, action)` — write buttons, admin panels. |
|
|
66
|
+
| `usePermissions`, `useHasPermission` | The caller's **named** grants from `GET /me` (ADR 0096), for a screen whose write needs `definitions.publish` rather than a rank. `Authorized` takes an optional `permission` alongside `action`; both must pass, as the server's own guard does. Display only — the backend re-checks. |
|
|
66
67
|
| `canPerform`, `DEFAULT_RANK_THRESHOLDS` | The role-rank predicate behind the gate. |
|
|
67
68
|
| `visibleNav` | Filter nav items to what the current user may see. |
|
|
68
69
|
|
|
@@ -85,6 +86,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
|
|
|
85
86
|
| `useRouteParam` | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding `undefined`. Replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). Checked against the generated route table when the app has one. |
|
|
86
87
|
| `useRouteParams` | Read a whole declared route's params, typed exactly: `const { recordId } = useRouteParams("/records/:recordId")`. With a generated route table, a typo in the path *or* a param name is a typecheck error. |
|
|
87
88
|
| `useTerpNavigate` | Navigate by manifest path: `navigate({ to: "/records/:recordId", params: { recordId } })`. An undeclared path is a typecheck error and a parameterised route requires its params — a typo'd path used to be a dead link that shipped green. Takes the manifest's `:id` spelling and translates to the router's `$id`. |
|
|
89
|
+
| `useRouteSearch` | Read a declared route's query-string keys, typed: `const { status, page } = useRouteSearch("/records")` (ADR 0096). Every value is `string | undefined`, and an undeclared key is a typecheck error — so a filtered list screen stays inside the checked seam instead of reaching for the router's own `useSearch`. Declare them in the manifest: `search: ["status", "page"]`. |
|
|
88
90
|
| `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
|
|
89
91
|
| `PageActions` | Primary action + overflow menu for a page header. |
|
|
90
92
|
|
|
@@ -112,7 +114,8 @@ declare module "@terpjs/react-core" {
|
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
From then on `useRouteParams("/records/:recordId")` is exact, `useRouteParam` refuses a
|
|
115
|
-
param no route declares,
|
|
117
|
+
param no route declares, `useRouteSearch` is keyed to the route's declared query-string
|
|
118
|
+
keys, and `useTerpNavigate` refuses an undeclared path (or an undeclared `search` key). Regenerate
|
|
116
119
|
after changing a manifest route — `terp verify`'s `routes-drift` check refuses a stale
|
|
117
120
|
table and names the command (it runs before the typecheck, so a stale table reads as
|
|
118
121
|
"regenerate", not as errors in your own screens). A route whose `path` is not a plain
|
|
@@ -158,6 +161,7 @@ marker, counted by the escape-hatch budget.
|
|
|
158
161
|
| `ResourceList` | The standard simple CRUD list screen: titled section, write-gated create form, loading/error/empty states. Composable — screens needing more render their own React. |
|
|
159
162
|
| `unwrap`, `unwrapOptional`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`. `unwrapOptional` returns `null` on a 404 instead — for resources whose absence is a normal state (a `/latest` snapshot not yet published), the client-side analog of `BaseService.find` beside `get`. |
|
|
160
163
|
| `FileUpload`, `useFileDownload` | The files-capability surface (ADR 0056/0057): a token-styled attachment picker that uploads through the typed client, and an authenticated download helper (a raw `<a href>` would carry no bearer token). |
|
|
164
|
+
| `useEndpointDownload`, `saveBlob` | Download an artifact the backend **generates** — an evidence bundle, a CSV export — which has no stored file id (ADR 0096). Goes through the session client, so it carries the base URL and bearer token and rejects a non-2xx instead of saving the error body under the intended filename. |
|
|
161
165
|
|
|
162
166
|
## Feedback & states
|
|
163
167
|
|
|
@@ -168,7 +172,7 @@ marker, counted by the escape-hatch budget.
|
|
|
168
172
|
| `ErrorState`, `describeError` | Human-readable failure block for a caught error. |
|
|
169
173
|
| `ErrorMessagesProvider`, `useErrorMessage`, `DEFAULT_ERROR_MESSAGES` | Map stable backend error codes to copy; falls back to the envelope `detail`. |
|
|
170
174
|
| `ToastProvider`, `useToast` | Transient success/error feedback (no toast library). |
|
|
171
|
-
| `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. |
|
|
175
|
+
| `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. A modal is for a confirmation or an explicit post-action moment — an edit form or a detail view belongs in a routed page, or in an expanded row beside the thing it edits (ADR 0096 §4). |
|
|
172
176
|
|
|
173
177
|
## Forms & primitives
|
|
174
178
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@tanstack/react-router": "^1.170.16",
|
|
16
|
-
"@terpjs/contract": "^0.
|
|
16
|
+
"@terpjs/contract": "^0.9.0"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/AppShell.test.tsx
CHANGED
|
@@ -21,9 +21,7 @@ function renderShell(extra?: Partial<Parameters<typeof AppShell>[0]>) {
|
|
|
21
21
|
<AppShell
|
|
22
22
|
title="Terp"
|
|
23
23
|
nav={nav}
|
|
24
|
-
renderLink={(item, children
|
|
25
|
-
<a href={item.to} style={context.style}>{children}</a>
|
|
26
|
-
)}
|
|
24
|
+
renderLink={(item, children) => <a href={item.to}>{children}</a>}
|
|
27
25
|
navFooter={<p>pinned footer</p>}
|
|
28
26
|
{...extra}
|
|
29
27
|
>
|
|
@@ -74,12 +72,18 @@ describe("AppShell", () => {
|
|
|
74
72
|
expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
|
|
75
73
|
expect(screen.getByText("U")).toBeInTheDocument(); // Users' fallback initial tile
|
|
76
74
|
const navigation = screen.getByRole("navigation", { name: "Primary" });
|
|
77
|
-
|
|
75
|
+
// data-collapsed is on the SIDEBAR, not on the nav. One fact, one owner: the rail decides
|
|
76
|
+
// the sidebar's width, the brand's centring, the nav's scrollbar and both hidden labels, so
|
|
77
|
+
// every rule that reads it descends from the element that owns it.
|
|
78
|
+
const sidebar = navigation.closest('[data-terp="appshell-sidebar"]');
|
|
79
|
+
expect(sidebar).toHaveAttribute("data-collapsed", "true");
|
|
78
80
|
expect(navigation.querySelectorAll('[data-terp="nav-icon"]')).toHaveLength(2);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// The link's collapsed geometry is a rule now, keyed on that attribute — asserting
|
|
82
|
+
// toHaveStyle here asserted that the shell hands a style object to the caller's link
|
|
83
|
+
// renderer, which is the thing this migration removed. jsdom computes no cascade, so the
|
|
84
|
+
// fact is what a unit test can hold; the geometry is gated by styles.test.ts and by the
|
|
85
|
+
// app-shell-collapsed baseline.
|
|
86
|
+
expect(screen.getByRole("link", { name: "Notes" })).not.toHaveAttribute("style");
|
|
83
87
|
expect(screen.getByText("rail")).toBeInTheDocument();
|
|
84
88
|
expect(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)).toBe("collapsed");
|
|
85
89
|
expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
|
|
@@ -89,13 +93,30 @@ describe("AppShell", () => {
|
|
|
89
93
|
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "collapsed");
|
|
90
94
|
renderShell();
|
|
91
95
|
expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
|
|
92
|
-
expect(
|
|
93
|
-
"data-
|
|
94
|
-
|
|
95
|
-
);
|
|
96
|
+
expect(
|
|
97
|
+
screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
|
|
98
|
+
).toHaveAttribute("data-collapsed", "true");
|
|
96
99
|
expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
|
|
97
100
|
});
|
|
98
101
|
|
|
102
|
+
it("starts collapsed on defaultCollapsed, and a stored choice still wins", () => {
|
|
103
|
+
// The rail was internal state with no way in, which is why four rules that apply only to it
|
|
104
|
+
// were painted by nothing. Reading the key with `=== "collapsed"` also treated an absent key
|
|
105
|
+
// and an explicit "expanded" as the same thing, so the fallback had to become a null check.
|
|
106
|
+
renderShell({ defaultCollapsed: true });
|
|
107
|
+
expect(
|
|
108
|
+
screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
|
|
109
|
+
).toHaveAttribute("data-collapsed", "true");
|
|
110
|
+
cleanup();
|
|
111
|
+
|
|
112
|
+
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "expanded");
|
|
113
|
+
renderShell({ defaultCollapsed: true });
|
|
114
|
+
expect(
|
|
115
|
+
screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
|
|
116
|
+
"an explicit stored choice must beat defaultCollapsed, in both directions",
|
|
117
|
+
).not.toHaveAttribute("data-collapsed");
|
|
118
|
+
});
|
|
119
|
+
|
|
99
120
|
it("renders a custom logo and footer in their slots", () => {
|
|
100
121
|
renderShell({ logo: <span>MyMark</span>, footer: <span>v1.2.3</span> });
|
|
101
122
|
expect(screen.getByText("MyMark")).toBeInTheDocument();
|
package/src/AppShell.tsx
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { NavItem } from "@terpjs/contract";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
import { Icon, NavIcon, TerpMark } from "./icons";
|
|
6
6
|
import { LanguageSwitcher } from "./locale";
|
|
7
7
|
import { injectTerpStyles } from "./styles";
|
|
8
8
|
import { ThemeToggle } from "./theme";
|
|
9
|
-
import { CONTROL_TEXT_STYLE } from "./ui/controlStyles";
|
|
10
9
|
import { useStrings, useUiText } from "./uiText";
|
|
11
10
|
import type { UiText } from "./uiText";
|
|
12
11
|
|
|
@@ -17,16 +16,18 @@ export interface AppShellSlotContext {
|
|
|
17
16
|
collapsed: boolean;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
/**
|
|
20
|
+
* What a link renderer is told about the shell it is rendering into.
|
|
21
|
+
*
|
|
22
|
+
* It used to carry `style` and `activeStyle` for the caller to spread, which made the
|
|
23
|
+
* shell's link geometry a style object handed across a public boundary — unthemeable by
|
|
24
|
+
* an app, and duplicated by every stack. The sheet owns that geometry now, keyed on
|
|
25
|
+
* `[data-terp="appshell-nav"] a` and on `aria-current="page"` for the active route, so a
|
|
26
|
+
* renderer needs to return nothing but its stack's link (ADR 0094).
|
|
27
|
+
*/
|
|
28
|
+
export type AppShellLinkContext = AppShellSlotContext;
|
|
24
29
|
|
|
25
|
-
export type RenderBrandLink = (props: {
|
|
26
|
-
to: string;
|
|
27
|
-
children: ReactNode;
|
|
28
|
-
style: CSSProperties;
|
|
29
|
-
}) => ReactNode;
|
|
30
|
+
export type RenderBrandLink = (props: { to: string; children: ReactNode }) => ReactNode;
|
|
30
31
|
|
|
31
32
|
export interface AppShellProps {
|
|
32
33
|
/** Product / app title shown next to the logo at the top of the sidebar. */
|
|
@@ -35,10 +36,10 @@ export interface AppShellProps {
|
|
|
35
36
|
nav: readonly NavItem[];
|
|
36
37
|
/**
|
|
37
38
|
* Turns a nav item into the active stack's link around the shell-styled
|
|
38
|
-
* `children` (icon + label), keeping the shell router-agnostic.
|
|
39
|
-
*
|
|
40
|
-
* link
|
|
41
|
-
*
|
|
39
|
+
* `children` (icon + label), keeping the shell router-agnostic. Return the
|
|
40
|
+
* stack's link and nothing else: the shell owns the expanded and collapsed
|
|
41
|
+
* link geometry from its stylesheet, and the active route's treatment is
|
|
42
|
+
* keyed on `aria-current="page"`, which every router sets.
|
|
42
43
|
*/
|
|
43
44
|
renderLink: (item: NavItem, children: ReactNode, context: AppShellLinkContext) => ReactNode;
|
|
44
45
|
/** Turns the product brand into the home link; defaults to a plain anchor to `/`. */
|
|
@@ -51,6 +52,16 @@ export interface AppShellProps {
|
|
|
51
52
|
navFooter?: ReactNode | ((context: AppShellSlotContext) => ReactNode);
|
|
52
53
|
/** Footer line under the content; default: a muted line with the app title. */
|
|
53
54
|
footer?: ReactNode;
|
|
55
|
+
/**
|
|
56
|
+
* Start with the desktop sidebar collapsed to its icon rail, when no choice has been
|
|
57
|
+
* persisted yet. The user's own toggle still wins and still persists.
|
|
58
|
+
*
|
|
59
|
+
* It exists for the same reason `Menu` and both date pickers take `defaultOpen`: the
|
|
60
|
+
* rail is internal state read from `localStorage`, so without a way in it can be
|
|
61
|
+
* rendered by no specimen and no test, and every rule that only applies to it is
|
|
62
|
+
* unpainted. Four were.
|
|
63
|
+
*/
|
|
64
|
+
defaultCollapsed?: boolean;
|
|
54
65
|
/** The routed page content. */
|
|
55
66
|
children: ReactNode;
|
|
56
67
|
}
|
|
@@ -61,42 +72,6 @@ export const SIDEBAR_STORAGE_KEY = "terp.sidebar";
|
|
|
61
72
|
/** Below this width the sidebar becomes an overlay drawer (matches DataView's card cutover). */
|
|
62
73
|
const MOBILE_BREAKPOINT = "(max-width: 768px)";
|
|
63
74
|
|
|
64
|
-
const EXPANDED_WIDTH = "15rem";
|
|
65
|
-
const COLLAPSED_WIDTH = "4rem";
|
|
66
|
-
|
|
67
|
-
/** Base style for sidebar links — spread onto the stack's link element. */
|
|
68
|
-
export const NAV_LINK_STYLE: CSSProperties = {
|
|
69
|
-
display: "flex",
|
|
70
|
-
alignItems: "center",
|
|
71
|
-
gap: "var(--space-2)",
|
|
72
|
-
padding: "var(--space-2) var(--space-3)",
|
|
73
|
-
borderRadius: "var(--radius-md)",
|
|
74
|
-
color: "var(--color-neutral-700)",
|
|
75
|
-
fontSize: "var(--font-size-sm)",
|
|
76
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
77
|
-
textDecoration: "none",
|
|
78
|
-
whiteSpace: "nowrap",
|
|
79
|
-
overflow: "hidden",
|
|
80
|
-
boxSizing: "border-box",
|
|
81
|
-
minHeight: "2.25rem",
|
|
82
|
-
transition: "background-color 150ms ease, color 150ms ease",
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/** Collapsed rail geometry: one centered fixed-size icon inside the 2.5rem content track. */
|
|
86
|
-
export const NAV_LINK_COLLAPSED_STYLE: CSSProperties = {
|
|
87
|
-
justifyContent: "center",
|
|
88
|
-
gap: 0,
|
|
89
|
-
padding: "var(--space-2)",
|
|
90
|
-
width: "100%",
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/** Merged over {@link NAV_LINK_STYLE} on the active route's link. */
|
|
94
|
-
export const NAV_LINK_ACTIVE_STYLE: CSSProperties = {
|
|
95
|
-
background: "var(--color-brand-primary-soft)",
|
|
96
|
-
color: "var(--color-fg-accent)",
|
|
97
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
98
|
-
};
|
|
99
|
-
|
|
100
75
|
function useIsMobile(): boolean {
|
|
101
76
|
const [isMobile, setIsMobile] = useState(
|
|
102
77
|
() =>
|
|
@@ -116,175 +91,27 @@ function useIsMobile(): boolean {
|
|
|
116
91
|
return isMobile;
|
|
117
92
|
}
|
|
118
93
|
|
|
119
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The persisted rail choice, falling back to `defaultCollapsed` when nothing is stored.
|
|
96
|
+
*
|
|
97
|
+
* The null check is the whole point and it is new: reading `=== "collapsed"` treated an
|
|
98
|
+
* absent key and an explicit "expanded" as the same thing, so a `defaultCollapsed` shell
|
|
99
|
+
* could never start collapsed. A stored choice still wins in both directions.
|
|
100
|
+
*/
|
|
101
|
+
function readStoredCollapsed(fallback: boolean): boolean {
|
|
120
102
|
if (typeof window === "undefined") {
|
|
121
|
-
return
|
|
103
|
+
return fallback;
|
|
122
104
|
}
|
|
123
105
|
try {
|
|
124
|
-
|
|
106
|
+
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
|
107
|
+
return stored === null ? fallback : stored === "collapsed";
|
|
125
108
|
} catch {
|
|
126
|
-
return
|
|
109
|
+
return fallback;
|
|
127
110
|
}
|
|
128
111
|
}
|
|
129
112
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
alignItems: "stretch",
|
|
133
|
-
minHeight: "100vh",
|
|
134
|
-
fontFamily: "var(--font-family-sans)",
|
|
135
|
-
color: "var(--color-neutral-900)",
|
|
136
|
-
background: "var(--color-neutral-50)",
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const sidebarStyle: CSSProperties = {
|
|
140
|
-
display: "flex",
|
|
141
|
-
flexDirection: "column",
|
|
142
|
-
gap: "var(--space-4)",
|
|
143
|
-
padding: "var(--space-3)",
|
|
144
|
-
boxSizing: "border-box",
|
|
145
|
-
flexShrink: 0,
|
|
146
|
-
position: "sticky",
|
|
147
|
-
top: 0,
|
|
148
|
-
height: "100vh",
|
|
149
|
-
overflowX: "hidden",
|
|
150
|
-
background: "var(--color-neutral-0)",
|
|
151
|
-
borderRight: "1px solid var(--color-neutral-200)",
|
|
152
|
-
transition: "width 150ms ease",
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const drawerStyle: CSSProperties = {
|
|
156
|
-
...sidebarStyle,
|
|
157
|
-
position: "fixed",
|
|
158
|
-
inset: "0 auto 0 0",
|
|
159
|
-
height: "100dvh",
|
|
160
|
-
width: EXPANDED_WIDTH,
|
|
161
|
-
zIndex: 50,
|
|
162
|
-
boxShadow: "var(--shadow-lg)",
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const backdropStyle: CSSProperties = {
|
|
166
|
-
position: "fixed",
|
|
167
|
-
inset: 0,
|
|
168
|
-
zIndex: 40,
|
|
169
|
-
background: "rgb(0 0 0 / 0.4)",
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const brandStyle: CSSProperties = {
|
|
173
|
-
display: "flex",
|
|
174
|
-
alignItems: "center",
|
|
175
|
-
gap: "var(--space-2)",
|
|
176
|
-
padding: "var(--space-1) var(--space-2)",
|
|
177
|
-
minHeight: "2.25rem",
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const brandLinkStyle: CSSProperties = {
|
|
181
|
-
...brandStyle,
|
|
182
|
-
color: "var(--color-neutral-900)",
|
|
183
|
-
textDecoration: "none",
|
|
184
|
-
borderRadius: "var(--radius-md)",
|
|
185
|
-
boxSizing: "border-box",
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const drawerBrandRowStyle: CSSProperties = {
|
|
189
|
-
display: "flex",
|
|
190
|
-
alignItems: "center",
|
|
191
|
-
gap: "var(--space-2)",
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const brandTitleStyle: CSSProperties = {
|
|
195
|
-
overflow: "hidden",
|
|
196
|
-
textOverflow: "ellipsis",
|
|
197
|
-
whiteSpace: "nowrap",
|
|
198
|
-
fontSize: "var(--font-size-base)",
|
|
199
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
200
|
-
color: "var(--color-neutral-900)",
|
|
201
|
-
letterSpacing: 0,
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const navItemLabelStyle: CSSProperties = {
|
|
205
|
-
overflow: "hidden",
|
|
206
|
-
textOverflow: "ellipsis",
|
|
207
|
-
whiteSpace: "nowrap",
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const visuallyHiddenStyle: CSSProperties = {
|
|
211
|
-
position: "absolute",
|
|
212
|
-
width: 1,
|
|
213
|
-
height: 1,
|
|
214
|
-
padding: 0,
|
|
215
|
-
margin: -1,
|
|
216
|
-
overflow: "hidden",
|
|
217
|
-
clip: "rect(0 0 0 0)",
|
|
218
|
-
whiteSpace: "nowrap",
|
|
219
|
-
border: 0,
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const navStyle: CSSProperties = { flexGrow: 1, overflowY: "auto", minHeight: 0 };
|
|
223
|
-
|
|
224
|
-
const listStyle: CSSProperties = {
|
|
225
|
-
listStyle: "none",
|
|
226
|
-
margin: 0,
|
|
227
|
-
padding: 0,
|
|
228
|
-
display: "grid",
|
|
229
|
-
gap: "var(--space-1)",
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const columnStyle: CSSProperties = {
|
|
233
|
-
display: "flex",
|
|
234
|
-
flexDirection: "column",
|
|
235
|
-
flexGrow: 1,
|
|
236
|
-
minWidth: 0,
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const headerStyle: CSSProperties = {
|
|
240
|
-
position: "sticky",
|
|
241
|
-
top: 0,
|
|
242
|
-
zIndex: 30,
|
|
243
|
-
display: "flex",
|
|
244
|
-
flexWrap: "wrap",
|
|
245
|
-
alignItems: "center",
|
|
246
|
-
justifyContent: "space-between",
|
|
247
|
-
gap: "var(--space-3)",
|
|
248
|
-
padding: "var(--space-2) var(--space-4)",
|
|
249
|
-
minHeight: "3rem",
|
|
250
|
-
boxSizing: "border-box",
|
|
251
|
-
background: "var(--color-neutral-0)",
|
|
252
|
-
borderBottom: "1px solid var(--color-neutral-200)",
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
const headerGroupStyle: CSSProperties = {
|
|
256
|
-
display: "flex",
|
|
257
|
-
alignItems: "center",
|
|
258
|
-
gap: "var(--space-2)",
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
const mainStyle: CSSProperties = { flexGrow: 1, padding: "var(--space-6)", minWidth: 0 };
|
|
262
|
-
const mainMobileStyle: CSSProperties = { ...mainStyle, padding: "var(--space-4)" };
|
|
263
|
-
|
|
264
|
-
const footerStyle: CSSProperties = {
|
|
265
|
-
padding: "var(--space-3) var(--space-6)",
|
|
266
|
-
borderTop: "1px solid var(--color-neutral-200)",
|
|
267
|
-
color: "var(--color-neutral-500)",
|
|
268
|
-
fontSize: "var(--font-size-xs)",
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
const toggleStyle: CSSProperties = {
|
|
272
|
-
...CONTROL_TEXT_STYLE,
|
|
273
|
-
display: "inline-flex",
|
|
274
|
-
alignItems: "center",
|
|
275
|
-
justifyContent: "center",
|
|
276
|
-
width: "2.25rem",
|
|
277
|
-
height: "2.25rem",
|
|
278
|
-
padding: 0,
|
|
279
|
-
color: "var(--color-neutral-700)",
|
|
280
|
-
background: "transparent",
|
|
281
|
-
border: "1px solid transparent",
|
|
282
|
-
borderRadius: "var(--radius-md)",
|
|
283
|
-
cursor: "pointer",
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
const defaultRenderBrandLink: RenderBrandLink = ({ to, children, style }) => (
|
|
287
|
-
<a href={to} data-terp="appshell-brand" style={style}>
|
|
113
|
+
const defaultRenderBrandLink: RenderBrandLink = ({ to, children }) => (
|
|
114
|
+
<a href={to} data-terp="appshell-brand">
|
|
288
115
|
{children}
|
|
289
116
|
</a>
|
|
290
117
|
);
|
|
@@ -331,12 +158,13 @@ export function AppShell({
|
|
|
331
158
|
headerActions,
|
|
332
159
|
navFooter,
|
|
333
160
|
footer,
|
|
161
|
+
defaultCollapsed = false,
|
|
334
162
|
children,
|
|
335
163
|
}: AppShellProps) {
|
|
336
164
|
const resolve = useUiText();
|
|
337
165
|
const strings = useStrings();
|
|
338
166
|
const isMobile = useIsMobile();
|
|
339
|
-
const [collapsed, setCollapsed] = useState(readStoredCollapsed);
|
|
167
|
+
const [collapsed, setCollapsed] = useState(() => readStoredCollapsed(defaultCollapsed));
|
|
340
168
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
341
169
|
const drawerRef = useRef<HTMLElement>(null);
|
|
342
170
|
const drawerCloseRef = useRef<HTMLButtonElement>(null);
|
|
@@ -399,24 +227,27 @@ export function AppShell({
|
|
|
399
227
|
// The drawer always shows labels; the desktop rail hides them when collapsed.
|
|
400
228
|
const railCollapsed = !isMobile && collapsed;
|
|
401
229
|
const context: AppShellSlotContext = { collapsed: railCollapsed };
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
230
|
+
// Hoisted, the density-attribute idiom: the marker scanner reads a whole expression
|
|
231
|
+
// container, so a conditional written at the attribute reports every literal in it as a
|
|
232
|
+
// marker name.
|
|
233
|
+
const collapsedAttribute = railCollapsed ? "true" : undefined;
|
|
234
|
+
// One attribute for the viewport, on the shell root, and every consequence of it descends
|
|
235
|
+
// from there: the sidebar becomes a drawer, main tightens its padding. The breakpoint stays
|
|
236
|
+
// in one place — this component's media query — rather than being restated as a CSS
|
|
237
|
+
// @media rule that could drift from it.
|
|
238
|
+
const shellVariant = isMobile ? "mobile" : "desktop";
|
|
405
239
|
const resolvedTitle = resolve(title);
|
|
406
240
|
|
|
241
|
+
// The brand takes no style object and needs none: its three looks are the resting one,
|
|
242
|
+
// the collapsed one (reached from the sidebar's data-collapsed) and the mobile one
|
|
243
|
+
// (reached from the drawer's brand row, which only exists on mobile). The DOM already
|
|
244
|
+
// says which it is.
|
|
407
245
|
const brand = renderBrandLink({
|
|
408
246
|
to: "/",
|
|
409
|
-
style: railCollapsed
|
|
410
|
-
? { ...brandLinkStyle, justifyContent: "center", paddingInline: 0 }
|
|
411
|
-
: isMobile
|
|
412
|
-
? { ...brandLinkStyle, flex: 1, minWidth: 0 }
|
|
413
|
-
: brandLinkStyle,
|
|
414
247
|
children: (
|
|
415
248
|
<>
|
|
416
249
|
{logo ?? <TerpMark />}
|
|
417
|
-
<strong
|
|
418
|
-
{resolvedTitle}
|
|
419
|
-
</strong>
|
|
250
|
+
<strong data-terp="appshell-brand-title">{resolvedTitle}</strong>
|
|
420
251
|
</>
|
|
421
252
|
),
|
|
422
253
|
});
|
|
@@ -429,23 +260,19 @@ export function AppShell({
|
|
|
429
260
|
aria-label={isMobile ? strings.primaryNavigationLabel : undefined}
|
|
430
261
|
tabIndex={isMobile ? -1 : undefined}
|
|
431
262
|
onKeyDown={isMobile ? onDrawerKeyDown : undefined}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
? drawerStyle
|
|
435
|
-
: { ...sidebarStyle, width: railCollapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH }
|
|
436
|
-
}
|
|
263
|
+
data-terp="appshell-sidebar"
|
|
264
|
+
data-collapsed={collapsedAttribute}
|
|
437
265
|
>
|
|
438
266
|
{isMobile && (
|
|
439
267
|
<span
|
|
440
268
|
data-terp="drawer-focus-start"
|
|
441
269
|
tabIndex={0}
|
|
442
|
-
style={visuallyHiddenStyle}
|
|
443
270
|
onFocus={() => focusDrawerEdge("last")}
|
|
444
271
|
/>
|
|
445
272
|
)}
|
|
446
273
|
{isMobile ? (
|
|
447
274
|
<div
|
|
448
|
-
|
|
275
|
+
data-terp="appshell-brand-row"
|
|
449
276
|
onClick={(event) => {
|
|
450
277
|
if (event.target instanceof Element && event.target.closest("a") !== null) {
|
|
451
278
|
closeDrawer();
|
|
@@ -458,7 +285,6 @@ export function AppShell({
|
|
|
458
285
|
type="button"
|
|
459
286
|
data-terp="iconbutton"
|
|
460
287
|
aria-label={strings.closeNavigation}
|
|
461
|
-
style={toggleStyle}
|
|
462
288
|
onClick={closeDrawer}
|
|
463
289
|
>
|
|
464
290
|
<Icon name="x" size="1.15rem" />
|
|
@@ -466,24 +292,20 @@ export function AppShell({
|
|
|
466
292
|
</div>
|
|
467
293
|
) : brand}
|
|
468
294
|
<nav
|
|
469
|
-
style={navStyle}
|
|
470
295
|
data-terp="appshell-nav"
|
|
471
|
-
data-collapsed={railCollapsed || undefined}
|
|
472
296
|
aria-label={strings.primaryNavigationLabel}
|
|
473
297
|
onClick={isMobile ? closeDrawer : undefined}
|
|
474
298
|
>
|
|
475
|
-
<ul
|
|
299
|
+
<ul data-terp="appshell-nav-list">
|
|
476
300
|
{nav.map((item) => (
|
|
477
301
|
<li key={item.to} title={railCollapsed ? item.label : undefined}>
|
|
478
302
|
{renderLink(
|
|
479
303
|
item,
|
|
480
304
|
<>
|
|
481
305
|
<NavIcon name={item.icon} label={item.label} />
|
|
482
|
-
<span
|
|
483
|
-
{item.label}
|
|
484
|
-
</span>
|
|
306
|
+
<span data-terp="appshell-nav-label">{item.label}</span>
|
|
485
307
|
</>,
|
|
486
|
-
{ collapsed: railCollapsed
|
|
308
|
+
{ collapsed: railCollapsed },
|
|
487
309
|
)}
|
|
488
310
|
</li>
|
|
489
311
|
))}
|
|
@@ -494,7 +316,6 @@ export function AppShell({
|
|
|
494
316
|
<span
|
|
495
317
|
data-terp="drawer-focus-end"
|
|
496
318
|
tabIndex={0}
|
|
497
|
-
style={visuallyHiddenStyle}
|
|
498
319
|
onFocus={() => focusDrawerEdge("first")}
|
|
499
320
|
/>
|
|
500
321
|
)}
|
|
@@ -502,13 +323,13 @@ export function AppShell({
|
|
|
502
323
|
);
|
|
503
324
|
|
|
504
325
|
return (
|
|
505
|
-
<div
|
|
326
|
+
<div data-terp="appshell" data-variant={shellVariant}>
|
|
506
327
|
{isMobile ? (
|
|
507
328
|
drawerOpen && (
|
|
508
329
|
<>
|
|
509
330
|
{/* Click-away surface only: Escape and the labelled header toggle are the
|
|
510
331
|
accessible close paths, so the backdrop stays out of the a11y tree. */}
|
|
511
|
-
<div aria-hidden="true"
|
|
332
|
+
<div aria-hidden="true" data-terp="appshell-backdrop" onClick={closeDrawer} />
|
|
512
333
|
{sidebar}
|
|
513
334
|
</>
|
|
514
335
|
)
|
|
@@ -516,16 +337,15 @@ export function AppShell({
|
|
|
516
337
|
sidebar
|
|
517
338
|
)}
|
|
518
339
|
<div
|
|
519
|
-
|
|
340
|
+
data-terp="appshell-column"
|
|
520
341
|
inert={isMobile && drawerOpen ? true : undefined}
|
|
521
342
|
aria-hidden={isMobile && drawerOpen ? true : undefined}
|
|
522
343
|
>
|
|
523
|
-
<header
|
|
344
|
+
<header data-terp="appshell-header">
|
|
524
345
|
<button
|
|
525
346
|
ref={toggleRef}
|
|
526
347
|
type="button"
|
|
527
348
|
data-terp="iconbutton"
|
|
528
|
-
style={toggleStyle}
|
|
529
349
|
aria-expanded={isMobile ? drawerOpen : !collapsed}
|
|
530
350
|
aria-label={
|
|
531
351
|
isMobile
|
|
@@ -540,14 +360,14 @@ export function AppShell({
|
|
|
540
360
|
>
|
|
541
361
|
<PanelIcon />
|
|
542
362
|
</button>
|
|
543
|
-
<div
|
|
363
|
+
<div data-terp="appshell-header-group">
|
|
544
364
|
{headerActions}
|
|
545
365
|
<ThemeToggle variant="inline" />
|
|
546
366
|
<LanguageSwitcher variant="inline" />
|
|
547
367
|
</div>
|
|
548
368
|
</header>
|
|
549
|
-
<main
|
|
550
|
-
<footer
|
|
369
|
+
<main data-terp="appshell-main">{children}</main>
|
|
370
|
+
<footer data-terp="appshell-footer">{footer ?? <small>{resolvedTitle}</small>}</footer>
|
|
551
371
|
</div>
|
|
552
372
|
</div>
|
|
553
373
|
);
|