@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/src/download.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handing bytes to the browser as a named download (ADR 0096).
|
|
3
|
+
*
|
|
4
|
+
* `useFileDownload` covers the stored-file case: a `FileMeta` id, fetched from the files
|
|
5
|
+
* capability's content endpoint. What it does not cover is the other half of the same
|
|
6
|
+
* need — **an artifact the backend generates on demand** ("download this revision as
|
|
7
|
+
* proof", a CSV export, a signed evidence bundle). Those have no stored file id, and the
|
|
8
|
+
* only ways to reach them were a raw `fetch` (refused: one typed egress path) or a raw
|
|
9
|
+
* `<a href>` (which carries no bearer token, so it 401s or, worse, silently downloads an
|
|
10
|
+
* error page). The observed outcome was the feature being dropped rather than built.
|
|
11
|
+
*
|
|
12
|
+
* So the blob-to-anchor dance lives here once, and `useEndpointDownload` reaches any
|
|
13
|
+
* authorized GET through the session client. `path` is a plain string, deliberately and
|
|
14
|
+
* unusually: the generated client is keyed by the app's own schema, which this package
|
|
15
|
+
* cannot see, and a byte-stream route is app-specific by nature. Everything else that
|
|
16
|
+
* matters — the base URL, the bearer token, cookie credentials, the refusal on a non-2xx —
|
|
17
|
+
* still comes from the client, which is what the raw alternatives threw away.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useCallback } from "react";
|
|
21
|
+
|
|
22
|
+
import { useTerpClient } from "./TerpProvider";
|
|
23
|
+
import type { TerpClient } from "@terpjs/contract";
|
|
24
|
+
|
|
25
|
+
/** What to download: where the bytes come from, and what the file should be called. */
|
|
26
|
+
export interface DownloadTarget {
|
|
27
|
+
/** API path, e.g. `/api/v1/revisions/{id}/evidence` with `params` filling `{id}`. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Filename offered to the browser (extension included). */
|
|
30
|
+
filename: string;
|
|
31
|
+
/** Path placeholders to substitute, e.g. `{ id: revision.id }`. */
|
|
32
|
+
params?: Record<string, string>;
|
|
33
|
+
/** Query string to append; `undefined` values are omitted. */
|
|
34
|
+
query?: Record<string, string | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Save *blob* as a named download.
|
|
39
|
+
*
|
|
40
|
+
* Exported because it is the one piece a screen cannot avoid re-implementing when it
|
|
41
|
+
* already holds the bytes (a client-side CSV, a canvas export) — and every hand-rolled
|
|
42
|
+
* copy leaks the object URL, which is why the revoke is in a `finally` here.
|
|
43
|
+
*/
|
|
44
|
+
export function saveBlob(blob: Blob, filename: string): void {
|
|
45
|
+
const url = URL.createObjectURL(blob);
|
|
46
|
+
try {
|
|
47
|
+
const anchor = document.createElement("a");
|
|
48
|
+
anchor.href = url;
|
|
49
|
+
anchor.download = filename;
|
|
50
|
+
document.body.appendChild(anchor);
|
|
51
|
+
anchor.click();
|
|
52
|
+
anchor.remove();
|
|
53
|
+
} finally {
|
|
54
|
+
URL.revokeObjectURL(url);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fill `{placeholder}` segments and append the query string, dropping unset values. */
|
|
59
|
+
export function downloadUrl(target: DownloadTarget): string {
|
|
60
|
+
let path = target.path;
|
|
61
|
+
for (const [name, value] of Object.entries(target.params ?? {})) {
|
|
62
|
+
path = path.replaceAll(`{${name}}`, encodeURIComponent(value));
|
|
63
|
+
}
|
|
64
|
+
const unfilled = /\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(path);
|
|
65
|
+
if (unfilled !== null) {
|
|
66
|
+
// Fail closed rather than requesting a literal `{id}`: the server would 404 or, on a
|
|
67
|
+
// permissive route, hand back somebody else's bytes.
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Download path "${target.path}" still contains the placeholder "${unfilled[0]}" — ` +
|
|
70
|
+
`pass it in \`params\` (e.g. params: { ${unfilled[1]}: row.id }).`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const query = new URLSearchParams();
|
|
74
|
+
for (const [name, value] of Object.entries(target.query ?? {})) {
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
query.append(name, value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const suffix = query.toString();
|
|
80
|
+
return suffix.length > 0 ? `${path}?${suffix}` : path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Download a generated artifact from an authorized endpoint (ADR 0096).
|
|
85
|
+
*
|
|
86
|
+
* ```tsx
|
|
87
|
+
* const download = useEndpointDownload();
|
|
88
|
+
* void download({
|
|
89
|
+
* path: "/api/v1/revisions/{revisionId}/evidence",
|
|
90
|
+
* params: { revisionId: revision.id },
|
|
91
|
+
* filename: `revision-${revision.number}.json`,
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* Rejects on a non-2xx response, so a caller can surface the failure — a raw anchor would
|
|
96
|
+
* have saved the error body under the intended filename instead.
|
|
97
|
+
*/
|
|
98
|
+
export function useEndpointDownload(): (target: DownloadTarget) => Promise<void> {
|
|
99
|
+
const client = useTerpClient();
|
|
100
|
+
return useCallback(
|
|
101
|
+
async (target: DownloadTarget) => {
|
|
102
|
+
const blob = await fetchDownload(client as unknown as TerpClient, target);
|
|
103
|
+
saveBlob(blob, target.filename);
|
|
104
|
+
},
|
|
105
|
+
[client],
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch a download target's bytes through the session client.
|
|
111
|
+
*
|
|
112
|
+
* Separate from the hook so a caller that wants the blob for something *other* than
|
|
113
|
+
* saving it (a preview, a checksum) does not have to save it first.
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchDownload(client: TerpClient, target: DownloadTarget): Promise<Blob> {
|
|
116
|
+
const url = downloadUrl(target);
|
|
117
|
+
const { data, error, response } = await (
|
|
118
|
+
client as unknown as {
|
|
119
|
+
GET: (
|
|
120
|
+
path: string,
|
|
121
|
+
init: { parseAs: "blob" },
|
|
122
|
+
) => Promise<{ data?: unknown; error?: unknown; response: Response }>;
|
|
123
|
+
}
|
|
124
|
+
).GET(url, { parseAs: "blob" });
|
|
125
|
+
if (error !== undefined || !response.ok) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Download of ${url} failed with HTTP ${response.status}. The endpoint must be a GET ` +
|
|
128
|
+
"the current session is authorized for.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return data as Blob;
|
|
132
|
+
}
|
package/src/feedback.test.tsx
CHANGED
|
@@ -82,6 +82,32 @@ describe("ConfirmDialog", () => {
|
|
|
82
82
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it("links the consequence text to the dialog, and only when there is one", () => {
|
|
86
|
+
const { rerender } = render(
|
|
87
|
+
<ConfirmDialog
|
|
88
|
+
open
|
|
89
|
+
onOpenChange={() => {}}
|
|
90
|
+
onConfirm={() => {}}
|
|
91
|
+
title="Delete this link?"
|
|
92
|
+
description="Its mappings and its run history are removed with it."
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
// A modal announces its name and its description on open. Unlinked body copy is reached
|
|
96
|
+
// only by manual exploration — the wrong ergonomics for the one screen whose whole job is
|
|
97
|
+
// telling someone what a destructive action will do.
|
|
98
|
+
const dialog = screen.getByRole("dialog");
|
|
99
|
+
const describedBy = dialog.getAttribute("aria-describedby");
|
|
100
|
+
expect(describedBy).not.toBeNull();
|
|
101
|
+
expect(document.getElementById(describedBy!)?.textContent).toBe(
|
|
102
|
+
"Its mappings and its run history are removed with it.",
|
|
103
|
+
);
|
|
104
|
+
// No description means no attribute, rather than an idref pointing at nothing.
|
|
105
|
+
rerender(
|
|
106
|
+
<ConfirmDialog open onOpenChange={() => {}} onConfirm={() => {}} title="Delete this link?" />,
|
|
107
|
+
);
|
|
108
|
+
expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-describedby");
|
|
109
|
+
});
|
|
110
|
+
|
|
85
111
|
it("confirms and cancels through the labelled buttons", () => {
|
|
86
112
|
const onConfirm = vi.fn();
|
|
87
113
|
const onOpenChange = vi.fn();
|
package/src/files.test.tsx
CHANGED
|
@@ -71,6 +71,24 @@ describe("FileUpload", () => {
|
|
|
71
71
|
await waitFor(() => expect(onUploaded).toHaveBeenCalledWith(STORED));
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it("hides the picker input with the hidden attribute, not an inline style", () => {
|
|
75
|
+
render(
|
|
76
|
+
<TerpProvider baseUrl="https://api.test">
|
|
77
|
+
<FileUpload />
|
|
78
|
+
</TerpProvider>,
|
|
79
|
+
);
|
|
80
|
+
// The visible control is the button; this input exists only so a programmatic click can
|
|
81
|
+
// open the native dialog. `hidden` is the platform's own way to say that, and it keeps the
|
|
82
|
+
// file free of a style object that would read as unmigrated styling.
|
|
83
|
+
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
84
|
+
expect(input).not.toBeNull();
|
|
85
|
+
expect(input.hidden).toBe(true);
|
|
86
|
+
expect(input.getAttribute("style")).toBeNull();
|
|
87
|
+
// Belt and braces, kept deliberately: both survive an author rule that resurrects the box.
|
|
88
|
+
expect(input).toHaveAttribute("aria-hidden", "true");
|
|
89
|
+
expect(input.tabIndex).toBe(-1);
|
|
90
|
+
});
|
|
91
|
+
|
|
74
92
|
it("reports a failed upload through onError", async () => {
|
|
75
93
|
stubFetch(async (request) => {
|
|
76
94
|
if (request.url.endsWith("/api/v1/files/") && request.method === "POST") {
|
package/src/files.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from "react";
|
|
2
|
-
import type { ChangeEvent
|
|
2
|
+
import type { ChangeEvent } from "react";
|
|
3
3
|
|
|
4
|
+
import { saveBlob } from "./download";
|
|
4
5
|
import { useTerpClient } from "./TerpProvider";
|
|
5
6
|
import { Button } from "./ui/Button";
|
|
6
7
|
import { useStrings, useUiText, type UiText } from "./uiText";
|
|
@@ -91,24 +92,12 @@ export function useFileDownload(): (file: Pick<FileMeta, "id" | "filename">) =>
|
|
|
91
92
|
return useCallback(
|
|
92
93
|
async (file: Pick<FileMeta, "id" | "filename">) => {
|
|
93
94
|
const blob = await fetchFileContent(client as unknown as TerpClient, file.id);
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const anchor = document.createElement("a");
|
|
97
|
-
anchor.href = url;
|
|
98
|
-
anchor.download = file.filename;
|
|
99
|
-
document.body.appendChild(anchor);
|
|
100
|
-
anchor.click();
|
|
101
|
-
anchor.remove();
|
|
102
|
-
} finally {
|
|
103
|
-
URL.revokeObjectURL(url);
|
|
104
|
-
}
|
|
95
|
+
saveBlob(blob, file.filename);
|
|
105
96
|
},
|
|
106
97
|
[client],
|
|
107
98
|
);
|
|
108
99
|
}
|
|
109
100
|
|
|
110
|
-
const hiddenInputStyle: CSSProperties = { display: "none" };
|
|
111
|
-
|
|
112
101
|
export interface FileUploadProps {
|
|
113
102
|
/** Called with the stored metadata after a successful upload. */
|
|
114
103
|
onUploaded?: (file: FileMeta) => void;
|
|
@@ -152,11 +141,22 @@ export function FileUpload({ onUploaded, onError, label, accept, disabled }: Fil
|
|
|
152
141
|
|
|
153
142
|
return (
|
|
154
143
|
<>
|
|
144
|
+
{/* The platform attribute, not an inline display:none.
|
|
145
|
+
|
|
146
|
+
This input is the file picker's plumbing: the visible control is the Button below,
|
|
147
|
+
and this element exists only so `.click()` can open the native dialog. So it is not
|
|
148
|
+
a styled surface at all, and the alternatives both misdescribe it — giving it a
|
|
149
|
+
marker and a `display: none` rule would put a component with no visual design into
|
|
150
|
+
the sheet, and keeping the inline object would leave the one declaration in the file
|
|
151
|
+
looking like styling that had not been migrated yet. `hidden` is what HTML provides
|
|
152
|
+
for "not relevant", and a programmatic click still opens the dialog. The
|
|
153
|
+
`aria-hidden` and `tabIndex` become redundant beside it and are kept anyway: they
|
|
154
|
+
cost nothing and they survive an author rule that resurrects the box. */}
|
|
155
155
|
<input
|
|
156
156
|
ref={inputRef}
|
|
157
157
|
type="file"
|
|
158
158
|
accept={accept}
|
|
159
|
-
|
|
159
|
+
hidden
|
|
160
160
|
onChange={(event) => void onChange(event)}
|
|
161
161
|
aria-hidden="true"
|
|
162
162
|
tabIndex={-1}
|
package/src/icons.test.tsx
CHANGED
|
@@ -11,7 +11,10 @@ describe("NavIcon", () => {
|
|
|
11
11
|
const { container } = render(<NavIcon name="users" label="Users" />);
|
|
12
12
|
const slot = container.querySelector('[data-terp="nav-icon"]');
|
|
13
13
|
const svg = container.querySelector("svg");
|
|
14
|
-
|
|
14
|
+
// The rail slot's fixed 1.25rem box is a sheet rule now (ADR 0094); what this test
|
|
15
|
+
// owns is that the named glyph resolved and stayed decorative.
|
|
16
|
+
expect(slot).not.toBeNull();
|
|
17
|
+
expect(slot?.getAttribute("style")).toBeNull();
|
|
15
18
|
expect(svg).not.toBeNull();
|
|
16
19
|
expect(svg).toHaveAttribute("aria-hidden", "true");
|
|
17
20
|
});
|
|
@@ -19,11 +22,12 @@ describe("NavIcon", () => {
|
|
|
19
22
|
it("falls back to the label's initial for an unknown or missing name", () => {
|
|
20
23
|
const { container } = render(<NavIcon name="no-such-glyph" label="widgets" />);
|
|
21
24
|
expect(screen.getByText("W")).toBeInTheDocument();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// The fallback tile is its own marker, so the rail can style "an initial in a tile"
|
|
26
|
+
// separately from "a glyph" — which is the distinction a theme cares about.
|
|
27
|
+
expect(
|
|
28
|
+
container.querySelector('[data-terp="nav-icon-fallback"]'),
|
|
29
|
+
"the initial renders in the fallback tile",
|
|
30
|
+
).not.toBeNull();
|
|
27
31
|
render(<NavIcon label="records" />);
|
|
28
32
|
expect(screen.getByText("R")).toBeInTheDocument();
|
|
29
33
|
});
|
package/src/icons.tsx
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "./styles";
|
|
4
|
+
|
|
5
|
+
injectTerpStyles();
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* The dependency-free icon layer: a small set of inline SVG glyphs the shell's
|
|
@@ -431,31 +435,6 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
|
|
|
431
435
|
),
|
|
432
436
|
};
|
|
433
437
|
|
|
434
|
-
const navIconStyle: CSSProperties = {
|
|
435
|
-
display: "inline-flex",
|
|
436
|
-
alignItems: "center",
|
|
437
|
-
justifyContent: "center",
|
|
438
|
-
width: "1.25rem",
|
|
439
|
-
height: "1.25rem",
|
|
440
|
-
flex: "0 0 1.25rem",
|
|
441
|
-
fontSize: "1rem",
|
|
442
|
-
lineHeight: 1,
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
const fallbackStyle: CSSProperties = {
|
|
446
|
-
display: "inline-flex",
|
|
447
|
-
alignItems: "center",
|
|
448
|
-
justifyContent: "center",
|
|
449
|
-
width: "100%",
|
|
450
|
-
height: "100%",
|
|
451
|
-
borderRadius: "var(--radius-sm, 4px)",
|
|
452
|
-
background: "var(--color-brand-primary-soft, var(--color-neutral-200))",
|
|
453
|
-
color: "var(--color-brand-primary, var(--color-neutral-700))",
|
|
454
|
-
fontSize: "0.7em",
|
|
455
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
456
|
-
lineHeight: 1,
|
|
457
|
-
};
|
|
458
|
-
|
|
459
438
|
export interface NavIconProps {
|
|
460
439
|
/** Glyph name (a `NavItem.icon` value); unknown / missing falls back to the initial. */
|
|
461
440
|
name?: string;
|
|
@@ -470,9 +449,9 @@ export interface NavIconProps {
|
|
|
470
449
|
export function NavIcon({ name, label }: NavIconProps) {
|
|
471
450
|
const glyph = name !== undefined ? ICON_GLYPHS[name] : undefined;
|
|
472
451
|
return (
|
|
473
|
-
<span aria-hidden="true" data-terp="nav-icon"
|
|
452
|
+
<span aria-hidden="true" data-terp="nav-icon">
|
|
474
453
|
{glyph ?? (
|
|
475
|
-
<span
|
|
454
|
+
<span data-terp="nav-icon-fallback">
|
|
476
455
|
{(label[0] ?? "?").toUpperCase()}
|
|
477
456
|
</span>
|
|
478
457
|
)}
|
|
@@ -511,15 +490,8 @@ export function Icon({ name, size = "1em", title }: IconProps) {
|
|
|
511
490
|
aria-hidden={labelled ? undefined : true}
|
|
512
491
|
role={labelled ? "img" : undefined}
|
|
513
492
|
aria-label={labelled ? title : undefined}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
alignItems: "center",
|
|
517
|
-
justifyContent: "center",
|
|
518
|
-
width: px,
|
|
519
|
-
height: px,
|
|
520
|
-
lineHeight: 1,
|
|
521
|
-
color: "inherit",
|
|
522
|
-
}}
|
|
493
|
+
data-terp="icon"
|
|
494
|
+
style={{ width: px, height: px }}
|
|
523
495
|
>
|
|
524
496
|
{glyph}
|
|
525
497
|
</span>
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { canPerform, DEFAULT_RANK_THRESHOLDS } from "./capabilities";
|
|
|
8
8
|
export type { RankThresholds } from "./capabilities";
|
|
9
9
|
export { createAuthClient } from "./createAuthClient";
|
|
10
10
|
export type { AuthClientOptions, TokenGetter } from "./createAuthClient";
|
|
11
|
-
export { Authorized, useCan } from "./Authorized";
|
|
11
|
+
export { Authorized, useCan, useHasPermission, usePermissions } from "./Authorized";
|
|
12
12
|
export type { AuthorizedProps } from "./Authorized";
|
|
13
13
|
export { useResource } from "./useResource";
|
|
14
14
|
export type { Resource, ResourceSource } from "./useResource";
|
|
@@ -30,9 +30,6 @@ export type { RequireAuthProps } from "./RequireAuth";
|
|
|
30
30
|
export { visibleNav } from "./nav";
|
|
31
31
|
export {
|
|
32
32
|
AppShell,
|
|
33
|
-
NAV_LINK_STYLE,
|
|
34
|
-
NAV_LINK_COLLAPSED_STYLE,
|
|
35
|
-
NAV_LINK_ACTIVE_STYLE,
|
|
36
33
|
SIDEBAR_STORAGE_KEY,
|
|
37
34
|
} from "./AppShell";
|
|
38
35
|
export type {
|
|
@@ -112,6 +109,8 @@ export { Alert } from "./ui/Alert";
|
|
|
112
109
|
export type { AlertProps, AlertTone } from "./ui/Alert";
|
|
113
110
|
export { Markdown } from "./ui/Markdown";
|
|
114
111
|
export type { MarkdownProps } from "./ui/Markdown";
|
|
112
|
+
export { saveBlob, useEndpointDownload, fetchDownload, downloadUrl } from "./download";
|
|
113
|
+
export type { DownloadTarget } from "./download";
|
|
115
114
|
export { Field } from "./Field";
|
|
116
115
|
export type { FieldProps } from "./Field";
|
|
117
116
|
export { Stack, DetailList } from "./layout";
|
|
@@ -122,6 +121,7 @@ export {
|
|
|
122
121
|
PROFILE_PATH,
|
|
123
122
|
useRouteParam,
|
|
124
123
|
useRouteParams,
|
|
124
|
+
useRouteSearch,
|
|
125
125
|
useTerpNavigate,
|
|
126
126
|
} from "./router";
|
|
127
127
|
export type { BuildAppRouterOptions } from "./router";
|
|
@@ -132,6 +132,9 @@ export type {
|
|
|
132
132
|
TerpRouteParamName,
|
|
133
133
|
TerpRouteParams,
|
|
134
134
|
TerpRoutePath,
|
|
135
|
+
TerpRouteSearch,
|
|
136
|
+
TerpRouteSearchKey,
|
|
137
|
+
TerpRouteSearchTable,
|
|
135
138
|
TerpRouteTable,
|
|
136
139
|
} from "./routeTypes";
|
|
137
140
|
export { LoginView } from "./LoginView";
|
package/src/layout.test.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { DetailList, Stack } from "./layout";
|
|
|
7
7
|
afterEach(cleanup);
|
|
8
8
|
|
|
9
9
|
describe("Stack", () => {
|
|
10
|
-
it("
|
|
10
|
+
it("names a column at the default gap, with no inline styling", () => {
|
|
11
11
|
render(
|
|
12
12
|
<Stack data-testid="stack">
|
|
13
13
|
<span>a</span>
|
|
@@ -16,24 +16,39 @@ describe("Stack", () => {
|
|
|
16
16
|
);
|
|
17
17
|
const el = screen.getByTestId("stack");
|
|
18
18
|
expect(el.tagName).toBe("DIV");
|
|
19
|
-
expect(el
|
|
20
|
-
expect(el
|
|
21
|
-
expect(el.
|
|
19
|
+
expect(el).toHaveAttribute("data-direction", "column");
|
|
20
|
+
expect(el).toHaveAttribute("data-gap", "2");
|
|
21
|
+
expect(el).not.toHaveAttribute("data-wrap");
|
|
22
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
it("renders the requested element
|
|
25
|
+
it("renders the requested element and names direction, gap and wrap", () => {
|
|
25
26
|
render(
|
|
26
|
-
<Stack data-testid="row" as="section" direction="row" gap={4}
|
|
27
|
+
<Stack data-testid="row" as="section" direction="row" gap={4} wrap>
|
|
27
28
|
<span>a</span>
|
|
28
29
|
</Stack>,
|
|
29
30
|
);
|
|
30
31
|
const el = screen.getByTestId("row");
|
|
31
32
|
expect(el.tagName).toBe("SECTION");
|
|
32
|
-
expect(el
|
|
33
|
-
expect(el
|
|
33
|
+
expect(el).toHaveAttribute("data-direction", "row");
|
|
34
|
+
expect(el).toHaveAttribute("data-gap", "4");
|
|
35
|
+
expect(el).toHaveAttribute("data-wrap", "true");
|
|
36
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps alignment inline, because it is an open set of CSS keywords", () => {
|
|
40
|
+
// align/justify accept any alignment keyword, so they cannot become a rule per value
|
|
41
|
+
// without inventing a vocabulary CSS already has (ADR 0094). They stay inline, and
|
|
42
|
+
// the geometry attributes stay attributes — the two halves of the same element.
|
|
43
|
+
render(
|
|
44
|
+
<Stack data-testid="aligned" align="center" justify="space-between">
|
|
45
|
+
<span>a</span>
|
|
46
|
+
</Stack>,
|
|
47
|
+
);
|
|
48
|
+
const el = screen.getByTestId("aligned");
|
|
34
49
|
expect(el.style.alignItems).toBe("center");
|
|
35
50
|
expect(el.style.justifyContent).toBe("space-between");
|
|
36
|
-
expect(el.style.
|
|
51
|
+
expect(el.style.display).toBe("");
|
|
37
52
|
});
|
|
38
53
|
|
|
39
54
|
it("works as a form (submit handler fires)", () => {
|
package/src/layout.tsx
CHANGED
|
@@ -38,16 +38,27 @@ export function Stack({
|
|
|
38
38
|
wrap = false,
|
|
39
39
|
...rest
|
|
40
40
|
}: StackProps) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
41
|
+
// `direction`, `gap` and `wrap` are closed sets, so they are attributes the sheet keys
|
|
42
|
+
// on. `align` and `justify` take any alignment keyword CSS accepts, so they stay inline
|
|
43
|
+
// rather than turning an open vocabulary into a rule per value (ADR 0094). Undefined on
|
|
44
|
+
// both means no style attribute at all.
|
|
45
|
+
const alignment: CSSProperties | undefined =
|
|
46
|
+
align === undefined && justify === undefined
|
|
47
|
+
? undefined
|
|
48
|
+
: {
|
|
49
|
+
...(align !== undefined ? { alignItems: align } : undefined),
|
|
50
|
+
...(justify !== undefined ? { justifyContent: justify } : undefined),
|
|
51
|
+
};
|
|
52
|
+
return (
|
|
53
|
+
<Component
|
|
54
|
+
{...rest}
|
|
55
|
+
data-terp="stack"
|
|
56
|
+
data-direction={direction}
|
|
57
|
+
data-gap={String(gap)}
|
|
58
|
+
data-wrap={wrap ? "true" : undefined}
|
|
59
|
+
style={alignment}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
export interface DetailItem {
|
|
@@ -62,14 +73,6 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
|
|
|
62
73
|
items: readonly DetailItem[];
|
|
63
74
|
}
|
|
64
75
|
|
|
65
|
-
const detailListStyle: CSSProperties = {
|
|
66
|
-
margin: 0,
|
|
67
|
-
display: "grid",
|
|
68
|
-
gap: "var(--space-1)",
|
|
69
|
-
};
|
|
70
|
-
const detailTermStyle: CSSProperties = { display: "inline", fontWeight: "var(--font-weight-medium)" };
|
|
71
|
-
const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
|
|
72
|
-
|
|
73
76
|
/**
|
|
74
77
|
* Token-styled label/value pairs as a semantic `<dl>` — record metadata on a detail page,
|
|
75
78
|
* an expanded row's summary. Centralizes the "Label: value" pattern so modules never
|
|
@@ -78,11 +81,11 @@ const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
|
|
|
78
81
|
export function DetailList({ items, ...rest }: DetailListProps) {
|
|
79
82
|
const text = useUiText();
|
|
80
83
|
return (
|
|
81
|
-
<dl {...rest} data-terp="detail-list"
|
|
84
|
+
<dl {...rest} data-terp="detail-list">
|
|
82
85
|
{items.map((item, index) => (
|
|
83
86
|
<div key={index}>
|
|
84
|
-
<dt
|
|
85
|
-
<dd
|
|
87
|
+
<dt data-terp="detail-list-term">{text(item.label)}: </dt>
|
|
88
|
+
<dd data-terp="detail-list-value">{item.value}</dd>
|
|
86
89
|
</div>
|
|
87
90
|
))}
|
|
88
91
|
</dl>
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
import { OverviewPage } from "./OverviewPage";
|
|
22
22
|
import { Page } from "./Page";
|
|
23
23
|
import { DetailList, Stack } from "./layout";
|
|
24
|
+
import { PageActions } from "./PageActions";
|
|
25
|
+
import { ThemeProvider, ThemeToggle } from "./theme";
|
|
26
|
+
import { Button } from "./ui/Button";
|
|
24
27
|
import { Card } from "./ui/Card";
|
|
25
28
|
|
|
26
29
|
afterEach(cleanup);
|
|
@@ -179,3 +182,95 @@ describe("runtime slot enforcement", () => {
|
|
|
179
182
|
expect(verifySlotChildren("standard", "Page", [])).toBeNull();
|
|
180
183
|
});
|
|
181
184
|
});
|
|
185
|
+
|
|
186
|
+
// The styling migration (ADR 0094) is renaming rendered roots: components that used to emit
|
|
187
|
+
// an unmarked <div>, or to borrow Popover's `popover`, now name themselves. Every one of those
|
|
188
|
+
// names is read by this check, because the check IS the marker join — so the question is not
|
|
189
|
+
// whether the names look sensible but whether any slot's verdict moved.
|
|
190
|
+
//
|
|
191
|
+
// It did not, and the reason is worth stating rather than re-derived: verifySlotChildren
|
|
192
|
+
// refuses a direct body-slot child whose data-terp is not in the slot's allow table, and a
|
|
193
|
+
// missing attribute is refused too (`marker === null`). None of the new names is in any table,
|
|
194
|
+
// and neither was the unmarked element each replaced. So each of these was refused before the
|
|
195
|
+
// migration and is refused after it. What changed is the MESSAGE — describeElement now reports
|
|
196
|
+
// a named component instead of a bare tag, which is strictly more useful to the person reading
|
|
197
|
+
// the refusal, and is also a string a test could have pinned.
|
|
198
|
+
describe("layout contract survives the roots the styling migration renames", () => {
|
|
199
|
+
it("still refuses a chrome menu in a governed body, and now names it", async () => {
|
|
200
|
+
underContract(
|
|
201
|
+
// The provider renders no element of its own, so the slot's direct child is still the
|
|
202
|
+
// toggle. Without it ThemeToggle returns null and the body is empty — which passes for
|
|
203
|
+
// no reason at all.
|
|
204
|
+
<ThemeProvider>
|
|
205
|
+
<OverviewPage title="Records">
|
|
206
|
+
<ThemeToggle variant="stacked" />
|
|
207
|
+
</OverviewPage>
|
|
208
|
+
</ThemeProvider>,
|
|
209
|
+
);
|
|
210
|
+
await waitFor(() => {
|
|
211
|
+
expect(screen.getByTestId("refused").textContent).toBe(
|
|
212
|
+
slotViolationMessage("standard", "OverviewPage", '<div data-terp="theme-toggle">'),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("still refuses an action cluster in a governed body, and now names it", async () => {
|
|
218
|
+
underContract(
|
|
219
|
+
<DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
|
|
220
|
+
<PageActions primary={<Button>Publish</Button>} />
|
|
221
|
+
</DetailPage>,
|
|
222
|
+
);
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(screen.getByTestId("refused").textContent).toBe(
|
|
225
|
+
slotViolationMessage("standard", "DetailPage", '<div data-terp="page-actions">'),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("keeps every marker the allow tables name out of the migration's way", () => {
|
|
231
|
+
// The tables are the audit surface: a rename of one of THESE would widen or close a slot
|
|
232
|
+
// silently, so they are the markers the migration may not touch without changing both
|
|
233
|
+
// halves of the contract. Asserted as a set so an addition has to be deliberate.
|
|
234
|
+
const named = new Set(
|
|
235
|
+
Object.values(LAYOUT_CONTRACTS.standard!.slots).flatMap((slot) =>
|
|
236
|
+
Object.values(slot.components),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
expect([...named].sort()).toEqual([
|
|
240
|
+
"alert",
|
|
241
|
+
"card",
|
|
242
|
+
"dataview",
|
|
243
|
+
"detail-list",
|
|
244
|
+
"dialog",
|
|
245
|
+
"empty-state",
|
|
246
|
+
"error-state",
|
|
247
|
+
"hubcard",
|
|
248
|
+
"loading-state",
|
|
249
|
+
"module-nav",
|
|
250
|
+
"resource-list",
|
|
251
|
+
"stack",
|
|
252
|
+
"tabs",
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("passes a governed body whose children are still allowed components", async () => {
|
|
257
|
+
// The other direction: the migration must not have widened anything either. A Stack and a
|
|
258
|
+
// Card are allowed, and they stay allowed with their markers unchanged.
|
|
259
|
+
underContract(
|
|
260
|
+
<ThemeProvider>
|
|
261
|
+
<DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
|
|
262
|
+
<Card title="A section">body</Card>
|
|
263
|
+
<Stack>
|
|
264
|
+
<ThemeToggle variant="stacked" />
|
|
265
|
+
</Stack>
|
|
266
|
+
</DetailPage>
|
|
267
|
+
</ThemeProvider>,
|
|
268
|
+
);
|
|
269
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
270
|
+
// And the toggle really did render, or this asserts nothing.
|
|
271
|
+
expect(document.querySelector('[data-terp="theme-toggle"]')).not.toBeNull();
|
|
272
|
+
// Nested inside an allowed container is sanctioned composition — the check reads direct
|
|
273
|
+
// children only, which is what makes a marked root safe to put anywhere below one.
|
|
274
|
+
expect(screen.queryByTestId("refused")).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
});
|