@terpjs/react-core 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/PageActions.tsx +5 -10
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +15 -15
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +7 -4
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +354 -25
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +81 -18
- package/src/sso.test.tsx +6 -3
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +855 -6
- package/src/styles.ts +3049 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +19 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/ui/controlStyles.ts +0 -9
package/src/HubPage.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
import { Page } from "./Page";
|
|
@@ -19,22 +19,15 @@ export type HubPageProps = Omit<
|
|
|
19
19
|
parents?: PageProps["breadcrumbs"];
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
const gridStyle: CSSProperties = {
|
|
23
|
-
display: "grid",
|
|
24
|
-
gridTemplateColumns: "repeat(auto-fit, minmax(min(16rem, 100%), 1fr))",
|
|
25
|
-
gridAutoRows: "1fr",
|
|
26
|
-
gap: "var(--space-4)",
|
|
27
|
-
alignItems: "stretch",
|
|
28
|
-
listStyle: "none",
|
|
29
|
-
margin: 0,
|
|
30
|
-
padding: 0,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
22
|
/**
|
|
34
23
|
* The landing / hub page archetype: a `Page` whose body is a responsive grid of
|
|
35
24
|
* {@link HubCard} links into the sub-areas of a domain. Use it as a module index that
|
|
36
25
|
* adds discovery value (each card can carry a live `stat`, making the hub a lightweight
|
|
37
26
|
* dashboard) — never as a mandatory speed-bump in front of a single frequently-used list.
|
|
27
|
+
*
|
|
28
|
+
* It renders no inline styles: the grid and every part of a card take their geometry from
|
|
29
|
+
* the injected react-core sheet, matched on the `data-terp` markers stamped below
|
|
30
|
+
* (ADR 0094).
|
|
38
31
|
*/
|
|
39
32
|
export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProps) {
|
|
40
33
|
// The runtime half of the slot-typed layout contract control (ADR 0079) for the hub
|
|
@@ -61,7 +54,7 @@ export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProp
|
|
|
61
54
|
}
|
|
62
55
|
return (
|
|
63
56
|
<Page {...page} breadcrumbs={parents ?? breadcrumbs}>
|
|
64
|
-
<ul ref={gridRef}
|
|
57
|
+
<ul ref={gridRef} data-terp="hubpage-grid">
|
|
65
58
|
{children}
|
|
66
59
|
</ul>
|
|
67
60
|
</Page>
|
|
@@ -89,86 +82,23 @@ export interface HubCardProps {
|
|
|
89
82
|
renderLink?: RenderHubCardLink;
|
|
90
83
|
}
|
|
91
84
|
|
|
92
|
-
const cardStyle: CSSProperties = {
|
|
93
|
-
height: "100%",
|
|
94
|
-
minHeight: 0,
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const cardBodyStyle: CSSProperties = {
|
|
98
|
-
display: "grid",
|
|
99
|
-
gridTemplateRows: "auto minmax(3rem, 1fr) auto",
|
|
100
|
-
gap: "var(--space-2)",
|
|
101
|
-
height: "100%",
|
|
102
|
-
minHeight: "10rem",
|
|
103
|
-
padding: "var(--space-4)",
|
|
104
|
-
border: "1px solid var(--color-neutral-200)",
|
|
105
|
-
borderRadius: "var(--radius-lg)",
|
|
106
|
-
background: "var(--color-neutral-0)",
|
|
107
|
-
color: "var(--color-neutral-900)",
|
|
108
|
-
boxSizing: "border-box",
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const cardTitleRowStyle: CSSProperties = {
|
|
112
|
-
margin: 0,
|
|
113
|
-
display: "flex",
|
|
114
|
-
alignItems: "center",
|
|
115
|
-
gap: "var(--space-3)",
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const iconTileStyle: CSSProperties = {
|
|
119
|
-
display: "inline-flex",
|
|
120
|
-
alignItems: "center",
|
|
121
|
-
justifyContent: "center",
|
|
122
|
-
width: "2.25rem",
|
|
123
|
-
height: "2.25rem",
|
|
124
|
-
flexShrink: 0,
|
|
125
|
-
borderRadius: "var(--radius-md)",
|
|
126
|
-
background: "var(--color-brand-primary-soft)",
|
|
127
|
-
color: "var(--color-fg-accent)",
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const titleTextStyle: CSSProperties = {
|
|
131
|
-
color: "var(--color-neutral-900)",
|
|
132
|
-
fontSize: "var(--font-size-base)",
|
|
133
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
134
|
-
transition: "color 150ms ease",
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const descriptionStyle: CSSProperties = {
|
|
138
|
-
margin: 0,
|
|
139
|
-
color: "var(--color-neutral-600)",
|
|
140
|
-
fontSize: "var(--font-size-sm)",
|
|
141
|
-
lineHeight: 1.5,
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const emptyDescriptionStyle: CSSProperties = { ...descriptionStyle, visibility: "hidden" };
|
|
145
|
-
|
|
146
|
-
const statStyle: CSSProperties = {
|
|
147
|
-
color: "var(--color-neutral-900)",
|
|
148
|
-
fontSize: "var(--font-size-sm)",
|
|
149
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
150
|
-
};
|
|
151
|
-
const emptyStatStyle: CSSProperties = { ...statStyle, visibility: "hidden" };
|
|
152
|
-
|
|
153
|
-
const linkStyle: CSSProperties = {
|
|
154
|
-
textDecoration: "none",
|
|
155
|
-
color: "inherit",
|
|
156
|
-
display: "block",
|
|
157
|
-
height: "100%",
|
|
158
|
-
minHeight: 0,
|
|
159
|
-
};
|
|
160
|
-
|
|
161
85
|
const anchorRenderLink: RenderHubCardLink = ({ to, children }) => (
|
|
162
|
-
<a href={to} data-terp="hubcard-link"
|
|
86
|
+
<a href={to} data-terp="hubcard-link">
|
|
163
87
|
{children}
|
|
164
88
|
</a>
|
|
165
89
|
);
|
|
166
90
|
|
|
167
91
|
/**
|
|
168
|
-
* A single navigable card inside a {@link HubPage}: icon + title, a short
|
|
92
|
+
* A single navigable card inside a {@link HubPage}: icon + title, a short explanation of
|
|
169
93
|
* the area, and an optional live `stat`. The whole card is one link, rendered through
|
|
170
94
|
* `renderLink` so the hub stays router-agnostic — defaulting to the surrounding router's
|
|
171
95
|
* `Link`, and to a plain anchor only outside a Terp router.
|
|
96
|
+
*
|
|
97
|
+
* The description and stat rows always render, carrying a non-breaking space when the prop
|
|
98
|
+
* is absent, so a card's three grid rows keep their heights and a bare card stays flush
|
|
99
|
+
* with a full one in the same row. `data-empty` is what hides the placeholder — an
|
|
100
|
+
* attribute rather than a second style object, because "is this row a placeholder" is a
|
|
101
|
+
* fact about the element that a rule can read.
|
|
172
102
|
*/
|
|
173
103
|
export function HubCard({
|
|
174
104
|
to,
|
|
@@ -184,27 +114,26 @@ export function HubCard({
|
|
|
184
114
|
(navLink === null
|
|
185
115
|
? anchorRenderLink
|
|
186
116
|
: ({ to: href, children }: { to: string; children: ReactNode }) => (
|
|
187
|
-
<span data-terp="hubcard-link"
|
|
188
|
-
{navLink({ to: href, children })}
|
|
189
|
-
</span>
|
|
117
|
+
<span data-terp="hubcard-link">{navLink({ to: href, children })}</span>
|
|
190
118
|
));
|
|
191
119
|
const resolve = useUiText();
|
|
192
120
|
return (
|
|
193
|
-
<li data-terp="hubcard"
|
|
121
|
+
<li data-terp="hubcard">
|
|
194
122
|
{renderCardLink({
|
|
195
123
|
to,
|
|
196
124
|
children: (
|
|
197
|
-
<span data-terp="hubcard-body"
|
|
198
|
-
<span
|
|
199
|
-
{icon !== undefined && <span
|
|
200
|
-
<strong data-terp="hubcard-title"
|
|
201
|
-
{resolve(title)}
|
|
202
|
-
</strong>
|
|
125
|
+
<span data-terp="hubcard-body">
|
|
126
|
+
<span data-terp="hubcard-heading">
|
|
127
|
+
{icon !== undefined && <span data-terp="hubcard-icon">{icon}</span>}
|
|
128
|
+
<strong data-terp="hubcard-title">{resolve(title)}</strong>
|
|
203
129
|
</span>
|
|
204
|
-
<span
|
|
130
|
+
<span
|
|
131
|
+
data-terp="hubcard-description"
|
|
132
|
+
data-empty={description === undefined ? "true" : undefined}
|
|
133
|
+
>
|
|
205
134
|
{description === undefined ? " " : resolve(description)}
|
|
206
135
|
</span>
|
|
207
|
-
<span data-terp="hubcard-stat"
|
|
136
|
+
<span data-terp="hubcard-stat" data-empty={stat === undefined ? "true" : undefined}>
|
|
208
137
|
{stat ?? " "}
|
|
209
138
|
</span>
|
|
210
139
|
</span>
|
|
@@ -213,4 +142,3 @@ export function HubCard({
|
|
|
213
142
|
</li>
|
|
214
143
|
);
|
|
215
144
|
}
|
|
216
|
-
|
package/src/LoadingState.tsx
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { CSSProperties } from "react";
|
|
2
|
-
|
|
3
1
|
import { injectTerpStyles } from "./styles";
|
|
4
2
|
import { useStrings, useUiText } from "./uiText";
|
|
5
3
|
import type { UiText } from "./uiText";
|
|
@@ -23,13 +21,7 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
|
|
|
23
21
|
<span
|
|
24
22
|
aria-hidden="true"
|
|
25
23
|
data-terp="spinner-ring"
|
|
26
|
-
style={{
|
|
27
|
-
display: "inline-block",
|
|
28
|
-
verticalAlign: "middle",
|
|
29
|
-
width: size,
|
|
30
|
-
height: size,
|
|
31
|
-
lineHeight: 0,
|
|
32
|
-
}}
|
|
24
|
+
style={{ width: size, height: size }}
|
|
33
25
|
>
|
|
34
26
|
<svg
|
|
35
27
|
aria-hidden="true"
|
|
@@ -37,7 +29,6 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
|
|
|
37
29
|
height={size}
|
|
38
30
|
viewBox="0 0 24 24"
|
|
39
31
|
fill="none"
|
|
40
|
-
style={{ display: "block" }}
|
|
41
32
|
>
|
|
42
33
|
<circle
|
|
43
34
|
cx="12"
|
|
@@ -63,18 +54,6 @@ export interface LoadingStateProps {
|
|
|
63
54
|
label?: UiText;
|
|
64
55
|
}
|
|
65
56
|
|
|
66
|
-
const wrapStyle: CSSProperties = {
|
|
67
|
-
display: "flex",
|
|
68
|
-
alignItems: "center",
|
|
69
|
-
justifyContent: "center",
|
|
70
|
-
gap: "var(--space-2)",
|
|
71
|
-
padding: "var(--space-6)",
|
|
72
|
-
color: "var(--color-neutral-500)",
|
|
73
|
-
fontSize: "var(--font-size-sm)",
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const spinnerColorStyle: CSSProperties = { color: "var(--color-fg-accent)" };
|
|
77
|
-
|
|
78
57
|
/**
|
|
79
58
|
* Standard inline loading indicator: a spinner plus a short label, announced
|
|
80
59
|
* as a `status` live region. Use it when a query is pending and the page shell
|
|
@@ -85,8 +64,8 @@ export function LoadingState({ label }: LoadingStateProps) {
|
|
|
85
64
|
const strings = useStrings();
|
|
86
65
|
const resolve = useUiText();
|
|
87
66
|
return (
|
|
88
|
-
<div role="status" data-terp="loading-state"
|
|
89
|
-
<span
|
|
67
|
+
<div role="status" data-terp="loading-state">
|
|
68
|
+
<span data-terp="loading-state-spinner">
|
|
90
69
|
<InlineSpinner size={20} />
|
|
91
70
|
</span>
|
|
92
71
|
<span>{resolve(label ?? strings.loading)}</span>
|
package/src/LoginView.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { FormEvent } from "react";
|
|
3
3
|
|
|
4
4
|
import { TerpMark } from "./icons";
|
|
5
5
|
import { useAuth, useSso } from "./TerpProvider";
|
|
@@ -8,67 +8,6 @@ import { Button } from "./ui/Button";
|
|
|
8
8
|
import { Input } from "./ui/Input";
|
|
9
9
|
import type { SsoProvider } from "./sso";
|
|
10
10
|
|
|
11
|
-
const pageStyle: CSSProperties = {
|
|
12
|
-
minHeight: "100vh",
|
|
13
|
-
display: "grid",
|
|
14
|
-
placeItems: "center",
|
|
15
|
-
padding: "var(--space-6)",
|
|
16
|
-
background: "var(--color-neutral-50)",
|
|
17
|
-
fontFamily: "var(--font-family-sans)",
|
|
18
|
-
color: "var(--color-neutral-900)",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const cardStyle: CSSProperties = {
|
|
22
|
-
width: "100%",
|
|
23
|
-
maxWidth: "24rem",
|
|
24
|
-
display: "grid",
|
|
25
|
-
gap: "var(--space-4)",
|
|
26
|
-
padding: "var(--space-6)",
|
|
27
|
-
background: "var(--color-neutral-0)",
|
|
28
|
-
border: "1px solid var(--color-neutral-200)",
|
|
29
|
-
borderRadius: "var(--radius-lg)",
|
|
30
|
-
boxShadow: "var(--shadow-md)",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const brandStyle: CSSProperties = {
|
|
34
|
-
display: "flex",
|
|
35
|
-
alignItems: "center",
|
|
36
|
-
gap: "var(--space-2)",
|
|
37
|
-
color: "var(--color-neutral-900)",
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const brandTitleStyle: CSSProperties = {
|
|
41
|
-
margin: 0,
|
|
42
|
-
fontSize: "var(--font-size-xl)",
|
|
43
|
-
fontWeight: "var(--font-weight-bold)" as CSSProperties["fontWeight"],
|
|
44
|
-
letterSpacing: 0,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const formStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
|
|
48
|
-
|
|
49
|
-
const fullWidthStyle: CSSProperties = { width: "100%" };
|
|
50
|
-
|
|
51
|
-
const separatorStyle: CSSProperties = {
|
|
52
|
-
display: "flex",
|
|
53
|
-
alignItems: "center",
|
|
54
|
-
gap: "var(--space-2)",
|
|
55
|
-
color: "var(--color-neutral-500)",
|
|
56
|
-
fontSize: "var(--font-size-xs)",
|
|
57
|
-
textTransform: "uppercase",
|
|
58
|
-
letterSpacing: "0.06em",
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const separatorRuleStyle: CSSProperties = {
|
|
62
|
-
flex: 1,
|
|
63
|
-
borderTop: "1px solid var(--color-neutral-200)",
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const errorStyle: CSSProperties = {
|
|
67
|
-
margin: 0,
|
|
68
|
-
color: "var(--color-status-danger)",
|
|
69
|
-
fontSize: "var(--font-size-sm)",
|
|
70
|
-
};
|
|
71
|
-
|
|
72
11
|
export interface LoginViewProps {
|
|
73
12
|
/**
|
|
74
13
|
* SSO providers to offer alongside the credentials form (ADR 0058). Each renders a
|
|
@@ -95,6 +34,12 @@ export interface DevCredentials {
|
|
|
95
34
|
* The default signed-out screen: collects credentials and calls the auth session. Used by
|
|
96
35
|
* {@link renderTerpApp}/`RequireAuth` unless an app supplies its own login view. Pass
|
|
97
36
|
* {@link LoginViewProps.ssoProviders} to add SSO provider buttons under the form.
|
|
37
|
+
*
|
|
38
|
+
* It renders no inline styles: the full-viewport page, the card, the brand row, both button
|
|
39
|
+
* groups, the separator and the error line take their geometry and ink from the injected
|
|
40
|
+
* react-core sheet (ADR 0094). The buttons fill their group through a rule on the group
|
|
41
|
+
* rather than a prop on `Button` — `Button` declares `width: fit-content`, so a grid does not
|
|
42
|
+
* stretch them for free, and a `block` prop for one internal caller is API this does not need.
|
|
98
43
|
*/
|
|
99
44
|
export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps = {}) {
|
|
100
45
|
const auth = useAuth();
|
|
@@ -133,13 +78,13 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
133
78
|
const ssoError = sso.error !== null && sso.error !== undefined ? strings.ssoFailed : null;
|
|
134
79
|
|
|
135
80
|
return (
|
|
136
|
-
<main
|
|
137
|
-
<div
|
|
138
|
-
<div
|
|
81
|
+
<main data-terp="login-view">
|
|
82
|
+
<div data-terp="login-card">
|
|
83
|
+
<div data-terp="login-brand">
|
|
139
84
|
<TerpMark />
|
|
140
|
-
<h1
|
|
85
|
+
<h1 data-terp="login-title">{strings.signIn}</h1>
|
|
141
86
|
</div>
|
|
142
|
-
<form
|
|
87
|
+
<form data-terp="login-form" onSubmit={onSubmit}>
|
|
143
88
|
<Input
|
|
144
89
|
type="email"
|
|
145
90
|
placeholder={strings.email}
|
|
@@ -154,7 +99,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
154
99
|
onChange={(event) => setPassword(event.target.value)}
|
|
155
100
|
required
|
|
156
101
|
/>
|
|
157
|
-
<Button type="submit" disabled={busy}
|
|
102
|
+
<Button type="submit" disabled={busy}>
|
|
158
103
|
{busy ? strings.signingIn : strings.signIn}
|
|
159
104
|
</Button>
|
|
160
105
|
{devCredentials ? (
|
|
@@ -162,7 +107,6 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
162
107
|
type="button"
|
|
163
108
|
variant="secondary"
|
|
164
109
|
disabled={busy}
|
|
165
|
-
style={fullWidthStyle}
|
|
166
110
|
onClick={() => {
|
|
167
111
|
setEmail(devCredentials.email);
|
|
168
112
|
setPassword(devCredentials.password);
|
|
@@ -175,19 +119,18 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
175
119
|
</form>
|
|
176
120
|
{ssoProviders.length > 0 ? (
|
|
177
121
|
<>
|
|
178
|
-
<div
|
|
179
|
-
<span
|
|
122
|
+
<div data-terp="login-separator" aria-hidden="true">
|
|
123
|
+
<span data-terp="login-separator-rule" />
|
|
180
124
|
<span>{strings.orSeparator}</span>
|
|
181
|
-
<span
|
|
125
|
+
<span data-terp="login-separator-rule" />
|
|
182
126
|
</div>
|
|
183
|
-
<div
|
|
127
|
+
<div data-terp="login-sso">
|
|
184
128
|
{ssoProviders.map((provider) => (
|
|
185
129
|
<Button
|
|
186
130
|
key={provider.name}
|
|
187
131
|
type="button"
|
|
188
132
|
variant="secondary"
|
|
189
133
|
disabled={busy}
|
|
190
|
-
style={fullWidthStyle}
|
|
191
134
|
onClick={() => void onSso(provider)}
|
|
192
135
|
>
|
|
193
136
|
{`${strings.continueWith} ${provider.label ?? provider.name}`}
|
|
@@ -196,7 +139,11 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
196
139
|
</div>
|
|
197
140
|
</>
|
|
198
141
|
) : null}
|
|
199
|
-
{error ?? ssoError ?
|
|
142
|
+
{error ?? ssoError ? (
|
|
143
|
+
<p role="alert" data-terp="login-error">
|
|
144
|
+
{error ?? ssoError}
|
|
145
|
+
</p>
|
|
146
|
+
) : null}
|
|
200
147
|
</div>
|
|
201
148
|
</main>
|
|
202
149
|
);
|
package/src/ModuleNav.test.tsx
CHANGED
|
@@ -60,6 +60,25 @@ describe("ModuleNav", () => {
|
|
|
60
60
|
"page",
|
|
61
61
|
);
|
|
62
62
|
expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current");
|
|
63
|
+
|
|
64
|
+
// The styling key is data-active, and the point is that it is NOT the aria-current
|
|
65
|
+
// beside it. A router Link merges its own active props last, so aria-current on this
|
|
66
|
+
// element has a second author — keying the accent edge on it would be the breadcrumb
|
|
67
|
+
// defect again the moment the two disagreed about "active", which they can, in BOTH
|
|
68
|
+
// directions: the router also demands an exact query-string match (so it is narrower
|
|
69
|
+
// on a filtered URL), and it compares paths through removeTrailingSlash while this
|
|
70
|
+
// component compares them raw (so it is broader on a trailing slash, and then the
|
|
71
|
+
// accent edge goes missing on a tab the router calls current). The second is a real
|
|
72
|
+
// defect, older than the marker — the inline styling read the same isActive — and it
|
|
73
|
+
// is the navigation model's to fix. Neither case is reachable from this specimen.
|
|
74
|
+
const active = screen.getByRole("link", { name: "Projects" });
|
|
75
|
+
const inactive = screen.getByRole("link", { name: "Overview" });
|
|
76
|
+
expect(active).toHaveAttribute("data-terp", "module-nav-link");
|
|
77
|
+
expect(active).toHaveAttribute("data-active", "true");
|
|
78
|
+
expect(inactive).toHaveAttribute("data-terp", "module-nav-link");
|
|
79
|
+
expect(inactive).not.toHaveAttribute("data-active");
|
|
80
|
+
expect(active.getAttribute("style")).toBeNull();
|
|
81
|
+
expect(inactive.getAttribute("style")).toBeNull();
|
|
63
82
|
});
|
|
64
83
|
|
|
65
84
|
it("returns nothing for an empty tab list", () => {
|
package/src/ModuleNav.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
|
-
import type { CSSProperties } from "react";
|
|
3
2
|
|
|
4
3
|
import { useStrings, useUiText } from "./uiText";
|
|
5
4
|
import type { UiText } from "./uiText";
|
|
@@ -17,38 +16,17 @@ export interface ModuleNavProps {
|
|
|
17
16
|
ariaLabel?: UiText;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
const navStyle: CSSProperties = {
|
|
21
|
-
borderBottom: "1px solid var(--color-neutral-200)",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const listStyle: CSSProperties = {
|
|
25
|
-
listStyle: "none",
|
|
26
|
-
margin: 0,
|
|
27
|
-
padding: 0,
|
|
28
|
-
display: "flex",
|
|
29
|
-
flexWrap: "wrap",
|
|
30
|
-
gap: "var(--space-3)",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const linkStyle: CSSProperties = {
|
|
34
|
-
display: "inline-flex",
|
|
35
|
-
alignItems: "center",
|
|
36
|
-
padding: "var(--space-2) 0",
|
|
37
|
-
color: "var(--color-neutral-600)",
|
|
38
|
-
textDecoration: "none",
|
|
39
|
-
borderBottom: "2px solid transparent",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const activeLinkStyle: CSSProperties = {
|
|
43
|
-
color: "var(--color-neutral-900)",
|
|
44
|
-
borderBottomColor: "var(--color-fg-accent)",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
19
|
/**
|
|
48
20
|
* Secondary horizontal navigation for intra-module sub-pages.
|
|
49
21
|
*
|
|
50
22
|
* Each tab links to a real TanStack Router route so sub-pages keep their own URL,
|
|
51
23
|
* lazy loading, and back-button behavior. The active tab is matched exactly.
|
|
24
|
+
*
|
|
25
|
+
* It renders no inline styles: the strip, the list and the links take their geometry and
|
|
26
|
+
* ink from the injected react-core sheet (ADR 0094). The active tab is styled from
|
|
27
|
+
* `data-active`, which this component alone writes, rather than from the `aria-current` it
|
|
28
|
+
* also sets — a router `Link` merges its own `aria-current` last, so that attribute has a
|
|
29
|
+
* second author and would be the breadcrumb mistake again.
|
|
52
30
|
*/
|
|
53
31
|
export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
54
32
|
const strings = useStrings();
|
|
@@ -60,12 +38,8 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
|
60
38
|
}
|
|
61
39
|
|
|
62
40
|
return (
|
|
63
|
-
<nav
|
|
64
|
-
|
|
65
|
-
data-terp="module-nav"
|
|
66
|
-
style={navStyle}
|
|
67
|
-
>
|
|
68
|
-
<ul style={listStyle}>
|
|
41
|
+
<nav aria-label={resolve(ariaLabel ?? strings.moduleNavigationLabel)} data-terp="module-nav">
|
|
42
|
+
<ul data-terp="module-nav-list">
|
|
69
43
|
{items.map((item) => {
|
|
70
44
|
const label = resolve(item.label);
|
|
71
45
|
const isActive = pathname === item.to;
|
|
@@ -75,7 +49,8 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
|
75
49
|
to={item.to}
|
|
76
50
|
activeOptions={{ exact: true }}
|
|
77
51
|
aria-current={isActive ? "page" : undefined}
|
|
78
|
-
|
|
52
|
+
data-terp="module-nav-link"
|
|
53
|
+
data-active={isActive ? "true" : undefined}
|
|
79
54
|
>
|
|
80
55
|
{label}
|
|
81
56
|
</Link>
|
package/src/Page.test.tsx
CHANGED
|
@@ -18,13 +18,16 @@ describe("Page", () => {
|
|
|
18
18
|
</Page>,
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
|
|
22
|
-
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toHaveStyle({
|
|
23
|
-
fontSize: "var(--font-size-lg)",
|
|
24
|
-
letterSpacing: "0",
|
|
25
|
-
});
|
|
26
|
-
expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
|
|
27
21
|
const title = screen.getByRole("heading", { level: 1, name: "Tasks" });
|
|
22
|
+
expect(title).toBeInTheDocument();
|
|
23
|
+
// The marker, not the declaration. jsdom computes no cascade, so toHaveStyle could only
|
|
24
|
+
// ever see an inline style — asserting the title's font size was asserting that Page
|
|
25
|
+
// styles itself from a style object, which is the thing ADR 0094 removes. What a unit
|
|
26
|
+
// test can hold is the fact the sheet keys on; the type is gated by styles.test.ts for
|
|
27
|
+
// existence and by the page-header baseline for what it renders as.
|
|
28
|
+
expect(title).toHaveAttribute("data-terp", "page-title");
|
|
29
|
+
expect(title.getAttribute("style")).toBeNull();
|
|
30
|
+
expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
|
|
28
31
|
const action = screen.getByRole("button", { name: "New" });
|
|
29
32
|
expect(title.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
30
33
|
expect(screen.getByText("body")).toBeInTheDocument();
|
package/src/Page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import { useContext, useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
import { Breadcrumbs } from "./Breadcrumbs";
|
|
@@ -40,39 +40,6 @@ export interface PageProps {
|
|
|
40
40
|
children: ReactNode;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const pageStyle: CSSProperties = {
|
|
44
|
-
display: "grid",
|
|
45
|
-
gridTemplateColumns: "minmax(0, 1fr)",
|
|
46
|
-
gap: "var(--space-4)",
|
|
47
|
-
alignContent: "start",
|
|
48
|
-
minWidth: 0,
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const headerStyle: CSSProperties = { display: "grid", gap: "var(--space-2)" };
|
|
52
|
-
|
|
53
|
-
const breadcrumbRowStyle: CSSProperties = {
|
|
54
|
-
display: "flex",
|
|
55
|
-
alignItems: "center",
|
|
56
|
-
minHeight: "2rem",
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const titleRowStyle: CSSProperties = {
|
|
60
|
-
display: "flex",
|
|
61
|
-
alignItems: "center",
|
|
62
|
-
justifyContent: "space-between",
|
|
63
|
-
gap: "var(--space-3)",
|
|
64
|
-
flexWrap: "wrap",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const titleStyle: CSSProperties = {
|
|
68
|
-
margin: 0,
|
|
69
|
-
fontSize: "var(--font-size-lg)",
|
|
70
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
71
|
-
letterSpacing: 0,
|
|
72
|
-
color: "var(--color-neutral-900)",
|
|
73
|
-
lineHeight: 1.3,
|
|
74
|
-
};
|
|
75
|
-
|
|
76
43
|
/**
|
|
77
44
|
* The base content-page frame: every routed view is constructed the same way — one
|
|
78
45
|
* header holding the breadcrumb trail (when there is a path back up through the
|
|
@@ -85,6 +52,9 @@ const titleStyle: CSSProperties = {
|
|
|
85
52
|
* The frame also owns the async body states: `error` (which wins, so a failed
|
|
86
53
|
* query never hides behind a spinner) then `isLoading` replace the body while the
|
|
87
54
|
* header stays put, so the user keeps their place in the layers.
|
|
55
|
+
*
|
|
56
|
+
* It renders no inline styles: the frame's geometry and the title's type come from the
|
|
57
|
+
* injected react-core sheet, matched on the `data-terp` markers stamped below (ADR 0094).
|
|
88
58
|
*/
|
|
89
59
|
export function Page({
|
|
90
60
|
title,
|
|
@@ -138,15 +108,21 @@ export function Page({
|
|
|
138
108
|
children
|
|
139
109
|
);
|
|
140
110
|
return (
|
|
141
|
-
<article ref={articleRef}
|
|
142
|
-
<header
|
|
111
|
+
<article ref={articleRef} data-terp="page">
|
|
112
|
+
{/* A <header> ELEMENT, and it has to stay one. The slot check above drops the header
|
|
113
|
+
from the body set by tagName, so re-rendering this as a marked <div> would put it
|
|
114
|
+
back in and fail every governed OverviewPage / DetailPage closed. The marker is
|
|
115
|
+
additive; the tag is load-bearing. For the same reason the body below takes no
|
|
116
|
+
wrapper of its own — not even a display: contents one, since article.children is a
|
|
117
|
+
DOM traversal and would see it. */}
|
|
118
|
+
<header data-terp="page-header">
|
|
143
119
|
{hasAncestors && (
|
|
144
|
-
<div
|
|
120
|
+
<div data-terp="page-breadcrumbs">
|
|
145
121
|
<Breadcrumbs items={trail} renderLink={renderLink} />
|
|
146
122
|
</div>
|
|
147
123
|
)}
|
|
148
|
-
<div
|
|
149
|
-
<h1
|
|
124
|
+
<div data-terp="page-heading">
|
|
125
|
+
<h1 data-terp="page-title">{resolve(title)}</h1>
|
|
150
126
|
{actions}
|
|
151
127
|
</div>
|
|
152
128
|
</header>
|
package/src/PageActions.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
+
import { injectTerpStyles } from "./styles";
|
|
3
4
|
import { Menu, MenuItem } from "./ui/Menu";
|
|
4
5
|
import { useStrings } from "./uiText";
|
|
5
6
|
import type { UiText } from "./uiText";
|
|
6
7
|
|
|
8
|
+
injectTerpStyles();
|
|
9
|
+
|
|
7
10
|
export interface OverflowAction {
|
|
8
11
|
/** Display label for the menu item. */
|
|
9
12
|
label: UiText;
|
|
@@ -25,14 +28,6 @@ export interface PageActionsProps {
|
|
|
25
28
|
className?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
const clusterStyle: CSSProperties = {
|
|
29
|
-
display: "flex",
|
|
30
|
-
flexWrap: "wrap",
|
|
31
|
-
alignItems: "center",
|
|
32
|
-
justifyContent: "flex-end",
|
|
33
|
-
gap: "var(--space-2)",
|
|
34
|
-
};
|
|
35
|
-
|
|
36
31
|
/** Standard right-aligned action cluster for page headers. */
|
|
37
32
|
export function PageActions({ primary, secondary, overflow, className }: PageActionsProps) {
|
|
38
33
|
const strings = useStrings();
|
|
@@ -43,7 +38,7 @@ export function PageActions({ primary, secondary, overflow, className }: PageAct
|
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
return (
|
|
46
|
-
<div className={className}
|
|
41
|
+
<div className={className} data-terp="page-actions">
|
|
47
42
|
{hasOverflow && (
|
|
48
43
|
<Menu trigger="⋯" triggerLabel={strings.moreActions}>
|
|
49
44
|
{({ close }) => (
|
package/src/ProfileView.test.tsx
CHANGED
|
@@ -69,6 +69,21 @@ describe("ProfileView", () => {
|
|
|
69
69
|
);
|
|
70
70
|
expect(screen.getAllByText("jane.doe@example.com").length).toBeGreaterThanOrEqual(1);
|
|
71
71
|
expect(screen.getByText("editor (20)")).toBeInTheDocument();
|
|
72
|
+
|
|
73
|
+
// The identity block, which nothing asserted before: the initials tile was entirely
|
|
74
|
+
// unreached by any test, and is aria-hidden so axe skips it too. Markers plus the
|
|
75
|
+
// absence of a style attribute, because the geometry is a sheet rule now (ADR 0094) and
|
|
76
|
+
// jsdom computes no cascade. The address marker matters most of the three — its
|
|
77
|
+
// overflow-wrap has no specimen, since the mock session's address is short, so this is
|
|
78
|
+
// the only gate that the element a long address would need is still the marked one.
|
|
79
|
+
const avatar = document.querySelector('[data-terp="profile-avatar"]');
|
|
80
|
+
expect(avatar).not.toBeNull();
|
|
81
|
+
expect(avatar?.textContent).toBe("JD");
|
|
82
|
+
expect(avatar?.getAttribute("style")).toBeNull();
|
|
83
|
+
const email = document.querySelector('[data-terp="profile-email"]');
|
|
84
|
+
expect(email?.textContent).toBe("jane.doe@example.com");
|
|
85
|
+
expect(email?.getAttribute("style")).toBeNull();
|
|
86
|
+
expect(document.querySelectorAll('[data-terp="profile-card"]')).toHaveLength(2);
|
|
72
87
|
// The stacked preference controls live here (settings surface).
|
|
73
88
|
expect(screen.getByLabelText("Theme")).toBeInTheDocument();
|
|
74
89
|
expect(screen.getByLabelText("Language")).toBeInTheDocument();
|