@terpjs/react-core 0.7.0 → 0.8.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/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- 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/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- 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/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +0 -3
- 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 +242 -19
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -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 +17 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@tanstack/react-router": "^1.170.16",
|
|
16
|
-
"@terpjs/contract": "^0.
|
|
16
|
+
"@terpjs/contract": "^0.8.0"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/AppShell.test.tsx
CHANGED
|
@@ -21,9 +21,7 @@ function renderShell(extra?: Partial<Parameters<typeof AppShell>[0]>) {
|
|
|
21
21
|
<AppShell
|
|
22
22
|
title="Terp"
|
|
23
23
|
nav={nav}
|
|
24
|
-
renderLink={(item, children
|
|
25
|
-
<a href={item.to} style={context.style}>{children}</a>
|
|
26
|
-
)}
|
|
24
|
+
renderLink={(item, children) => <a href={item.to}>{children}</a>}
|
|
27
25
|
navFooter={<p>pinned footer</p>}
|
|
28
26
|
{...extra}
|
|
29
27
|
>
|
|
@@ -74,12 +72,18 @@ describe("AppShell", () => {
|
|
|
74
72
|
expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
|
|
75
73
|
expect(screen.getByText("U")).toBeInTheDocument(); // Users' fallback initial tile
|
|
76
74
|
const navigation = screen.getByRole("navigation", { name: "Primary" });
|
|
77
|
-
|
|
75
|
+
// data-collapsed is on the SIDEBAR, not on the nav. One fact, one owner: the rail decides
|
|
76
|
+
// the sidebar's width, the brand's centring, the nav's scrollbar and both hidden labels, so
|
|
77
|
+
// every rule that reads it descends from the element that owns it.
|
|
78
|
+
const sidebar = navigation.closest('[data-terp="appshell-sidebar"]');
|
|
79
|
+
expect(sidebar).toHaveAttribute("data-collapsed", "true");
|
|
78
80
|
expect(navigation.querySelectorAll('[data-terp="nav-icon"]')).toHaveLength(2);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// The link's collapsed geometry is a rule now, keyed on that attribute — asserting
|
|
82
|
+
// toHaveStyle here asserted that the shell hands a style object to the caller's link
|
|
83
|
+
// renderer, which is the thing this migration removed. jsdom computes no cascade, so the
|
|
84
|
+
// fact is what a unit test can hold; the geometry is gated by styles.test.ts and by the
|
|
85
|
+
// app-shell-collapsed baseline.
|
|
86
|
+
expect(screen.getByRole("link", { name: "Notes" })).not.toHaveAttribute("style");
|
|
83
87
|
expect(screen.getByText("rail")).toBeInTheDocument();
|
|
84
88
|
expect(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)).toBe("collapsed");
|
|
85
89
|
expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
|
|
@@ -89,13 +93,30 @@ describe("AppShell", () => {
|
|
|
89
93
|
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "collapsed");
|
|
90
94
|
renderShell();
|
|
91
95
|
expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
|
|
92
|
-
expect(
|
|
93
|
-
"data-
|
|
94
|
-
|
|
95
|
-
);
|
|
96
|
+
expect(
|
|
97
|
+
screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
|
|
98
|
+
).toHaveAttribute("data-collapsed", "true");
|
|
96
99
|
expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
|
|
97
100
|
});
|
|
98
101
|
|
|
102
|
+
it("starts collapsed on defaultCollapsed, and a stored choice still wins", () => {
|
|
103
|
+
// The rail was internal state with no way in, which is why four rules that apply only to it
|
|
104
|
+
// were painted by nothing. Reading the key with `=== "collapsed"` also treated an absent key
|
|
105
|
+
// and an explicit "expanded" as the same thing, so the fallback had to become a null check.
|
|
106
|
+
renderShell({ defaultCollapsed: true });
|
|
107
|
+
expect(
|
|
108
|
+
screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
|
|
109
|
+
).toHaveAttribute("data-collapsed", "true");
|
|
110
|
+
cleanup();
|
|
111
|
+
|
|
112
|
+
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "expanded");
|
|
113
|
+
renderShell({ defaultCollapsed: true });
|
|
114
|
+
expect(
|
|
115
|
+
screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
|
|
116
|
+
"an explicit stored choice must beat defaultCollapsed, in both directions",
|
|
117
|
+
).not.toHaveAttribute("data-collapsed");
|
|
118
|
+
});
|
|
119
|
+
|
|
99
120
|
it("renders a custom logo and footer in their slots", () => {
|
|
100
121
|
renderShell({ logo: <span>MyMark</span>, footer: <span>v1.2.3</span> });
|
|
101
122
|
expect(screen.getByText("MyMark")).toBeInTheDocument();
|
package/src/AppShell.tsx
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { NavItem } from "@terpjs/contract";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
import { Icon, NavIcon, TerpMark } from "./icons";
|
|
6
6
|
import { LanguageSwitcher } from "./locale";
|
|
7
7
|
import { injectTerpStyles } from "./styles";
|
|
8
8
|
import { ThemeToggle } from "./theme";
|
|
9
|
-
import { CONTROL_TEXT_STYLE } from "./ui/controlStyles";
|
|
10
9
|
import { useStrings, useUiText } from "./uiText";
|
|
11
10
|
import type { UiText } from "./uiText";
|
|
12
11
|
|
|
@@ -17,16 +16,18 @@ export interface AppShellSlotContext {
|
|
|
17
16
|
collapsed: boolean;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
/**
|
|
20
|
+
* What a link renderer is told about the shell it is rendering into.
|
|
21
|
+
*
|
|
22
|
+
* It used to carry `style` and `activeStyle` for the caller to spread, which made the
|
|
23
|
+
* shell's link geometry a style object handed across a public boundary — unthemeable by
|
|
24
|
+
* an app, and duplicated by every stack. The sheet owns that geometry now, keyed on
|
|
25
|
+
* `[data-terp="appshell-nav"] a` and on `aria-current="page"` for the active route, so a
|
|
26
|
+
* renderer needs to return nothing but its stack's link (ADR 0094).
|
|
27
|
+
*/
|
|
28
|
+
export type AppShellLinkContext = AppShellSlotContext;
|
|
24
29
|
|
|
25
|
-
export type RenderBrandLink = (props: {
|
|
26
|
-
to: string;
|
|
27
|
-
children: ReactNode;
|
|
28
|
-
style: CSSProperties;
|
|
29
|
-
}) => ReactNode;
|
|
30
|
+
export type RenderBrandLink = (props: { to: string; children: ReactNode }) => ReactNode;
|
|
30
31
|
|
|
31
32
|
export interface AppShellProps {
|
|
32
33
|
/** Product / app title shown next to the logo at the top of the sidebar. */
|
|
@@ -35,10 +36,10 @@ export interface AppShellProps {
|
|
|
35
36
|
nav: readonly NavItem[];
|
|
36
37
|
/**
|
|
37
38
|
* Turns a nav item into the active stack's link around the shell-styled
|
|
38
|
-
* `children` (icon + label), keeping the shell router-agnostic.
|
|
39
|
-
*
|
|
40
|
-
* link
|
|
41
|
-
*
|
|
39
|
+
* `children` (icon + label), keeping the shell router-agnostic. Return the
|
|
40
|
+
* stack's link and nothing else: the shell owns the expanded and collapsed
|
|
41
|
+
* link geometry from its stylesheet, and the active route's treatment is
|
|
42
|
+
* keyed on `aria-current="page"`, which every router sets.
|
|
42
43
|
*/
|
|
43
44
|
renderLink: (item: NavItem, children: ReactNode, context: AppShellLinkContext) => ReactNode;
|
|
44
45
|
/** Turns the product brand into the home link; defaults to a plain anchor to `/`. */
|
|
@@ -51,6 +52,16 @@ export interface AppShellProps {
|
|
|
51
52
|
navFooter?: ReactNode | ((context: AppShellSlotContext) => ReactNode);
|
|
52
53
|
/** Footer line under the content; default: a muted line with the app title. */
|
|
53
54
|
footer?: ReactNode;
|
|
55
|
+
/**
|
|
56
|
+
* Start with the desktop sidebar collapsed to its icon rail, when no choice has been
|
|
57
|
+
* persisted yet. The user's own toggle still wins and still persists.
|
|
58
|
+
*
|
|
59
|
+
* It exists for the same reason `Menu` and both date pickers take `defaultOpen`: the
|
|
60
|
+
* rail is internal state read from `localStorage`, so without a way in it can be
|
|
61
|
+
* rendered by no specimen and no test, and every rule that only applies to it is
|
|
62
|
+
* unpainted. Four were.
|
|
63
|
+
*/
|
|
64
|
+
defaultCollapsed?: boolean;
|
|
54
65
|
/** The routed page content. */
|
|
55
66
|
children: ReactNode;
|
|
56
67
|
}
|
|
@@ -61,42 +72,6 @@ export const SIDEBAR_STORAGE_KEY = "terp.sidebar";
|
|
|
61
72
|
/** Below this width the sidebar becomes an overlay drawer (matches DataView's card cutover). */
|
|
62
73
|
const MOBILE_BREAKPOINT = "(max-width: 768px)";
|
|
63
74
|
|
|
64
|
-
const EXPANDED_WIDTH = "15rem";
|
|
65
|
-
const COLLAPSED_WIDTH = "4rem";
|
|
66
|
-
|
|
67
|
-
/** Base style for sidebar links — spread onto the stack's link element. */
|
|
68
|
-
export const NAV_LINK_STYLE: CSSProperties = {
|
|
69
|
-
display: "flex",
|
|
70
|
-
alignItems: "center",
|
|
71
|
-
gap: "var(--space-2)",
|
|
72
|
-
padding: "var(--space-2) var(--space-3)",
|
|
73
|
-
borderRadius: "var(--radius-md)",
|
|
74
|
-
color: "var(--color-neutral-700)",
|
|
75
|
-
fontSize: "var(--font-size-sm)",
|
|
76
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
77
|
-
textDecoration: "none",
|
|
78
|
-
whiteSpace: "nowrap",
|
|
79
|
-
overflow: "hidden",
|
|
80
|
-
boxSizing: "border-box",
|
|
81
|
-
minHeight: "2.25rem",
|
|
82
|
-
transition: "background-color 150ms ease, color 150ms ease",
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/** Collapsed rail geometry: one centered fixed-size icon inside the 2.5rem content track. */
|
|
86
|
-
export const NAV_LINK_COLLAPSED_STYLE: CSSProperties = {
|
|
87
|
-
justifyContent: "center",
|
|
88
|
-
gap: 0,
|
|
89
|
-
padding: "var(--space-2)",
|
|
90
|
-
width: "100%",
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/** Merged over {@link NAV_LINK_STYLE} on the active route's link. */
|
|
94
|
-
export const NAV_LINK_ACTIVE_STYLE: CSSProperties = {
|
|
95
|
-
background: "var(--color-brand-primary-soft)",
|
|
96
|
-
color: "var(--color-fg-accent)",
|
|
97
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
98
|
-
};
|
|
99
|
-
|
|
100
75
|
function useIsMobile(): boolean {
|
|
101
76
|
const [isMobile, setIsMobile] = useState(
|
|
102
77
|
() =>
|
|
@@ -116,175 +91,27 @@ function useIsMobile(): boolean {
|
|
|
116
91
|
return isMobile;
|
|
117
92
|
}
|
|
118
93
|
|
|
119
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The persisted rail choice, falling back to `defaultCollapsed` when nothing is stored.
|
|
96
|
+
*
|
|
97
|
+
* The null check is the whole point and it is new: reading `=== "collapsed"` treated an
|
|
98
|
+
* absent key and an explicit "expanded" as the same thing, so a `defaultCollapsed` shell
|
|
99
|
+
* could never start collapsed. A stored choice still wins in both directions.
|
|
100
|
+
*/
|
|
101
|
+
function readStoredCollapsed(fallback: boolean): boolean {
|
|
120
102
|
if (typeof window === "undefined") {
|
|
121
|
-
return
|
|
103
|
+
return fallback;
|
|
122
104
|
}
|
|
123
105
|
try {
|
|
124
|
-
|
|
106
|
+
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
|
107
|
+
return stored === null ? fallback : stored === "collapsed";
|
|
125
108
|
} catch {
|
|
126
|
-
return
|
|
109
|
+
return fallback;
|
|
127
110
|
}
|
|
128
111
|
}
|
|
129
112
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
alignItems: "stretch",
|
|
133
|
-
minHeight: "100vh",
|
|
134
|
-
fontFamily: "var(--font-family-sans)",
|
|
135
|
-
color: "var(--color-neutral-900)",
|
|
136
|
-
background: "var(--color-neutral-50)",
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const sidebarStyle: CSSProperties = {
|
|
140
|
-
display: "flex",
|
|
141
|
-
flexDirection: "column",
|
|
142
|
-
gap: "var(--space-4)",
|
|
143
|
-
padding: "var(--space-3)",
|
|
144
|
-
boxSizing: "border-box",
|
|
145
|
-
flexShrink: 0,
|
|
146
|
-
position: "sticky",
|
|
147
|
-
top: 0,
|
|
148
|
-
height: "100vh",
|
|
149
|
-
overflowX: "hidden",
|
|
150
|
-
background: "var(--color-neutral-0)",
|
|
151
|
-
borderRight: "1px solid var(--color-neutral-200)",
|
|
152
|
-
transition: "width 150ms ease",
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const drawerStyle: CSSProperties = {
|
|
156
|
-
...sidebarStyle,
|
|
157
|
-
position: "fixed",
|
|
158
|
-
inset: "0 auto 0 0",
|
|
159
|
-
height: "100dvh",
|
|
160
|
-
width: EXPANDED_WIDTH,
|
|
161
|
-
zIndex: 50,
|
|
162
|
-
boxShadow: "var(--shadow-lg)",
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const backdropStyle: CSSProperties = {
|
|
166
|
-
position: "fixed",
|
|
167
|
-
inset: 0,
|
|
168
|
-
zIndex: 40,
|
|
169
|
-
background: "rgb(0 0 0 / 0.4)",
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const brandStyle: CSSProperties = {
|
|
173
|
-
display: "flex",
|
|
174
|
-
alignItems: "center",
|
|
175
|
-
gap: "var(--space-2)",
|
|
176
|
-
padding: "var(--space-1) var(--space-2)",
|
|
177
|
-
minHeight: "2.25rem",
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const brandLinkStyle: CSSProperties = {
|
|
181
|
-
...brandStyle,
|
|
182
|
-
color: "var(--color-neutral-900)",
|
|
183
|
-
textDecoration: "none",
|
|
184
|
-
borderRadius: "var(--radius-md)",
|
|
185
|
-
boxSizing: "border-box",
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const drawerBrandRowStyle: CSSProperties = {
|
|
189
|
-
display: "flex",
|
|
190
|
-
alignItems: "center",
|
|
191
|
-
gap: "var(--space-2)",
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const brandTitleStyle: CSSProperties = {
|
|
195
|
-
overflow: "hidden",
|
|
196
|
-
textOverflow: "ellipsis",
|
|
197
|
-
whiteSpace: "nowrap",
|
|
198
|
-
fontSize: "var(--font-size-base)",
|
|
199
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
200
|
-
color: "var(--color-neutral-900)",
|
|
201
|
-
letterSpacing: 0,
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const navItemLabelStyle: CSSProperties = {
|
|
205
|
-
overflow: "hidden",
|
|
206
|
-
textOverflow: "ellipsis",
|
|
207
|
-
whiteSpace: "nowrap",
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const visuallyHiddenStyle: CSSProperties = {
|
|
211
|
-
position: "absolute",
|
|
212
|
-
width: 1,
|
|
213
|
-
height: 1,
|
|
214
|
-
padding: 0,
|
|
215
|
-
margin: -1,
|
|
216
|
-
overflow: "hidden",
|
|
217
|
-
clip: "rect(0 0 0 0)",
|
|
218
|
-
whiteSpace: "nowrap",
|
|
219
|
-
border: 0,
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const navStyle: CSSProperties = { flexGrow: 1, overflowY: "auto", minHeight: 0 };
|
|
223
|
-
|
|
224
|
-
const listStyle: CSSProperties = {
|
|
225
|
-
listStyle: "none",
|
|
226
|
-
margin: 0,
|
|
227
|
-
padding: 0,
|
|
228
|
-
display: "grid",
|
|
229
|
-
gap: "var(--space-1)",
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const columnStyle: CSSProperties = {
|
|
233
|
-
display: "flex",
|
|
234
|
-
flexDirection: "column",
|
|
235
|
-
flexGrow: 1,
|
|
236
|
-
minWidth: 0,
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const headerStyle: CSSProperties = {
|
|
240
|
-
position: "sticky",
|
|
241
|
-
top: 0,
|
|
242
|
-
zIndex: 30,
|
|
243
|
-
display: "flex",
|
|
244
|
-
flexWrap: "wrap",
|
|
245
|
-
alignItems: "center",
|
|
246
|
-
justifyContent: "space-between",
|
|
247
|
-
gap: "var(--space-3)",
|
|
248
|
-
padding: "var(--space-2) var(--space-4)",
|
|
249
|
-
minHeight: "3rem",
|
|
250
|
-
boxSizing: "border-box",
|
|
251
|
-
background: "var(--color-neutral-0)",
|
|
252
|
-
borderBottom: "1px solid var(--color-neutral-200)",
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
const headerGroupStyle: CSSProperties = {
|
|
256
|
-
display: "flex",
|
|
257
|
-
alignItems: "center",
|
|
258
|
-
gap: "var(--space-2)",
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
const mainStyle: CSSProperties = { flexGrow: 1, padding: "var(--space-6)", minWidth: 0 };
|
|
262
|
-
const mainMobileStyle: CSSProperties = { ...mainStyle, padding: "var(--space-4)" };
|
|
263
|
-
|
|
264
|
-
const footerStyle: CSSProperties = {
|
|
265
|
-
padding: "var(--space-3) var(--space-6)",
|
|
266
|
-
borderTop: "1px solid var(--color-neutral-200)",
|
|
267
|
-
color: "var(--color-neutral-500)",
|
|
268
|
-
fontSize: "var(--font-size-xs)",
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
const toggleStyle: CSSProperties = {
|
|
272
|
-
...CONTROL_TEXT_STYLE,
|
|
273
|
-
display: "inline-flex",
|
|
274
|
-
alignItems: "center",
|
|
275
|
-
justifyContent: "center",
|
|
276
|
-
width: "2.25rem",
|
|
277
|
-
height: "2.25rem",
|
|
278
|
-
padding: 0,
|
|
279
|
-
color: "var(--color-neutral-700)",
|
|
280
|
-
background: "transparent",
|
|
281
|
-
border: "1px solid transparent",
|
|
282
|
-
borderRadius: "var(--radius-md)",
|
|
283
|
-
cursor: "pointer",
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
const defaultRenderBrandLink: RenderBrandLink = ({ to, children, style }) => (
|
|
287
|
-
<a href={to} data-terp="appshell-brand" style={style}>
|
|
113
|
+
const defaultRenderBrandLink: RenderBrandLink = ({ to, children }) => (
|
|
114
|
+
<a href={to} data-terp="appshell-brand">
|
|
288
115
|
{children}
|
|
289
116
|
</a>
|
|
290
117
|
);
|
|
@@ -331,12 +158,13 @@ export function AppShell({
|
|
|
331
158
|
headerActions,
|
|
332
159
|
navFooter,
|
|
333
160
|
footer,
|
|
161
|
+
defaultCollapsed = false,
|
|
334
162
|
children,
|
|
335
163
|
}: AppShellProps) {
|
|
336
164
|
const resolve = useUiText();
|
|
337
165
|
const strings = useStrings();
|
|
338
166
|
const isMobile = useIsMobile();
|
|
339
|
-
const [collapsed, setCollapsed] = useState(readStoredCollapsed);
|
|
167
|
+
const [collapsed, setCollapsed] = useState(() => readStoredCollapsed(defaultCollapsed));
|
|
340
168
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
341
169
|
const drawerRef = useRef<HTMLElement>(null);
|
|
342
170
|
const drawerCloseRef = useRef<HTMLButtonElement>(null);
|
|
@@ -399,24 +227,27 @@ export function AppShell({
|
|
|
399
227
|
// The drawer always shows labels; the desktop rail hides them when collapsed.
|
|
400
228
|
const railCollapsed = !isMobile && collapsed;
|
|
401
229
|
const context: AppShellSlotContext = { collapsed: railCollapsed };
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
230
|
+
// Hoisted, the density-attribute idiom: the marker scanner reads a whole expression
|
|
231
|
+
// container, so a conditional written at the attribute reports every literal in it as a
|
|
232
|
+
// marker name.
|
|
233
|
+
const collapsedAttribute = railCollapsed ? "true" : undefined;
|
|
234
|
+
// One attribute for the viewport, on the shell root, and every consequence of it descends
|
|
235
|
+
// from there: the sidebar becomes a drawer, main tightens its padding. The breakpoint stays
|
|
236
|
+
// in one place — this component's media query — rather than being restated as a CSS
|
|
237
|
+
// @media rule that could drift from it.
|
|
238
|
+
const shellVariant = isMobile ? "mobile" : "desktop";
|
|
405
239
|
const resolvedTitle = resolve(title);
|
|
406
240
|
|
|
241
|
+
// The brand takes no style object and needs none: its three looks are the resting one,
|
|
242
|
+
// the collapsed one (reached from the sidebar's data-collapsed) and the mobile one
|
|
243
|
+
// (reached from the drawer's brand row, which only exists on mobile). The DOM already
|
|
244
|
+
// says which it is.
|
|
407
245
|
const brand = renderBrandLink({
|
|
408
246
|
to: "/",
|
|
409
|
-
style: railCollapsed
|
|
410
|
-
? { ...brandLinkStyle, justifyContent: "center", paddingInline: 0 }
|
|
411
|
-
: isMobile
|
|
412
|
-
? { ...brandLinkStyle, flex: 1, minWidth: 0 }
|
|
413
|
-
: brandLinkStyle,
|
|
414
247
|
children: (
|
|
415
248
|
<>
|
|
416
249
|
{logo ?? <TerpMark />}
|
|
417
|
-
<strong
|
|
418
|
-
{resolvedTitle}
|
|
419
|
-
</strong>
|
|
250
|
+
<strong data-terp="appshell-brand-title">{resolvedTitle}</strong>
|
|
420
251
|
</>
|
|
421
252
|
),
|
|
422
253
|
});
|
|
@@ -429,23 +260,19 @@ export function AppShell({
|
|
|
429
260
|
aria-label={isMobile ? strings.primaryNavigationLabel : undefined}
|
|
430
261
|
tabIndex={isMobile ? -1 : undefined}
|
|
431
262
|
onKeyDown={isMobile ? onDrawerKeyDown : undefined}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
? drawerStyle
|
|
435
|
-
: { ...sidebarStyle, width: railCollapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH }
|
|
436
|
-
}
|
|
263
|
+
data-terp="appshell-sidebar"
|
|
264
|
+
data-collapsed={collapsedAttribute}
|
|
437
265
|
>
|
|
438
266
|
{isMobile && (
|
|
439
267
|
<span
|
|
440
268
|
data-terp="drawer-focus-start"
|
|
441
269
|
tabIndex={0}
|
|
442
|
-
style={visuallyHiddenStyle}
|
|
443
270
|
onFocus={() => focusDrawerEdge("last")}
|
|
444
271
|
/>
|
|
445
272
|
)}
|
|
446
273
|
{isMobile ? (
|
|
447
274
|
<div
|
|
448
|
-
|
|
275
|
+
data-terp="appshell-brand-row"
|
|
449
276
|
onClick={(event) => {
|
|
450
277
|
if (event.target instanceof Element && event.target.closest("a") !== null) {
|
|
451
278
|
closeDrawer();
|
|
@@ -458,7 +285,6 @@ export function AppShell({
|
|
|
458
285
|
type="button"
|
|
459
286
|
data-terp="iconbutton"
|
|
460
287
|
aria-label={strings.closeNavigation}
|
|
461
|
-
style={toggleStyle}
|
|
462
288
|
onClick={closeDrawer}
|
|
463
289
|
>
|
|
464
290
|
<Icon name="x" size="1.15rem" />
|
|
@@ -466,24 +292,20 @@ export function AppShell({
|
|
|
466
292
|
</div>
|
|
467
293
|
) : brand}
|
|
468
294
|
<nav
|
|
469
|
-
style={navStyle}
|
|
470
295
|
data-terp="appshell-nav"
|
|
471
|
-
data-collapsed={railCollapsed || undefined}
|
|
472
296
|
aria-label={strings.primaryNavigationLabel}
|
|
473
297
|
onClick={isMobile ? closeDrawer : undefined}
|
|
474
298
|
>
|
|
475
|
-
<ul
|
|
299
|
+
<ul data-terp="appshell-nav-list">
|
|
476
300
|
{nav.map((item) => (
|
|
477
301
|
<li key={item.to} title={railCollapsed ? item.label : undefined}>
|
|
478
302
|
{renderLink(
|
|
479
303
|
item,
|
|
480
304
|
<>
|
|
481
305
|
<NavIcon name={item.icon} label={item.label} />
|
|
482
|
-
<span
|
|
483
|
-
{item.label}
|
|
484
|
-
</span>
|
|
306
|
+
<span data-terp="appshell-nav-label">{item.label}</span>
|
|
485
307
|
</>,
|
|
486
|
-
{ collapsed: railCollapsed
|
|
308
|
+
{ collapsed: railCollapsed },
|
|
487
309
|
)}
|
|
488
310
|
</li>
|
|
489
311
|
))}
|
|
@@ -494,7 +316,6 @@ export function AppShell({
|
|
|
494
316
|
<span
|
|
495
317
|
data-terp="drawer-focus-end"
|
|
496
318
|
tabIndex={0}
|
|
497
|
-
style={visuallyHiddenStyle}
|
|
498
319
|
onFocus={() => focusDrawerEdge("first")}
|
|
499
320
|
/>
|
|
500
321
|
)}
|
|
@@ -502,13 +323,13 @@ export function AppShell({
|
|
|
502
323
|
);
|
|
503
324
|
|
|
504
325
|
return (
|
|
505
|
-
<div
|
|
326
|
+
<div data-terp="appshell" data-variant={shellVariant}>
|
|
506
327
|
{isMobile ? (
|
|
507
328
|
drawerOpen && (
|
|
508
329
|
<>
|
|
509
330
|
{/* Click-away surface only: Escape and the labelled header toggle are the
|
|
510
331
|
accessible close paths, so the backdrop stays out of the a11y tree. */}
|
|
511
|
-
<div aria-hidden="true"
|
|
332
|
+
<div aria-hidden="true" data-terp="appshell-backdrop" onClick={closeDrawer} />
|
|
512
333
|
{sidebar}
|
|
513
334
|
</>
|
|
514
335
|
)
|
|
@@ -516,16 +337,15 @@ export function AppShell({
|
|
|
516
337
|
sidebar
|
|
517
338
|
)}
|
|
518
339
|
<div
|
|
519
|
-
|
|
340
|
+
data-terp="appshell-column"
|
|
520
341
|
inert={isMobile && drawerOpen ? true : undefined}
|
|
521
342
|
aria-hidden={isMobile && drawerOpen ? true : undefined}
|
|
522
343
|
>
|
|
523
|
-
<header
|
|
344
|
+
<header data-terp="appshell-header">
|
|
524
345
|
<button
|
|
525
346
|
ref={toggleRef}
|
|
526
347
|
type="button"
|
|
527
348
|
data-terp="iconbutton"
|
|
528
|
-
style={toggleStyle}
|
|
529
349
|
aria-expanded={isMobile ? drawerOpen : !collapsed}
|
|
530
350
|
aria-label={
|
|
531
351
|
isMobile
|
|
@@ -540,14 +360,14 @@ export function AppShell({
|
|
|
540
360
|
>
|
|
541
361
|
<PanelIcon />
|
|
542
362
|
</button>
|
|
543
|
-
<div
|
|
363
|
+
<div data-terp="appshell-header-group">
|
|
544
364
|
{headerActions}
|
|
545
365
|
<ThemeToggle variant="inline" />
|
|
546
366
|
<LanguageSwitcher variant="inline" />
|
|
547
367
|
</div>
|
|
548
368
|
</header>
|
|
549
|
-
<main
|
|
550
|
-
<footer
|
|
369
|
+
<main data-terp="appshell-main">{children}</main>
|
|
370
|
+
<footer data-terp="appshell-footer">{footer ?? <small>{resolvedTitle}</small>}</footer>
|
|
551
371
|
</div>
|
|
552
372
|
</div>
|
|
553
373
|
);
|
package/src/Breadcrumbs.test.tsx
CHANGED
|
@@ -42,4 +42,28 @@ describe("Breadcrumbs", () => {
|
|
|
42
42
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
43
43
|
expect(screen.getByText("Section")).not.toHaveAttribute("aria-current");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("marks only the final crumb as current, on a marker of its own", () => {
|
|
47
|
+
// The current-crumb styling deliberately does NOT key on aria-current. A router's Link
|
|
48
|
+
// stamps aria-current="page" on every link whose path is a prefix of the current one —
|
|
49
|
+
// which every ancestor crumb is — so borrowing that attribute painted the whole trail as
|
|
50
|
+
// the current page. The marker says what this component means, not what a router infers.
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<Breadcrumbs
|
|
53
|
+
items={[{ label: "Tasks", to: "/tasks" }, { label: "Open", to: "/tasks/open" }, { label: "Here" }]}
|
|
54
|
+
renderLink={(item) => (
|
|
55
|
+
<a href={item.to} aria-current="page">
|
|
56
|
+
{item.label}
|
|
57
|
+
</a>
|
|
58
|
+
)}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const current = container.querySelectorAll('[data-terp="breadcrumbs-current"]');
|
|
63
|
+
expect(current).toHaveLength(1);
|
|
64
|
+
expect(current[0]).toHaveTextContent("Here");
|
|
65
|
+
// Both ancestors claim aria-current here, which is exactly the router behaviour that
|
|
66
|
+
// made the old selector wrong — and none of them may pick up the current styling.
|
|
67
|
+
expect(container.querySelectorAll('[aria-current="page"]')).toHaveLength(3);
|
|
68
|
+
});
|
|
45
69
|
});
|
package/src/Breadcrumbs.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { injectTerpStyles } from "./styles";
|
|
4
4
|
import { useNavLink } from "./navLink";
|
|
@@ -29,32 +29,6 @@ export interface BreadcrumbsProps {
|
|
|
29
29
|
renderLink?: RenderBreadcrumbLink;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const navStyle: CSSProperties = {
|
|
33
|
-
fontSize: "var(--font-size-sm)",
|
|
34
|
-
color: "var(--color-neutral-600)",
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const listStyle: CSSProperties = {
|
|
38
|
-
listStyle: "none",
|
|
39
|
-
margin: 0,
|
|
40
|
-
padding: 0,
|
|
41
|
-
display: "flex",
|
|
42
|
-
flexWrap: "wrap",
|
|
43
|
-
alignItems: "center",
|
|
44
|
-
gap: "var(--space-2)",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const currentStyle: CSSProperties = {
|
|
48
|
-
color: "var(--color-neutral-900)",
|
|
49
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const separatorStyle: CSSProperties = {
|
|
53
|
-
display: "inline-flex",
|
|
54
|
-
color: "var(--color-neutral-400)",
|
|
55
|
-
lineHeight: 0,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
32
|
function ChevronSeparator() {
|
|
59
33
|
return (
|
|
60
34
|
<svg
|
|
@@ -92,22 +66,25 @@ export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
|
|
|
92
66
|
const strings = useStrings();
|
|
93
67
|
const resolve = useUiText();
|
|
94
68
|
return (
|
|
95
|
-
<nav aria-label={strings.breadcrumbsLabel} data-terp="breadcrumbs"
|
|
96
|
-
<ol
|
|
69
|
+
<nav aria-label={strings.breadcrumbsLabel} data-terp="breadcrumbs">
|
|
70
|
+
<ol>
|
|
97
71
|
{items.map((item, index) => {
|
|
98
72
|
const isLast = index === items.length - 1;
|
|
99
73
|
const label = resolve(item.label);
|
|
100
74
|
return (
|
|
101
|
-
<li key={`${index}-${label}`}
|
|
75
|
+
<li key={`${index}-${label}`}>
|
|
102
76
|
{!isLast && item.to !== undefined ? (
|
|
103
77
|
renderCrumbLink({ label, to: item.to })
|
|
104
78
|
) : (
|
|
105
|
-
<span
|
|
79
|
+
<span
|
|
80
|
+
aria-current={isLast ? "page" : undefined}
|
|
81
|
+
data-terp={isLast ? "breadcrumbs-current" : undefined}
|
|
82
|
+
>
|
|
106
83
|
{label}
|
|
107
84
|
</span>
|
|
108
85
|
)}
|
|
109
86
|
{!isLast && (
|
|
110
|
-
<span aria-hidden="true"
|
|
87
|
+
<span aria-hidden="true" data-terp="breadcrumbs-separator">
|
|
111
88
|
<ChevronSeparator />
|
|
112
89
|
</span>
|
|
113
90
|
)}
|