@terpjs/react-core 0.1.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 +190 -0
- package/package.json +44 -0
- package/src/AppShell.test.tsx +152 -0
- package/src/AppShell.tsx +554 -0
- package/src/Authorized.test.tsx +60 -0
- package/src/Authorized.tsx +21 -0
- package/src/Breadcrumbs.test.tsx +45 -0
- package/src/Breadcrumbs.tsx +110 -0
- package/src/ConfirmDialog.tsx +170 -0
- package/src/DetailPage.tsx +28 -0
- package/src/EmptyState.tsx +74 -0
- package/src/ErrorState.tsx +108 -0
- package/src/Field.test.tsx +53 -0
- package/src/Field.tsx +51 -0
- package/src/HubPage.test.tsx +108 -0
- package/src/HubPage.tsx +204 -0
- package/src/LoadingState.test.tsx +40 -0
- package/src/LoadingState.tsx +96 -0
- package/src/LoginView.test.tsx +57 -0
- package/src/LoginView.tsx +203 -0
- package/src/ModuleNav.test.tsx +96 -0
- package/src/ModuleNav.tsx +88 -0
- package/src/OverviewPage.tsx +26 -0
- package/src/Page.test.tsx +147 -0
- package/src/Page.tsx +158 -0
- package/src/PageActions.test.tsx +104 -0
- package/src/PageActions.tsx +72 -0
- package/src/ProfileView.test.tsx +112 -0
- package/src/ProfileView.tsx +84 -0
- package/src/RequireAuth.test.tsx +89 -0
- package/src/RequireAuth.tsx +22 -0
- package/src/ResourceList.test.tsx +176 -0
- package/src/ResourceList.tsx +123 -0
- package/src/TerpProvider.tsx +320 -0
- package/src/UserMenu.test.tsx +166 -0
- package/src/UserMenu.tsx +125 -0
- package/src/admin/AdminHub.tsx +108 -0
- package/src/admin/AuditLogAdmin.tsx +116 -0
- package/src/admin/GroupCreate.tsx +90 -0
- package/src/admin/GroupDetail.tsx +446 -0
- package/src/admin/GroupsAdmin.tsx +109 -0
- package/src/admin/UserCreate.tsx +115 -0
- package/src/admin/UserDetail.tsx +228 -0
- package/src/admin/UsersAdmin.tsx +111 -0
- package/src/admin/admin.test.tsx +537 -0
- package/src/admin/crumbs.tsx +14 -0
- package/src/admin/module.tsx +51 -0
- package/src/admin/roles.ts +19 -0
- package/src/bootstrap.test.tsx +67 -0
- package/src/bootstrap.tsx +270 -0
- package/src/capabilities.test.ts +24 -0
- package/src/capabilities.ts +26 -0
- package/src/createAuthClient.test.ts +176 -0
- package/src/createAuthClient.ts +105 -0
- package/src/dataview/DataView.test.tsx +392 -0
- package/src/dataview/DataView.tsx +467 -0
- package/src/dataview/DataViewCardList.tsx +189 -0
- package/src/dataview/DataViewColumnSettings.tsx +118 -0
- package/src/dataview/DataViewExpandableRow.tsx +67 -0
- package/src/dataview/DataViewPagination.tsx +113 -0
- package/src/dataview/DataViewRowActions.tsx +131 -0
- package/src/dataview/DataViewTable.tsx +359 -0
- package/src/dataview/DataViewToolbar.tsx +260 -0
- package/src/dataview/README.md +138 -0
- package/src/dataview/glyphs.tsx +175 -0
- package/src/dataview/hooks/hooks.test.tsx +240 -0
- package/src/dataview/hooks/useDataViewQuery.ts +72 -0
- package/src/dataview/hooks/useDataViewState.ts +310 -0
- package/src/dataview/hooks/useServerDataView.ts +154 -0
- package/src/dataview/hooks/useViewSearch.ts +68 -0
- package/src/dataview/index.ts +62 -0
- package/src/dataview/internal.tsx +96 -0
- package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
- package/src/dataview/repositories/repositories.test.ts +158 -0
- package/src/dataview/repositories/viewState.test.ts +90 -0
- package/src/dataview/repositories/viewState.ts +128 -0
- package/src/dataview/types.ts +249 -0
- package/src/errorMessages.test.tsx +83 -0
- package/src/errorMessages.tsx +79 -0
- package/src/feedback.test.tsx +167 -0
- package/src/files.test.tsx +142 -0
- package/src/files.tsx +174 -0
- package/src/icons.test.tsx +46 -0
- package/src/icons.tsx +533 -0
- package/src/index.ts +155 -0
- package/src/layout.test.tsx +72 -0
- package/src/layout.tsx +90 -0
- package/src/layoutContract.test.tsx +179 -0
- package/src/layoutContract.ts +137 -0
- package/src/locale.test.tsx +97 -0
- package/src/locale.tsx +246 -0
- package/src/nav.test.ts +21 -0
- package/src/nav.ts +13 -0
- package/src/pageMarker.ts +15 -0
- package/src/raw.d.ts +7 -0
- package/src/realtime-hook.test.tsx +226 -0
- package/src/realtime.test.ts +44 -0
- package/src/realtime.ts +307 -0
- package/src/refresh-session.test.tsx +114 -0
- package/src/revocation.test.tsx +81 -0
- package/src/router.test.tsx +307 -0
- package/src/router.tsx +222 -0
- package/src/sso.test.tsx +128 -0
- package/src/sso.ts +142 -0
- package/src/ssr.test.tsx +45 -0
- package/src/styles.test.ts +21 -0
- package/src/styles.ts +302 -0
- package/src/theme.test.tsx +74 -0
- package/src/theme.tsx +143 -0
- package/src/toast.test.tsx +94 -0
- package/src/toast.tsx +214 -0
- package/src/tokens.guard.test.ts +51 -0
- package/src/ui/Alert.test.tsx +19 -0
- package/src/ui/Alert.tsx +115 -0
- package/src/ui/Badge.test.tsx +14 -0
- package/src/ui/Badge.tsx +48 -0
- package/src/ui/Button.test.tsx +36 -0
- package/src/ui/Button.tsx +95 -0
- package/src/ui/Card.test.tsx +40 -0
- package/src/ui/Card.tsx +92 -0
- package/src/ui/Checkbox.test.tsx +17 -0
- package/src/ui/Checkbox.tsx +51 -0
- package/src/ui/Combobox.test.tsx +58 -0
- package/src/ui/Combobox.tsx +313 -0
- package/src/ui/DatePicker.test.tsx +60 -0
- package/src/ui/DatePicker.tsx +421 -0
- package/src/ui/Input.tsx +30 -0
- package/src/ui/Markdown.test.tsx +32 -0
- package/src/ui/Markdown.tsx +213 -0
- package/src/ui/Menu.test.tsx +85 -0
- package/src/ui/Menu.tsx +216 -0
- package/src/ui/Popover.tsx +218 -0
- package/src/ui/Radio.test.tsx +29 -0
- package/src/ui/Radio.tsx +127 -0
- package/src/ui/Select.tsx +40 -0
- package/src/ui/Switch.test.tsx +17 -0
- package/src/ui/Switch.tsx +53 -0
- package/src/ui/Tabs.test.tsx +29 -0
- package/src/ui/Tabs.tsx +128 -0
- package/src/ui/Textarea.tsx +27 -0
- package/src/ui/Tooltip.test.tsx +28 -0
- package/src/ui/Tooltip.tsx +67 -0
- package/src/ui/controlStyles.ts +9 -0
- package/src/uiText.test.tsx +93 -0
- package/src/uiText.tsx +342 -0
- package/src/unwrap.test.ts +67 -0
- package/src/unwrap.ts +101 -0
- package/src/useResource.test.tsx +118 -0
- package/src/useResource.ts +110 -0
- package/src/useTerpClient.test.ts +35 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +14 -0
- package/vitest.setup.ts +58 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "./styles";
|
|
4
|
+
import { useStrings, useUiText } from "./uiText";
|
|
5
|
+
import type { UiText } from "./uiText";
|
|
6
|
+
|
|
7
|
+
injectTerpStyles();
|
|
8
|
+
|
|
9
|
+
/** One breadcrumb: a label plus, for ancestor levels, the path it links back to. */
|
|
10
|
+
export interface BreadcrumbItem {
|
|
11
|
+
/** The crumb text (e.g. the module title, or the record's display name). */
|
|
12
|
+
label: UiText;
|
|
13
|
+
/** Destination path for ancestor crumbs; the current page's crumb omits it. */
|
|
14
|
+
to?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Turns an ancestor crumb into the active stack's link (keeps the trail router-agnostic). */
|
|
18
|
+
export type RenderBreadcrumbLink = (item: { label: string; to: string }) => ReactNode;
|
|
19
|
+
|
|
20
|
+
export interface BreadcrumbsProps {
|
|
21
|
+
/** The trail, outermost first; the last item is the current page. */
|
|
22
|
+
items: readonly BreadcrumbItem[];
|
|
23
|
+
/** Link renderer for ancestor crumbs (default: a plain `<a href>`); pass the stack's `Link`. */
|
|
24
|
+
renderLink?: RenderBreadcrumbLink;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const navStyle: CSSProperties = {
|
|
28
|
+
fontSize: "var(--font-size-sm)",
|
|
29
|
+
color: "var(--color-neutral-600)",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const listStyle: CSSProperties = {
|
|
33
|
+
listStyle: "none",
|
|
34
|
+
margin: 0,
|
|
35
|
+
padding: 0,
|
|
36
|
+
display: "flex",
|
|
37
|
+
flexWrap: "wrap",
|
|
38
|
+
alignItems: "center",
|
|
39
|
+
gap: "var(--space-2)",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const currentStyle: CSSProperties = {
|
|
43
|
+
color: "var(--color-neutral-900)",
|
|
44
|
+
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const separatorStyle: CSSProperties = {
|
|
48
|
+
display: "inline-flex",
|
|
49
|
+
color: "var(--color-neutral-400)",
|
|
50
|
+
lineHeight: 0,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function ChevronSeparator() {
|
|
54
|
+
return (
|
|
55
|
+
<svg
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
focusable={false}
|
|
58
|
+
width="0.85em"
|
|
59
|
+
height="0.85em"
|
|
60
|
+
viewBox="0 0 24 24"
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke="currentColor"
|
|
63
|
+
strokeWidth={2}
|
|
64
|
+
strokeLinecap="round"
|
|
65
|
+
strokeLinejoin="round"
|
|
66
|
+
>
|
|
67
|
+
<path d="m9 6 6 6-6 6" />
|
|
68
|
+
</svg>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const defaultRenderLink: RenderBreadcrumbLink = (item) => <a href={item.to}>{item.label}</a>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The breadcrumb trail every page shows through the remaining layers (shell -> overview ->
|
|
76
|
+
* detail). Accessible by construction: a `nav` landmark labelled "Breadcrumb", an ordered
|
|
77
|
+
* list, and `aria-current="page"` on the final crumb. Router-agnostic — `renderLink` turns
|
|
78
|
+
* an ancestor crumb into the active stack's link, exactly like `AppShell`'s `renderLink`.
|
|
79
|
+
*/
|
|
80
|
+
export function Breadcrumbs({ items, renderLink = defaultRenderLink }: BreadcrumbsProps) {
|
|
81
|
+
const strings = useStrings();
|
|
82
|
+
const resolve = useUiText();
|
|
83
|
+
return (
|
|
84
|
+
<nav aria-label={strings.breadcrumbsLabel} data-terp="breadcrumbs" style={navStyle}>
|
|
85
|
+
<ol style={listStyle}>
|
|
86
|
+
{items.map((item, index) => {
|
|
87
|
+
const isLast = index === items.length - 1;
|
|
88
|
+
const label = resolve(item.label);
|
|
89
|
+
return (
|
|
90
|
+
<li key={`${index}-${label}`} style={listStyle}>
|
|
91
|
+
{!isLast && item.to !== undefined ? (
|
|
92
|
+
renderLink({ label, to: item.to })
|
|
93
|
+
) : (
|
|
94
|
+
<span aria-current={isLast ? "page" : undefined} style={isLast ? currentStyle : undefined}>
|
|
95
|
+
{label}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
{!isLast && (
|
|
99
|
+
<span aria-hidden="true" style={separatorStyle}>
|
|
100
|
+
<ChevronSeparator />
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
</li>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</ol>
|
|
107
|
+
</nav>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useEffect, useId, useRef } from "react";
|
|
2
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { Button } from "./ui/Button";
|
|
5
|
+
import { injectTerpStyles } from "./styles";
|
|
6
|
+
import { useStrings, useUiText } from "./uiText";
|
|
7
|
+
import type { UiText } from "./uiText";
|
|
8
|
+
|
|
9
|
+
injectTerpStyles();
|
|
10
|
+
|
|
11
|
+
export interface ConfirmDialogProps {
|
|
12
|
+
/** Whether the dialog is shown (controlled). */
|
|
13
|
+
open: boolean;
|
|
14
|
+
/** Called with `false` when the user dismisses (cancel, backdrop click, Escape). */
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
/** Called when the user confirms. The caller closes the dialog when the action settles. */
|
|
17
|
+
onConfirm: () => void;
|
|
18
|
+
/** Short question — what is about to happen. */
|
|
19
|
+
title: UiText;
|
|
20
|
+
/** Optional consequence explanation. */
|
|
21
|
+
description?: ReactNode;
|
|
22
|
+
/** Confirm-button label; defaults to the `confirm` string. */
|
|
23
|
+
confirmLabel?: UiText;
|
|
24
|
+
/** Cancel-button label; defaults to the `cancel` string. */
|
|
25
|
+
cancelLabel?: UiText;
|
|
26
|
+
/** Style the confirm button as destructive (delete / remove / deactivate). */
|
|
27
|
+
destructive?: boolean;
|
|
28
|
+
/** Keep the dialog inert while the confirmed action is in flight: disables both
|
|
29
|
+
* buttons and blocks Escape / backdrop dismissal until the action settles. */
|
|
30
|
+
isPending?: boolean;
|
|
31
|
+
/** Disable confirmation until required dialog input is valid. */
|
|
32
|
+
confirmDisabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dialogStyle: CSSProperties = {
|
|
36
|
+
width: "100%",
|
|
37
|
+
maxWidth: "26rem",
|
|
38
|
+
padding: 0,
|
|
39
|
+
border: "1px solid var(--color-neutral-200)",
|
|
40
|
+
borderRadius: "var(--radius-lg)",
|
|
41
|
+
boxShadow: "var(--shadow-lg)",
|
|
42
|
+
background: "var(--color-neutral-0)",
|
|
43
|
+
color: "var(--color-neutral-900)",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const bodyStyle: CSSProperties = {
|
|
47
|
+
display: "grid",
|
|
48
|
+
gap: "var(--space-3)",
|
|
49
|
+
padding: "var(--space-6)",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const dialogTitleStyle: CSSProperties = {
|
|
53
|
+
margin: 0,
|
|
54
|
+
fontSize: "var(--font-size-lg)",
|
|
55
|
+
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
56
|
+
letterSpacing: 0,
|
|
57
|
+
color: "var(--color-neutral-900)",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const descriptionStyle: CSSProperties = {
|
|
61
|
+
color: "var(--color-neutral-600)",
|
|
62
|
+
fontSize: "var(--font-size-sm)",
|
|
63
|
+
lineHeight: 1.5,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const footerStyle: CSSProperties = {
|
|
67
|
+
display: "flex",
|
|
68
|
+
justifyContent: "flex-end",
|
|
69
|
+
gap: "var(--space-2)",
|
|
70
|
+
marginTop: "var(--space-2)",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The shared confirmation dialog — replaces `window.confirm()` with an accessible,
|
|
75
|
+
* token-styled modal. Use it before any destructive or irreversible action (delete,
|
|
76
|
+
* remove, deactivate), with `destructive` marking the confirm button.
|
|
77
|
+
*
|
|
78
|
+
* Built on the native `<dialog>` element via `showModal()`, so the platform provides
|
|
79
|
+
* the modal contract for free: initial focus, focus trapping, focus restore to the
|
|
80
|
+
* opener, Escape handling, and top-layer rendering (never clipped by ancestor
|
|
81
|
+
* overflow/transform, always above toasts). Dismissal is possible via the cancel
|
|
82
|
+
* button, backdrop click, or Escape — except while `isPending`, which keeps the
|
|
83
|
+
* dialog inert until the confirmed action settles. The element stays mounted while
|
|
84
|
+
* closed (hidden by the UA `dialog:not([open])` rule) so closing goes through
|
|
85
|
+
* `close()` — the step that restores focus to the opener.
|
|
86
|
+
*/
|
|
87
|
+
export function ConfirmDialog({
|
|
88
|
+
open,
|
|
89
|
+
onOpenChange,
|
|
90
|
+
onConfirm,
|
|
91
|
+
title,
|
|
92
|
+
description,
|
|
93
|
+
confirmLabel,
|
|
94
|
+
cancelLabel,
|
|
95
|
+
destructive,
|
|
96
|
+
isPending,
|
|
97
|
+
confirmDisabled,
|
|
98
|
+
}: ConfirmDialogProps) {
|
|
99
|
+
const strings = useStrings();
|
|
100
|
+
const resolve = useUiText();
|
|
101
|
+
const titleId = useId();
|
|
102
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const dialog = dialogRef.current;
|
|
106
|
+
if (dialog === null) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (open && !dialog.open) {
|
|
110
|
+
dialog.showModal();
|
|
111
|
+
} else if (!open && dialog.open) {
|
|
112
|
+
dialog.close();
|
|
113
|
+
}
|
|
114
|
+
}, [open]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!open) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// The top layer blocks interaction with the page but not scrolling behind it.
|
|
121
|
+
const previous = document.body.style.overflow;
|
|
122
|
+
document.body.style.overflow = "hidden";
|
|
123
|
+
return () => {
|
|
124
|
+
document.body.style.overflow = previous;
|
|
125
|
+
};
|
|
126
|
+
}, [open]);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<dialog
|
|
130
|
+
ref={dialogRef}
|
|
131
|
+
data-terp="dialog"
|
|
132
|
+
aria-labelledby={titleId}
|
|
133
|
+
style={dialogStyle}
|
|
134
|
+
onCancel={(event) => {
|
|
135
|
+
// Escape: stay controlled — never let the platform close the element itself.
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
if (isPending !== true) {
|
|
138
|
+
onOpenChange(false);
|
|
139
|
+
}
|
|
140
|
+
}}
|
|
141
|
+
onClick={(event) => {
|
|
142
|
+
// A click on the backdrop targets the <dialog> element itself.
|
|
143
|
+
if (event.target === dialogRef.current && isPending !== true) {
|
|
144
|
+
onOpenChange(false);
|
|
145
|
+
}
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<div style={bodyStyle}>
|
|
149
|
+
<h2 id={titleId} style={dialogTitleStyle}>
|
|
150
|
+
{resolve(title)}
|
|
151
|
+
</h2>
|
|
152
|
+
{description !== undefined && (
|
|
153
|
+
<div style={descriptionStyle}>{description}</div>
|
|
154
|
+
)}
|
|
155
|
+
<div style={footerStyle}>
|
|
156
|
+
<Button variant="secondary" disabled={isPending} onClick={() => onOpenChange(false)}>
|
|
157
|
+
{resolve(cancelLabel ?? strings.cancel)}
|
|
158
|
+
</Button>
|
|
159
|
+
<Button
|
|
160
|
+
variant={destructive ? "danger" : "primary"}
|
|
161
|
+
disabled={isPending || confirmDisabled}
|
|
162
|
+
onClick={onConfirm}
|
|
163
|
+
>
|
|
164
|
+
{resolve(confirmLabel ?? strings.confirm)}
|
|
165
|
+
</Button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</dialog>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Page } from "./Page";
|
|
2
|
+
import type { PageProps } from "./Page";
|
|
3
|
+
import type { BreadcrumbItem } from "./Breadcrumbs";
|
|
4
|
+
import { LayoutSlotContext } from "./layoutContract";
|
|
5
|
+
|
|
6
|
+
export interface DetailPageProps extends Omit<PageProps, "breadcrumbs"> {
|
|
7
|
+
/**
|
|
8
|
+
* The overview (and any intermediate layers) this record lives under, outermost first.
|
|
9
|
+
* Every ancestor crumb must link back up, so `to` is required — a detail page is never
|
|
10
|
+
* orphaned from its overview.
|
|
11
|
+
*/
|
|
12
|
+
parents: readonly (BreadcrumbItem & { to: string })[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The detail (level-3) page archetype: one record's screen, reached from an overview. It is a
|
|
17
|
+
* `Page` whose breadcrumb trail is the ancestor layers plus the record itself (`title`), so
|
|
18
|
+
* users can always navigate back up — the shell -> overview -> detail layering by construction.
|
|
19
|
+
* With a layout contract active (ADR 0079), the body slot accepts only the contract's record
|
|
20
|
+
* components (e.g. `DetailList` / `Stack` / `Tabs`) — refused fail closed otherwise.
|
|
21
|
+
*/
|
|
22
|
+
export function DetailPage({ parents, ...page }: DetailPageProps) {
|
|
23
|
+
return (
|
|
24
|
+
<LayoutSlotContext.Provider value="DetailPage">
|
|
25
|
+
<Page breadcrumbs={parents} {...page} />
|
|
26
|
+
</LayoutSlotContext.Provider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { Icon } from "./icons";
|
|
4
|
+
import { useUiText } from "./uiText";
|
|
5
|
+
import type { UiText } from "./uiText";
|
|
6
|
+
|
|
7
|
+
export interface EmptyStateProps {
|
|
8
|
+
/**
|
|
9
|
+
* Optional leading visual (any rendered node — react-core takes no icon dependency).
|
|
10
|
+
* Defaults to a muted inbox glyph so the block always has a recognisable frame.
|
|
11
|
+
*/
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
/** Short title — what is missing. */
|
|
14
|
+
title: UiText;
|
|
15
|
+
/** Optional explanation — why it's missing, or what to do next. */
|
|
16
|
+
description?: ReactNode;
|
|
17
|
+
/** Optional call to action (typically a `Button`). */
|
|
18
|
+
action?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const wrapStyle: CSSProperties = {
|
|
22
|
+
display: "grid",
|
|
23
|
+
justifyItems: "center",
|
|
24
|
+
gap: "var(--space-3)",
|
|
25
|
+
padding: "var(--space-8) var(--space-6)",
|
|
26
|
+
textAlign: "center",
|
|
27
|
+
color: "var(--color-neutral-600)",
|
|
28
|
+
border: "1px dashed var(--color-neutral-300)",
|
|
29
|
+
borderRadius: "var(--radius-lg)",
|
|
30
|
+
background: "var(--color-neutral-0)",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const iconStyle: CSSProperties = {
|
|
34
|
+
color: "var(--color-neutral-400)",
|
|
35
|
+
display: "inline-flex",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const titleStyle: CSSProperties = {
|
|
39
|
+
margin: 0,
|
|
40
|
+
color: "var(--color-neutral-900)",
|
|
41
|
+
fontSize: "var(--font-size-base)",
|
|
42
|
+
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const descriptionStyle: CSSProperties = {
|
|
46
|
+
color: "var(--color-neutral-600)",
|
|
47
|
+
fontSize: "var(--font-size-sm)",
|
|
48
|
+
lineHeight: 1.5,
|
|
49
|
+
maxWidth: "36ch",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The standard "nothing here yet" block: use whenever a query legitimately returns zero
|
|
54
|
+
* rows, a module is not wired to data yet, or a feature is gated. One recognisable empty
|
|
55
|
+
* UX platform-wide tells the user "this is not an error — there is just nothing to show",
|
|
56
|
+
* and the `action` slot turns the dead end into the obvious next step.
|
|
57
|
+
*/
|
|
58
|
+
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
|
59
|
+
const resolve = useUiText();
|
|
60
|
+
const leading = icon ?? (
|
|
61
|
+
<span style={iconStyle}>
|
|
62
|
+
<Icon name="inbox" size="2rem" />
|
|
63
|
+
</span>
|
|
64
|
+
);
|
|
65
|
+
return (
|
|
66
|
+
<div data-terp="empty-state" style={wrapStyle}>
|
|
67
|
+
{leading}
|
|
68
|
+
<p style={titleStyle}>{resolve(title)}</p>
|
|
69
|
+
{description !== undefined && <div style={descriptionStyle}>{description}</div>}
|
|
70
|
+
{action}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useErrorMessage } from "./errorMessages";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import { useStrings, useUiText } from "./uiText";
|
|
6
|
+
import type { UiText } from "./uiText";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Best-effort human-readable message for a caught failure. `unwrap` already throws
|
|
10
|
+
* `Error`s carrying the backend envelope's `detail`, so most callers just surface
|
|
11
|
+
* `error.message`; raw envelope objects (`detail` / `code`) and plain strings are
|
|
12
|
+
* also understood. Returns `null` when nothing useful can be derived.
|
|
13
|
+
*/
|
|
14
|
+
export function describeError(error: unknown): string | null {
|
|
15
|
+
if (typeof error === "string" && error.length > 0) {
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
if (error instanceof Error && error.message.length > 0) {
|
|
19
|
+
return error.message;
|
|
20
|
+
}
|
|
21
|
+
if (error !== null && typeof error === "object") {
|
|
22
|
+
const envelope = error as { detail?: unknown; code?: unknown };
|
|
23
|
+
if (typeof envelope.detail === "string" && envelope.detail.length > 0) {
|
|
24
|
+
return envelope.detail;
|
|
25
|
+
}
|
|
26
|
+
if (typeof envelope.code === "string" && envelope.code.length > 0) {
|
|
27
|
+
return envelope.code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ErrorStateProps {
|
|
34
|
+
/** Optional leading visual (any rendered node — react-core takes no icon dependency). */
|
|
35
|
+
icon?: ReactNode;
|
|
36
|
+
/** Short title — what failed. Defaults to the `errorTitle` string. */
|
|
37
|
+
title?: UiText;
|
|
38
|
+
/**
|
|
39
|
+
* Explanation. When omitted and `error` is set, the message is the registered
|
|
40
|
+
* copy for the error's stable `code` (see `useErrorMessage`), falling back to
|
|
41
|
+
* {@link describeError}, so the platform error envelope surfaces consistently.
|
|
42
|
+
*/
|
|
43
|
+
description?: ReactNode;
|
|
44
|
+
/** The caught failure — used to derive `description` when none is given. */
|
|
45
|
+
error?: unknown;
|
|
46
|
+
/** Optional call to action (typically a retry `Button`). */
|
|
47
|
+
action?: ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const wrapStyle: CSSProperties = {
|
|
51
|
+
display: "grid",
|
|
52
|
+
justifyItems: "center",
|
|
53
|
+
gap: "var(--space-3)",
|
|
54
|
+
padding: "var(--space-6)",
|
|
55
|
+
textAlign: "center",
|
|
56
|
+
color: "var(--color-neutral-700)",
|
|
57
|
+
background: "var(--color-status-danger-soft)",
|
|
58
|
+
border: "1px solid var(--color-status-danger)",
|
|
59
|
+
borderRadius: "var(--radius-lg)",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const titleStyle: CSSProperties = {
|
|
63
|
+
margin: 0,
|
|
64
|
+
color: "var(--color-status-danger)",
|
|
65
|
+
fontSize: "var(--font-size-base)",
|
|
66
|
+
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const iconStyle: CSSProperties = {
|
|
70
|
+
color: "var(--color-status-danger)",
|
|
71
|
+
display: "inline-flex",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const descriptionStyle: CSSProperties = {
|
|
75
|
+
color: "var(--color-neutral-700)",
|
|
76
|
+
fontSize: "var(--font-size-sm)",
|
|
77
|
+
lineHeight: 1.5,
|
|
78
|
+
maxWidth: "48ch",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The standard "something went wrong" block: use whenever a query fails (404 / 403 /
|
|
83
|
+
* network) and the page frame is already rendered. Announced as an `alert` so assistive
|
|
84
|
+
* tech hears the failure; the message comes from the platform error envelope by default,
|
|
85
|
+
* so failures read the same platform-wide. Pairs with `Page`'s `errorState` slot.
|
|
86
|
+
*/
|
|
87
|
+
export function ErrorState({ icon, title, description, error, action }: ErrorStateProps) {
|
|
88
|
+
const strings = useStrings();
|
|
89
|
+
const resolve = useUiText();
|
|
90
|
+
const messageForCode = useErrorMessage();
|
|
91
|
+
const message =
|
|
92
|
+
description ??
|
|
93
|
+
(error !== undefined ? (messageForCode(error) ?? describeError(error)) : null);
|
|
94
|
+
const leading =
|
|
95
|
+
icon ?? (
|
|
96
|
+
<span style={iconStyle}>
|
|
97
|
+
<Icon name="x" size="1.75rem" />
|
|
98
|
+
</span>
|
|
99
|
+
);
|
|
100
|
+
return (
|
|
101
|
+
<div role="alert" data-terp="error-state" style={wrapStyle}>
|
|
102
|
+
{leading}
|
|
103
|
+
<p style={titleStyle}>{resolve(title ?? strings.errorTitle)}</p>
|
|
104
|
+
{message !== null && message !== undefined && <div style={descriptionStyle}>{message}</div>}
|
|
105
|
+
{action}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Field } from "./Field";
|
|
6
|
+
import { Input } from "./ui/Input";
|
|
7
|
+
import { Select } from "./ui/Select";
|
|
8
|
+
import { Textarea } from "./ui/Textarea";
|
|
9
|
+
|
|
10
|
+
afterEach(cleanup);
|
|
11
|
+
|
|
12
|
+
describe("Field", () => {
|
|
13
|
+
it("labels its control (accessible association) and shows hint + error", () => {
|
|
14
|
+
render(
|
|
15
|
+
<Field label="Email" hint="we never share it" error="required">
|
|
16
|
+
<Input defaultValue="" />
|
|
17
|
+
</Field>,
|
|
18
|
+
);
|
|
19
|
+
// The control is reachable by its label text (implicit association via the wrapping <label>).
|
|
20
|
+
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByText("we never share it")).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByText("required")).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders no error node when error is null", () => {
|
|
26
|
+
render(
|
|
27
|
+
<Field label="Name" error={null}>
|
|
28
|
+
<Input defaultValue="" />
|
|
29
|
+
</Field>,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("Select / Textarea primitives", () => {
|
|
36
|
+
it("render token-styled controls reachable by their Field label", () => {
|
|
37
|
+
render(
|
|
38
|
+
<>
|
|
39
|
+
<Field label="Status">
|
|
40
|
+
<Select defaultValue="open">
|
|
41
|
+
<option value="open">open</option>
|
|
42
|
+
<option value="done">done</option>
|
|
43
|
+
</Select>
|
|
44
|
+
</Field>
|
|
45
|
+
<Field label="Notes">
|
|
46
|
+
<Textarea defaultValue="" />
|
|
47
|
+
</Field>
|
|
48
|
+
</>,
|
|
49
|
+
);
|
|
50
|
+
expect(screen.getByLabelText("Status")).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByLabelText("Notes")).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/Field.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useUiText } from "./uiText";
|
|
4
|
+
import type { UiText } from "./uiText";
|
|
5
|
+
|
|
6
|
+
const fieldStyle: CSSProperties = { display: "grid", gap: "var(--space-1)" };
|
|
7
|
+
const labelStyle: CSSProperties = {
|
|
8
|
+
fontWeight: "var(--font-weight-medium)" as never,
|
|
9
|
+
fontSize: "var(--font-size-sm)",
|
|
10
|
+
color: "var(--color-neutral-700)",
|
|
11
|
+
};
|
|
12
|
+
const hintStyle: CSSProperties = {
|
|
13
|
+
color: "var(--color-neutral-500)",
|
|
14
|
+
fontSize: "var(--font-size-xs)",
|
|
15
|
+
};
|
|
16
|
+
const errorStyle: CSSProperties = {
|
|
17
|
+
color: "var(--color-status-danger)",
|
|
18
|
+
fontSize: "var(--font-size-xs)",
|
|
19
|
+
fontWeight: "var(--font-weight-medium)" as never,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface FieldProps {
|
|
23
|
+
/** The field label (also the control's accessible name — the control is wrapped in the `<label>`). */
|
|
24
|
+
label: UiText;
|
|
25
|
+
/** The control: an `<Input>`, `<Select>`, or `<Textarea>`. */
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
/** A field-level error (e.g. mapped from a 422), shown under the control. */
|
|
28
|
+
error?: string | null;
|
|
29
|
+
/** Optional helper text under the control. */
|
|
30
|
+
hint?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A labelled form field: wraps a control in a `<label>` (so the label is its accessible name with no
|
|
35
|
+
* id wiring) and renders an optional hint + a field-level error. Compose it with the token-styled
|
|
36
|
+
* `Input` / `Select` / `Textarea` primitives to build a multi-field form — the centralized, accessible
|
|
37
|
+
* way every module authors inputs.
|
|
38
|
+
*/
|
|
39
|
+
export function Field({ label, children, error, hint }: FieldProps) {
|
|
40
|
+
const resolve = useUiText();
|
|
41
|
+
return (
|
|
42
|
+
<div style={fieldStyle}>
|
|
43
|
+
<label style={fieldStyle}>
|
|
44
|
+
<span style={labelStyle}>{resolve(label)}</span>
|
|
45
|
+
{children}
|
|
46
|
+
</label>
|
|
47
|
+
{hint !== undefined && <span style={hintStyle}>{hint}</span>}
|
|
48
|
+
{error !== undefined && error !== null && <span style={errorStyle}>{error}</span>}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|