@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
package/src/AppShell.tsx
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import type { NavItem } from "@terpjs/contract";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Icon, NavIcon, TerpMark } from "./icons";
|
|
6
|
+
import { LanguageSwitcher } from "./locale";
|
|
7
|
+
import { injectTerpStyles } from "./styles";
|
|
8
|
+
import { ThemeToggle } from "./theme";
|
|
9
|
+
import { CONTROL_TEXT_STYLE } from "./ui/controlStyles";
|
|
10
|
+
import { useStrings, useUiText } from "./uiText";
|
|
11
|
+
import type { UiText } from "./uiText";
|
|
12
|
+
|
|
13
|
+
injectTerpStyles();
|
|
14
|
+
|
|
15
|
+
/** Context the shell passes to slot renderers (the collapsed icon-rail state). */
|
|
16
|
+
export interface AppShellSlotContext {
|
|
17
|
+
collapsed: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AppShellLinkContext extends AppShellSlotContext {
|
|
21
|
+
style: CSSProperties;
|
|
22
|
+
activeStyle: CSSProperties;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RenderBrandLink = (props: {
|
|
26
|
+
to: string;
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
style: CSSProperties;
|
|
29
|
+
}) => ReactNode;
|
|
30
|
+
|
|
31
|
+
export interface AppShellProps {
|
|
32
|
+
/** Product / app title shown next to the logo at the top of the sidebar. */
|
|
33
|
+
title: UiText;
|
|
34
|
+
/** Sidebar nav, already filtered for the current user (see `visibleNav`). */
|
|
35
|
+
nav: readonly NavItem[];
|
|
36
|
+
/**
|
|
37
|
+
* Turns a nav item into the active stack's link around the shell-styled
|
|
38
|
+
* `children` (icon + label), keeping the shell router-agnostic. Spread
|
|
39
|
+
* `context.style` (and `context.activeStyle` on the active route) onto the
|
|
40
|
+
* link element — the shell owns the expanded/collapsed link geometry, so
|
|
41
|
+
* every stack's links look identical in both rail states.
|
|
42
|
+
*/
|
|
43
|
+
renderLink: (item: NavItem, children: ReactNode, context: AppShellLinkContext) => ReactNode;
|
|
44
|
+
/** Turns the product brand into the home link; defaults to a plain anchor to `/`. */
|
|
45
|
+
renderBrandLink?: RenderBrandLink;
|
|
46
|
+
/** Brand mark at the top of the sidebar; default: the {@link TerpMark} placeholder. */
|
|
47
|
+
logo?: ReactNode;
|
|
48
|
+
/** Extra header content, rendered before the theme / language controls. */
|
|
49
|
+
headerActions?: ReactNode;
|
|
50
|
+
/** Pinned to the bottom of the sidebar (the {@link UserMenu}); may read the rail state. */
|
|
51
|
+
navFooter?: ReactNode | ((context: AppShellSlotContext) => ReactNode);
|
|
52
|
+
/** Footer line under the content; default: a muted line with the app title. */
|
|
53
|
+
footer?: ReactNode;
|
|
54
|
+
/** The routed page content. */
|
|
55
|
+
children: ReactNode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The `localStorage` key the sidebar's collapsed choice persists under. */
|
|
59
|
+
export const SIDEBAR_STORAGE_KEY = "terp.sidebar";
|
|
60
|
+
|
|
61
|
+
/** Below this width the sidebar becomes an overlay drawer (matches DataView's card cutover). */
|
|
62
|
+
const MOBILE_BREAKPOINT = "(max-width: 768px)";
|
|
63
|
+
|
|
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-brand-primary)",
|
|
97
|
+
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function useIsMobile(): boolean {
|
|
101
|
+
const [isMobile, setIsMobile] = useState(
|
|
102
|
+
() =>
|
|
103
|
+
typeof window !== "undefined" &&
|
|
104
|
+
typeof window.matchMedia === "function" &&
|
|
105
|
+
window.matchMedia(MOBILE_BREAKPOINT).matches,
|
|
106
|
+
);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const media = window.matchMedia(MOBILE_BREAKPOINT);
|
|
112
|
+
const onChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
|
|
113
|
+
media.addEventListener("change", onChange);
|
|
114
|
+
return () => media.removeEventListener("change", onChange);
|
|
115
|
+
}, []);
|
|
116
|
+
return isMobile;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readStoredCollapsed(): boolean {
|
|
120
|
+
if (typeof window === "undefined") {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "collapsed";
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const shellStyle: CSSProperties = {
|
|
131
|
+
display: "flex",
|
|
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}>
|
|
288
|
+
{children}
|
|
289
|
+
</a>
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
function PanelIcon() {
|
|
293
|
+
return (
|
|
294
|
+
<svg
|
|
295
|
+
width="1.25em"
|
|
296
|
+
height="1.25em"
|
|
297
|
+
viewBox="0 0 24 24"
|
|
298
|
+
fill="none"
|
|
299
|
+
stroke="currentColor"
|
|
300
|
+
strokeWidth={1.8}
|
|
301
|
+
strokeLinecap="round"
|
|
302
|
+
aria-hidden="true"
|
|
303
|
+
focusable={false}
|
|
304
|
+
>
|
|
305
|
+
<rect x="3" y="4.5" width="18" height="15" rx="1.5" />
|
|
306
|
+
<path d="M9.5 4.5v15" />
|
|
307
|
+
</svg>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The app shell every Terp screen lives in — responsive and mobile-ready by default:
|
|
313
|
+
*
|
|
314
|
+
* - a full-height sidebar: brand (logo + title) on top, the role-filtered nav with
|
|
315
|
+
* per-item icons, and the `navFooter` (the {@link UserMenu}) pinned to the bottom.
|
|
316
|
+
* On desktop it collapses to an icon rail (persisted in `localStorage`); below the
|
|
317
|
+
* mobile breakpoint it becomes an overlay drawer with a backdrop;
|
|
318
|
+
* - a **sticky** header over the content: the sidebar toggle on the left, then
|
|
319
|
+
* `headerActions` and the standard theme + language controls on the right;
|
|
320
|
+
* - the routed `children` in a `main` landmark, with a slim `footer` underneath.
|
|
321
|
+
*
|
|
322
|
+
* Router-agnostic: `renderLink` wraps the shell-styled icon + label in the active
|
|
323
|
+
* stack's link. Landmarks (`header` / `nav` / `main` / `footer`) keep it accessible.
|
|
324
|
+
*/
|
|
325
|
+
export function AppShell({
|
|
326
|
+
title,
|
|
327
|
+
nav,
|
|
328
|
+
renderLink,
|
|
329
|
+
renderBrandLink = defaultRenderBrandLink,
|
|
330
|
+
logo,
|
|
331
|
+
headerActions,
|
|
332
|
+
navFooter,
|
|
333
|
+
footer,
|
|
334
|
+
children,
|
|
335
|
+
}: AppShellProps) {
|
|
336
|
+
const resolve = useUiText();
|
|
337
|
+
const strings = useStrings();
|
|
338
|
+
const isMobile = useIsMobile();
|
|
339
|
+
const [collapsed, setCollapsed] = useState(readStoredCollapsed);
|
|
340
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
341
|
+
const drawerRef = useRef<HTMLElement>(null);
|
|
342
|
+
const drawerCloseRef = useRef<HTMLButtonElement>(null);
|
|
343
|
+
const toggleRef = useRef<HTMLButtonElement>(null);
|
|
344
|
+
|
|
345
|
+
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (!isMobile || !drawerOpen) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
drawerCloseRef.current?.focus();
|
|
352
|
+
return () => {
|
|
353
|
+
window.setTimeout(() => toggleRef.current?.focus(), 0);
|
|
354
|
+
};
|
|
355
|
+
}, [isMobile, drawerOpen]);
|
|
356
|
+
|
|
357
|
+
function onDrawerKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
|
358
|
+
if (event.key === "Escape") {
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
closeDrawer();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function focusDrawerEdge(edge: "first" | "last") {
|
|
365
|
+
const focusable = drawerRef.current?.querySelectorAll<HTMLElement>(
|
|
366
|
+
'a[href], button:not(:disabled)',
|
|
367
|
+
);
|
|
368
|
+
const target = edge === "first" ? focusable?.[0] : focusable?.[focusable.length - 1];
|
|
369
|
+
(target ?? drawerRef.current)?.focus();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (!isMobile || !drawerOpen) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const previous = document.body.style.overflow;
|
|
377
|
+
document.body.style.overflow = "hidden";
|
|
378
|
+
return () => {
|
|
379
|
+
document.body.style.overflow = previous;
|
|
380
|
+
};
|
|
381
|
+
}, [isMobile, drawerOpen]);
|
|
382
|
+
|
|
383
|
+
function toggleSidebar() {
|
|
384
|
+
if (isMobile) {
|
|
385
|
+
setDrawerOpen((current) => !current);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
setCollapsed((current) => {
|
|
389
|
+
const next = !current;
|
|
390
|
+
try {
|
|
391
|
+
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, next ? "collapsed" : "expanded");
|
|
392
|
+
} catch {
|
|
393
|
+
// Private mode / quota: the choice still applies for this session.
|
|
394
|
+
}
|
|
395
|
+
return next;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// The drawer always shows labels; the desktop rail hides them when collapsed.
|
|
400
|
+
const railCollapsed = !isMobile && collapsed;
|
|
401
|
+
const context: AppShellSlotContext = { collapsed: railCollapsed };
|
|
402
|
+
const linkStyle = railCollapsed
|
|
403
|
+
? { ...NAV_LINK_STYLE, ...NAV_LINK_COLLAPSED_STYLE }
|
|
404
|
+
: NAV_LINK_STYLE;
|
|
405
|
+
const resolvedTitle = resolve(title);
|
|
406
|
+
|
|
407
|
+
const brand = renderBrandLink({
|
|
408
|
+
to: "/",
|
|
409
|
+
style: railCollapsed
|
|
410
|
+
? { ...brandLinkStyle, justifyContent: "center", paddingInline: 0 }
|
|
411
|
+
: isMobile
|
|
412
|
+
? { ...brandLinkStyle, flex: 1, minWidth: 0 }
|
|
413
|
+
: brandLinkStyle,
|
|
414
|
+
children: (
|
|
415
|
+
<>
|
|
416
|
+
{logo ?? <TerpMark />}
|
|
417
|
+
<strong style={railCollapsed ? visuallyHiddenStyle : brandTitleStyle}>
|
|
418
|
+
{resolvedTitle}
|
|
419
|
+
</strong>
|
|
420
|
+
</>
|
|
421
|
+
),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const sidebar = (
|
|
425
|
+
<aside
|
|
426
|
+
ref={isMobile ? drawerRef : undefined}
|
|
427
|
+
role={isMobile ? "dialog" : undefined}
|
|
428
|
+
aria-modal={isMobile ? true : undefined}
|
|
429
|
+
aria-label={isMobile ? strings.primaryNavigationLabel : undefined}
|
|
430
|
+
tabIndex={isMobile ? -1 : undefined}
|
|
431
|
+
onKeyDown={isMobile ? onDrawerKeyDown : undefined}
|
|
432
|
+
style={
|
|
433
|
+
isMobile
|
|
434
|
+
? drawerStyle
|
|
435
|
+
: { ...sidebarStyle, width: railCollapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH }
|
|
436
|
+
}
|
|
437
|
+
>
|
|
438
|
+
{isMobile && (
|
|
439
|
+
<span
|
|
440
|
+
data-terp="drawer-focus-start"
|
|
441
|
+
tabIndex={0}
|
|
442
|
+
style={visuallyHiddenStyle}
|
|
443
|
+
onFocus={() => focusDrawerEdge("last")}
|
|
444
|
+
/>
|
|
445
|
+
)}
|
|
446
|
+
{isMobile ? (
|
|
447
|
+
<div
|
|
448
|
+
style={drawerBrandRowStyle}
|
|
449
|
+
onClick={(event) => {
|
|
450
|
+
if (event.target instanceof Element && event.target.closest("a") !== null) {
|
|
451
|
+
closeDrawer();
|
|
452
|
+
}
|
|
453
|
+
}}
|
|
454
|
+
>
|
|
455
|
+
{brand}
|
|
456
|
+
<button
|
|
457
|
+
ref={drawerCloseRef}
|
|
458
|
+
type="button"
|
|
459
|
+
data-terp="iconbutton"
|
|
460
|
+
aria-label={strings.closeNavigation}
|
|
461
|
+
style={toggleStyle}
|
|
462
|
+
onClick={closeDrawer}
|
|
463
|
+
>
|
|
464
|
+
<Icon name="x" size="1.15rem" />
|
|
465
|
+
</button>
|
|
466
|
+
</div>
|
|
467
|
+
) : brand}
|
|
468
|
+
<nav
|
|
469
|
+
style={navStyle}
|
|
470
|
+
data-terp="appshell-nav"
|
|
471
|
+
data-collapsed={railCollapsed || undefined}
|
|
472
|
+
aria-label={strings.primaryNavigationLabel}
|
|
473
|
+
onClick={isMobile ? closeDrawer : undefined}
|
|
474
|
+
>
|
|
475
|
+
<ul style={listStyle}>
|
|
476
|
+
{nav.map((item) => (
|
|
477
|
+
<li key={item.to} title={railCollapsed ? item.label : undefined}>
|
|
478
|
+
{renderLink(
|
|
479
|
+
item,
|
|
480
|
+
<>
|
|
481
|
+
<NavIcon name={item.icon} label={item.label} />
|
|
482
|
+
<span style={railCollapsed ? visuallyHiddenStyle : navItemLabelStyle}>
|
|
483
|
+
{item.label}
|
|
484
|
+
</span>
|
|
485
|
+
</>,
|
|
486
|
+
{ collapsed: railCollapsed, style: linkStyle, activeStyle: NAV_LINK_ACTIVE_STYLE },
|
|
487
|
+
)}
|
|
488
|
+
</li>
|
|
489
|
+
))}
|
|
490
|
+
</ul>
|
|
491
|
+
</nav>
|
|
492
|
+
{typeof navFooter === "function" ? navFooter(context) : navFooter}
|
|
493
|
+
{isMobile && (
|
|
494
|
+
<span
|
|
495
|
+
data-terp="drawer-focus-end"
|
|
496
|
+
tabIndex={0}
|
|
497
|
+
style={visuallyHiddenStyle}
|
|
498
|
+
onFocus={() => focusDrawerEdge("first")}
|
|
499
|
+
/>
|
|
500
|
+
)}
|
|
501
|
+
</aside>
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<div style={shellStyle}>
|
|
506
|
+
{isMobile ? (
|
|
507
|
+
drawerOpen && (
|
|
508
|
+
<>
|
|
509
|
+
{/* Click-away surface only: Escape and the labelled header toggle are the
|
|
510
|
+
accessible close paths, so the backdrop stays out of the a11y tree. */}
|
|
511
|
+
<div aria-hidden="true" style={backdropStyle} onClick={closeDrawer} />
|
|
512
|
+
{sidebar}
|
|
513
|
+
</>
|
|
514
|
+
)
|
|
515
|
+
) : (
|
|
516
|
+
sidebar
|
|
517
|
+
)}
|
|
518
|
+
<div
|
|
519
|
+
style={columnStyle}
|
|
520
|
+
inert={isMobile && drawerOpen ? true : undefined}
|
|
521
|
+
aria-hidden={isMobile && drawerOpen ? true : undefined}
|
|
522
|
+
>
|
|
523
|
+
<header style={headerStyle}>
|
|
524
|
+
<button
|
|
525
|
+
ref={toggleRef}
|
|
526
|
+
type="button"
|
|
527
|
+
data-terp="iconbutton"
|
|
528
|
+
style={toggleStyle}
|
|
529
|
+
aria-expanded={isMobile ? drawerOpen : !collapsed}
|
|
530
|
+
aria-label={
|
|
531
|
+
isMobile
|
|
532
|
+
? drawerOpen
|
|
533
|
+
? strings.closeNavigation
|
|
534
|
+
: strings.openNavigation
|
|
535
|
+
: collapsed
|
|
536
|
+
? strings.expandSidebar
|
|
537
|
+
: strings.collapseSidebar
|
|
538
|
+
}
|
|
539
|
+
onClick={toggleSidebar}
|
|
540
|
+
>
|
|
541
|
+
<PanelIcon />
|
|
542
|
+
</button>
|
|
543
|
+
<div style={headerGroupStyle}>
|
|
544
|
+
{headerActions}
|
|
545
|
+
<ThemeToggle variant="inline" />
|
|
546
|
+
<LanguageSwitcher variant="inline" />
|
|
547
|
+
</div>
|
|
548
|
+
</header>
|
|
549
|
+
<main style={isMobile ? mainMobileStyle : mainStyle}>{children}</main>
|
|
550
|
+
<footer style={footerStyle}>{footer ?? <small>{resolvedTitle}</small>}</footer>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { Authorized } from "./Authorized";
|
|
7
|
+
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
8
|
+
|
|
9
|
+
function jsonResponse(body: unknown): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status: 200,
|
|
12
|
+
headers: { "content-type": "application/json" },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function LogInOnMount() {
|
|
21
|
+
const auth = useAuth();
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
void auth.login({ email: "editor@example.com", password: "pw" });
|
|
24
|
+
}, []);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("Authorized", () => {
|
|
29
|
+
it("reveals content per the logged-in user's role (editor can write, not admin)", async () => {
|
|
30
|
+
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
|
31
|
+
const url = (input as Request).url;
|
|
32
|
+
if (url.endsWith("/api/v1/auth/login")) {
|
|
33
|
+
return jsonResponse({ access_token: "token", token_type: "bearer" });
|
|
34
|
+
}
|
|
35
|
+
return jsonResponse({
|
|
36
|
+
id: "u1",
|
|
37
|
+
email: "editor@example.com",
|
|
38
|
+
role_rank: 20,
|
|
39
|
+
role_name: "editor",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
43
|
+
|
|
44
|
+
render(
|
|
45
|
+
<TerpProvider baseUrl="https://api.test">
|
|
46
|
+
<LogInOnMount />
|
|
47
|
+
<Authorized action="write">
|
|
48
|
+
<span>can-write</span>
|
|
49
|
+
</Authorized>
|
|
50
|
+
<Authorized action="admin" fallback={<span>no-admin</span>}>
|
|
51
|
+
<span>admin-only</span>
|
|
52
|
+
</Authorized>
|
|
53
|
+
</TerpProvider>,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
await waitFor(() => expect(screen.getByText("can-write")).toBeInTheDocument());
|
|
57
|
+
expect(screen.getByText("no-admin")).toBeInTheDocument();
|
|
58
|
+
expect(screen.queryByText("admin-only")).not.toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Action } from "@terpjs/contract";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { useAuth } from "./TerpProvider";
|
|
5
|
+
|
|
6
|
+
/** Whether the current user may perform `action` (the UI gate; the backend re-checks). */
|
|
7
|
+
export function useCan(action: Action): boolean {
|
|
8
|
+
return useAuth().can(action);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AuthorizedProps {
|
|
12
|
+
action: Action;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
/** Rendered when the user may not perform `action` (default: nothing). */
|
|
15
|
+
fallback?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Render `children` only when the current user may perform `action`, else `fallback`. */
|
|
19
|
+
export function Authorized({ action, children, fallback = null }: AuthorizedProps) {
|
|
20
|
+
return <>{useCan(action) ? children : fallback}</>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Breadcrumbs } from "./Breadcrumbs";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("Breadcrumbs", () => {
|
|
10
|
+
it("renders a Breadcrumb landmark with ancestor links and the current page marked", () => {
|
|
11
|
+
render(
|
|
12
|
+
<Breadcrumbs
|
|
13
|
+
items={[
|
|
14
|
+
{ label: "Tasks", to: "/tasks" },
|
|
15
|
+
{ label: "Fix the door" },
|
|
16
|
+
]}
|
|
17
|
+
/>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
|
|
22
|
+
expect(screen.getByText("Fix the door")).toHaveAttribute("aria-current", "page");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("uses renderLink for ancestor crumbs (router-agnostic)", () => {
|
|
26
|
+
render(
|
|
27
|
+
<Breadcrumbs
|
|
28
|
+
items={[
|
|
29
|
+
{ label: "Tasks", to: "/tasks" },
|
|
30
|
+
{ label: "Detail" },
|
|
31
|
+
]}
|
|
32
|
+
renderLink={(item) => <a href={`#${item.to}`}>{item.label}</a>}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "#/tasks");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders an ancestor without a `to` as plain text (never a dead link)", () => {
|
|
40
|
+
render(<Breadcrumbs items={[{ label: "Section" }, { label: "Here" }]} />);
|
|
41
|
+
|
|
42
|
+
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
43
|
+
expect(screen.getByText("Section")).not.toHaveAttribute("aria-current");
|
|
44
|
+
});
|
|
45
|
+
});
|