@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/theme.tsx
CHANGED
|
@@ -135,8 +135,29 @@ export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
|
|
|
135
135
|
contrast: strings.themeContrast,
|
|
136
136
|
system: strings.themeSystem,
|
|
137
137
|
};
|
|
138
|
+
// Hoisted out of the attribute rather than inlined as `variant === "inline"`, and not for
|
|
139
|
+
// readability: the marker inventory scanner reads every string literal inside a
|
|
140
|
+
// `data-terp={…}` expression as a marker, so the comparison's own "inline" was picked up as
|
|
141
|
+
// a component marker that nothing styles. Keep marker expressions to marker literals.
|
|
142
|
+
const isInline = variant === "inline";
|
|
138
143
|
const menu = (
|
|
139
144
|
<Menu
|
|
145
|
+
// The rendered root of the inline variant IS this Menu's popover wrapper — the
|
|
146
|
+
// component adds no element of its own — so the marker has to travel through Menu to
|
|
147
|
+
// land there. Both variants wear the same name because they are the same component;
|
|
148
|
+
// data-variant is what tells them apart.
|
|
149
|
+
// Claim the root ONLY when this Menu is the root, which is the inline variant. The
|
|
150
|
+
// stacked variant renders its own div and puts the menu inside it, so stamping the
|
|
151
|
+
// same marker unconditionally put it on BOTH elements — and the inner wrapper then
|
|
152
|
+
// matched the stacked grid rule instead of the popover wrapper's geometry. The
|
|
153
|
+
// baselines did not catch that: a one-child grid and a one-child inline-flex box
|
|
154
|
+
// shrink-wrap to the same pixels, so it was wrong and invisible at the same time.
|
|
155
|
+
data-terp={isInline ? "theme-toggle" : undefined}
|
|
156
|
+
data-variant={isInline ? "inline" : undefined}
|
|
157
|
+
// Unconditional, unlike the root marker: the panel is the same panel in both variants, so
|
|
158
|
+
// a rule for it must reach both. Deriving the owner from the conditional root marker made
|
|
159
|
+
// this panel "theme-toggle" when inline and "popover" when stacked.
|
|
160
|
+
data-owner="theme-toggle"
|
|
140
161
|
trigger={<Icon name={THEME_ICONS[context.theme]} size="1.15rem" />}
|
|
141
162
|
triggerLabel={strings.theme}
|
|
142
163
|
>
|
|
@@ -158,12 +179,12 @@ export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
|
|
|
158
179
|
)}
|
|
159
180
|
</Menu>
|
|
160
181
|
);
|
|
161
|
-
if (
|
|
182
|
+
if (isInline) {
|
|
162
183
|
return menu;
|
|
163
184
|
}
|
|
164
185
|
return (
|
|
165
|
-
<div
|
|
166
|
-
<span
|
|
186
|
+
<div data-terp="theme-toggle" data-variant="stacked">
|
|
187
|
+
<span data-terp="theme-toggle-label">{strings.theme}</span>
|
|
167
188
|
{menu}
|
|
168
189
|
</div>
|
|
169
190
|
);
|
package/src/toast.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { Icon } from "./icons";
|
|
5
|
+
import { injectTerpStyles } from "./styles";
|
|
5
6
|
import { useStrings, useUiText } from "./uiText";
|
|
6
7
|
import type { UiText } from "./uiText";
|
|
7
8
|
|
|
9
|
+
injectTerpStyles();
|
|
10
|
+
|
|
8
11
|
export type ToastVariant = "success" | "error" | "warning";
|
|
9
12
|
|
|
10
13
|
export interface ToastOptions {
|
|
@@ -36,65 +39,30 @@ const ToastContext = createContext<ToastApi | null>(null);
|
|
|
36
39
|
|
|
37
40
|
const DEFAULT_DURATION_MS = 5000;
|
|
38
41
|
|
|
39
|
-
const viewportStyle: CSSProperties = {
|
|
40
|
-
position: "fixed",
|
|
41
|
-
bottom: "var(--space-4)",
|
|
42
|
-
right: "var(--space-4)",
|
|
43
|
-
display: "grid",
|
|
44
|
-
gap: "var(--space-2)",
|
|
45
|
-
zIndex: 100,
|
|
46
|
-
maxWidth: "min(22.5rem, calc(100vw - 2 * var(--space-4)))",
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const toastStyle = (variant: ToastVariant): CSSProperties => ({
|
|
50
|
-
display: "grid",
|
|
51
|
-
gridTemplateColumns: "auto 1fr auto",
|
|
52
|
-
alignItems: "start",
|
|
53
|
-
gap: "var(--space-2)",
|
|
54
|
-
padding: "var(--space-3) var(--space-4)",
|
|
55
|
-
borderRadius: "var(--radius-md)",
|
|
56
|
-
border: `1px solid ${borderColor[variant]}`,
|
|
57
|
-
borderInlineStart: `3px solid ${titleColor[variant]}`,
|
|
58
|
-
background: "var(--color-neutral-0)",
|
|
59
|
-
color: "var(--color-neutral-900)",
|
|
60
|
-
fontSize: "var(--font-size-sm)",
|
|
61
|
-
boxShadow: "var(--shadow-md)",
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const titleColor: Record<ToastVariant, string> = {
|
|
65
|
-
success: "var(--color-status-success)",
|
|
66
|
-
error: "var(--color-status-danger)",
|
|
67
|
-
warning: "var(--color-status-warning)",
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const borderColor: Record<ToastVariant, string> = {
|
|
71
|
-
success: "var(--color-status-success-soft)",
|
|
72
|
-
error: "var(--color-status-danger-soft)",
|
|
73
|
-
warning: "var(--color-status-warning-soft)",
|
|
74
|
-
};
|
|
75
|
-
|
|
76
42
|
const iconName: Record<ToastVariant, string> = {
|
|
77
43
|
success: "check",
|
|
78
44
|
error: "x",
|
|
79
45
|
warning: "bell",
|
|
80
46
|
};
|
|
81
47
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The `data-tone` value each variant paints as.
|
|
50
|
+
*
|
|
51
|
+
* `data-tone` rather than `data-variant`, because a toast's success/error/warning IS a status
|
|
52
|
+
* tone and `Badge` and `Alert` already key theirs that way — an app restyling one tone should
|
|
53
|
+
* not have to learn two attribute names for the same idea. The one translation is `error` to
|
|
54
|
+
* `danger`: the shared tone vocabulary is {neutral, info, success, warning, danger}, and
|
|
55
|
+
* admitting a fourth synonym to it would mean `[data-terp="toast"][data-tone="danger"]`
|
|
56
|
+
* silently matching nothing for someone who reasoned by analogy from the alert. The mapping is
|
|
57
|
+
* not new indirection either — the component already resolved `error` to
|
|
58
|
+
* `--color-status-danger` for its colours; this states it once instead.
|
|
59
|
+
*
|
|
60
|
+
* `toast.error()` is unchanged: the method name is the API, and this is the DOM.
|
|
61
|
+
*/
|
|
62
|
+
const toneOf: Record<ToastVariant, string> = {
|
|
63
|
+
success: "success",
|
|
64
|
+
error: "danger",
|
|
65
|
+
warning: "warning",
|
|
98
66
|
};
|
|
99
67
|
|
|
100
68
|
function ToastCard({ toast, onDismiss }: { toast: ToastItem; onDismiss: () => void }) {
|
|
@@ -106,30 +74,26 @@ function ToastCard({ toast, onDismiss }: { toast: ToastItem; onDismiss: () => vo
|
|
|
106
74
|
warning: strings.warningTitle,
|
|
107
75
|
};
|
|
108
76
|
return (
|
|
109
|
-
<div
|
|
110
|
-
|
|
77
|
+
<div
|
|
78
|
+
role={toast.variant === "success" ? "status" : "alert"}
|
|
79
|
+
data-terp="toast"
|
|
80
|
+
data-tone={toneOf[toast.variant]}
|
|
81
|
+
>
|
|
82
|
+
<span aria-hidden="true" data-terp="toast-icon">
|
|
111
83
|
<Icon name={iconName[toast.variant]} size="1.1rem" />
|
|
112
84
|
</span>
|
|
113
|
-
<div
|
|
114
|
-
<strong
|
|
115
|
-
style={{
|
|
116
|
-
color: titleColor[toast.variant],
|
|
117
|
-
fontWeight: "var(--font-weight-semibold)" as never,
|
|
118
|
-
}}
|
|
119
|
-
>
|
|
85
|
+
<div data-terp="toast-body">
|
|
86
|
+
<strong data-terp="toast-title">
|
|
120
87
|
{resolve(toast.title ?? defaultTitle[toast.variant])}
|
|
121
88
|
</strong>
|
|
122
89
|
{toast.description !== null && toast.description !== undefined && (
|
|
123
90
|
<div>{toast.description}</div>
|
|
124
91
|
)}
|
|
125
92
|
</div>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
style={dismissStyle}
|
|
131
|
-
onClick={onDismiss}
|
|
132
|
-
>
|
|
93
|
+
{/* Keeps the shared iconbutton marker and is addressed structurally, the way the
|
|
94
|
+
combobox's clear button and the calendar's month arrows are: it is an icon button,
|
|
95
|
+
and the only thing distinguishing it is where it sits. */}
|
|
96
|
+
<button type="button" data-terp="iconbutton" aria-label={strings.dismiss} onClick={onDismiss}>
|
|
133
97
|
×
|
|
134
98
|
</button>
|
|
135
99
|
</div>
|
|
@@ -189,7 +153,7 @@ export function ToastProvider({ children }: ToastProviderProps) {
|
|
|
189
153
|
<ToastContext.Provider value={api}>
|
|
190
154
|
{children}
|
|
191
155
|
{toasts.length > 0 && (
|
|
192
|
-
<div
|
|
156
|
+
<div data-terp="toast-viewport">
|
|
193
157
|
{toasts.map((toast) => (
|
|
194
158
|
<ToastCard key={toast.id} toast={toast} onDismiss={() => dismiss(toast.id)} />
|
|
195
159
|
))}
|
package/src/ui/Alert.test.tsx
CHANGED
|
@@ -16,4 +16,16 @@ describe("Alert", () => {
|
|
|
16
16
|
render(<Alert tone="danger">Delete failed.</Alert>);
|
|
17
17
|
expect(screen.getByRole("alert")).toHaveTextContent("Delete failed.");
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
it("names its tone on the banner, which is what paints the frame and the glyph", () => {
|
|
21
|
+
render(<Alert tone="warning">Check the mapping.</Alert>);
|
|
22
|
+
const banner = screen.getByRole("alert");
|
|
23
|
+
expect(banner).toHaveAttribute("data-tone", "warning");
|
|
24
|
+
expect(banner.getAttribute("style")).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("defaults to the info tone", () => {
|
|
28
|
+
render(<Alert>Nothing to do.</Alert>);
|
|
29
|
+
expect(screen.getByRole("status")).toHaveAttribute("data-tone", "info");
|
|
30
|
+
});
|
|
19
31
|
});
|
package/src/ui/Alert.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 { useUiText } from "../uiText";
|
|
4
5
|
import type { UiText } from "../uiText";
|
|
5
6
|
import type { BadgeTone } from "./Badge";
|
|
6
7
|
|
|
8
|
+
injectTerpStyles();
|
|
9
|
+
|
|
7
10
|
export type AlertTone = BadgeTone;
|
|
8
11
|
|
|
9
12
|
export interface AlertProps {
|
|
@@ -12,43 +15,6 @@ export interface AlertProps {
|
|
|
12
15
|
children: ReactNode;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
const toneColor: Record<AlertTone, string> = {
|
|
16
|
-
neutral: "var(--color-neutral-600)",
|
|
17
|
-
info: "var(--color-status-info)",
|
|
18
|
-
success: "var(--color-status-success)",
|
|
19
|
-
warning: "var(--color-status-warning)",
|
|
20
|
-
danger: "var(--color-status-danger)",
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const toneSoft: Record<AlertTone, string> = {
|
|
24
|
-
neutral: "var(--color-neutral-50)",
|
|
25
|
-
info: "var(--color-status-info-soft)",
|
|
26
|
-
success: "var(--color-status-success-soft)",
|
|
27
|
-
warning: "var(--color-status-warning-soft)",
|
|
28
|
-
danger: "var(--color-status-danger-soft)",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const alertStyle = (tone: AlertTone): CSSProperties => ({
|
|
32
|
-
display: "grid",
|
|
33
|
-
gridTemplateColumns: "auto 1fr",
|
|
34
|
-
gap: "var(--space-3)",
|
|
35
|
-
padding: "var(--space-3) var(--space-4)",
|
|
36
|
-
border: `1px solid ${toneColor[tone]}`,
|
|
37
|
-
borderRadius: "var(--radius-md)",
|
|
38
|
-
color: "var(--color-neutral-900)",
|
|
39
|
-
background: toneSoft[tone],
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const iconWrapStyle = (tone: AlertTone): CSSProperties => ({
|
|
43
|
-
color: toneColor[tone],
|
|
44
|
-
display: "inline-flex",
|
|
45
|
-
alignItems: "flex-start",
|
|
46
|
-
paddingTop: "2px",
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const bodyStyle: CSSProperties = { display: "grid", gap: "var(--space-1)", minWidth: 0 };
|
|
50
|
-
const titleStyle: CSSProperties = { fontWeight: "var(--font-weight-semibold)" as never };
|
|
51
|
-
|
|
52
18
|
const glyphProps = {
|
|
53
19
|
width: 20,
|
|
54
20
|
height: 20,
|
|
@@ -95,18 +61,24 @@ const toneIcon: Record<AlertTone, ReactNode> = {
|
|
|
95
61
|
),
|
|
96
62
|
};
|
|
97
63
|
|
|
98
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Inline banner for persistent feedback; warnings and errors announce as alerts.
|
|
66
|
+
*
|
|
67
|
+
* The tone is a `data-tone` attribute rather than a style object: the sheet paints the
|
|
68
|
+
* frame, the tint and the glyph from it, and the body restates the reading colour so the
|
|
69
|
+
* copy stays neutral while the frame carries the tone (ADR 0094).
|
|
70
|
+
*/
|
|
99
71
|
export function Alert({ tone = "info", title, children }: AlertProps) {
|
|
100
72
|
const resolve = useUiText();
|
|
101
73
|
return (
|
|
102
74
|
<div
|
|
103
75
|
role={tone === "warning" || tone === "danger" ? "alert" : "status"}
|
|
104
76
|
data-terp="alert"
|
|
105
|
-
|
|
77
|
+
data-tone={tone}
|
|
106
78
|
>
|
|
107
|
-
<span
|
|
108
|
-
<div
|
|
109
|
-
{title !== undefined && <strong
|
|
79
|
+
<span data-terp="alert-icon">{toneIcon[tone]}</span>
|
|
80
|
+
<div data-terp="alert-body">
|
|
81
|
+
{title !== undefined && <strong data-terp="alert-title">{resolve(title)}</strong>}
|
|
110
82
|
<div>{children}</div>
|
|
111
83
|
</div>
|
|
112
84
|
</div>
|
package/src/ui/Badge.test.tsx
CHANGED
|
@@ -7,13 +7,24 @@ import { Badge } from "./Badge";
|
|
|
7
7
|
afterEach(cleanup);
|
|
8
8
|
|
|
9
9
|
describe("Badge", () => {
|
|
10
|
-
|
|
10
|
+
// The tone is asserted as the attribute the sheet keys on, not as a resolved colour
|
|
11
|
+
// (ADR 0094) — which is also the tone DataView reads to tint a row, so the two can
|
|
12
|
+
// never disagree about what "success" means.
|
|
13
|
+
it("names its tone on the pill and carries no inline styling", () => {
|
|
11
14
|
render(<Badge label="Active" tone="success" />);
|
|
12
|
-
|
|
15
|
+
const pill = screen.getByText("Active");
|
|
16
|
+
expect(pill).toHaveAttribute("data-terp", "badge");
|
|
17
|
+
expect(pill).toHaveAttribute("data-tone", "success");
|
|
18
|
+
expect(pill.getAttribute("style")).toBeNull();
|
|
13
19
|
});
|
|
14
20
|
|
|
15
21
|
it("takes its text as children too, the way every other component does", () => {
|
|
16
22
|
render(<Badge tone="danger">No drift</Badge>);
|
|
17
|
-
expect(screen.getByText("No drift")
|
|
23
|
+
expect(screen.getByText("No drift")).toHaveAttribute("data-tone", "danger");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("defaults to the neutral tone", () => {
|
|
27
|
+
render(<Badge label="Draft" />);
|
|
28
|
+
expect(screen.getByText("Draft")).toHaveAttribute("data-tone", "neutral");
|
|
18
29
|
});
|
|
19
30
|
});
|
package/src/ui/Badge.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { injectTerpStyles } from "../styles";
|
|
3
2
|
import { useUiText } from "../uiText";
|
|
4
3
|
import type { UiText } from "../uiText";
|
|
5
4
|
|
|
5
|
+
injectTerpStyles();
|
|
6
|
+
|
|
6
7
|
export type BadgeTone = "neutral" | "info" | "success" | "warning" | "danger";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -18,17 +19,14 @@ export type BadgeProps = { tone?: BadgeTone } & (
|
|
|
18
19
|
| { children: UiText; label?: never }
|
|
19
20
|
);
|
|
20
21
|
|
|
21
|
-
const toneColor: Record<BadgeTone, string> = {
|
|
22
|
-
neutral: "var(--color-neutral-600)",
|
|
23
|
-
info: "var(--color-status-info)",
|
|
24
|
-
success: "var(--color-status-success)",
|
|
25
|
-
warning: "var(--color-status-warning)",
|
|
26
|
-
danger: "var(--color-status-danger)",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
22
|
/**
|
|
30
23
|
* Soft tint per tone — exported (not via the package barrel) so DataView's row/card
|
|
31
24
|
* tinting resolves a tone to the exact same tokens the Badge pill uses.
|
|
25
|
+
*
|
|
26
|
+
* The pill itself no longer reads this map: its tones are rules in the sheet, keyed on
|
|
27
|
+
* `data-tone` (ADR 0094). It stays because DataView tints rows and cards from an inline
|
|
28
|
+
* background and still needs the tone-to-token mapping; that call site is the one that
|
|
29
|
+
* removes this export, when the DataView cluster migrates.
|
|
32
30
|
*/
|
|
33
31
|
export const toneSoftColors: Record<BadgeTone, string> = {
|
|
34
32
|
neutral: "var(--color-neutral-100)",
|
|
@@ -38,28 +36,11 @@ export const toneSoftColors: Record<BadgeTone, string> = {
|
|
|
38
36
|
danger: "var(--color-status-danger-soft)",
|
|
39
37
|
};
|
|
40
38
|
|
|
41
|
-
const badgeStyle = (tone: BadgeTone): CSSProperties => ({
|
|
42
|
-
display: "inline-flex",
|
|
43
|
-
alignItems: "center",
|
|
44
|
-
border: `1px solid ${toneSoftColors[tone]}`,
|
|
45
|
-
borderRadius: "var(--radius-full)",
|
|
46
|
-
padding: "2px var(--space-2)",
|
|
47
|
-
color: toneColor[tone],
|
|
48
|
-
background: toneSoftColors[tone],
|
|
49
|
-
fontSize: "var(--font-size-xs)",
|
|
50
|
-
fontWeight: "var(--font-weight-semibold)" as never,
|
|
51
|
-
lineHeight: 1.4,
|
|
52
|
-
whiteSpace: "nowrap",
|
|
53
|
-
});
|
|
54
|
-
|
|
55
39
|
/** Small token-styled status pill — flat soft tint with a matching text colour. */
|
|
56
40
|
export function Badge({ label, children, tone = "neutral" }: BadgeProps) {
|
|
57
41
|
const resolve = useUiText();
|
|
58
|
-
// `data-tone` alongside the marker: the tone is currently readable only from the inline
|
|
59
|
-
// colour values, so a stylesheet rule cannot address "the warning pill" at all. Naming it
|
|
60
|
-
// as an attribute is what lets tone styling move out of `style={}` later.
|
|
61
42
|
return (
|
|
62
|
-
<span data-terp="badge" data-tone={tone}
|
|
43
|
+
<span data-terp="badge" data-tone={tone}>
|
|
63
44
|
{resolve((label ?? children) as UiText)}
|
|
64
45
|
</span>
|
|
65
46
|
);
|
package/src/ui/Button.test.tsx
CHANGED
|
@@ -7,20 +7,25 @@ import { Button } from "./Button";
|
|
|
7
7
|
afterEach(cleanup);
|
|
8
8
|
|
|
9
9
|
describe("Button", () => {
|
|
10
|
-
|
|
10
|
+
// These assert the attributes rather than `style.background` (ADR 0094). The variant is
|
|
11
|
+
// the semantic claim — that the sheet paints it is the sheet's business, and the visual
|
|
12
|
+
// baselines are what prove the paint. Asserting the absence of an inline style is the
|
|
13
|
+
// other half: it is what makes the rules in the sheet reachable at all, so a component
|
|
14
|
+
// that quietly regrew a base `style={}` would silently take back its own restyleability.
|
|
15
|
+
it("renders an accessible button with a default type and no inline styling", () => {
|
|
11
16
|
render(<Button>Save</Button>);
|
|
12
17
|
const button = screen.getByRole("button", { name: "Save" });
|
|
13
18
|
expect(button).toHaveAttribute("type", "button");
|
|
14
19
|
expect(button).toHaveAttribute("data-terp", "button");
|
|
15
20
|
expect(button).toHaveAttribute("data-variant", "primary");
|
|
16
|
-
expect(button.style
|
|
21
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
17
22
|
});
|
|
18
23
|
|
|
19
|
-
it("
|
|
24
|
+
it("names the ghost variant on the element", () => {
|
|
20
25
|
render(<Button variant="ghost">Cancel</Button>);
|
|
21
26
|
const button = screen.getByRole("button", { name: "Cancel" });
|
|
22
27
|
expect(button).toHaveAttribute("data-variant", "ghost");
|
|
23
|
-
expect(button.style
|
|
28
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
it("renders a leading icon before the children", () => {
|
|
@@ -32,5 +37,15 @@ describe("Button", () => {
|
|
|
32
37
|
expect(button.contains(icon)).toBe(true);
|
|
33
38
|
expect(button.textContent).toBe("iDo it");
|
|
34
39
|
});
|
|
40
|
+
|
|
41
|
+
it("still forwards an explicit style, so framework callers keep their escape", () => {
|
|
42
|
+
// The sheet owns the base, and the escape still has to work: a caller may pass a measured
|
|
43
|
+
// value the sheet has no business owning, and it wins because a style attribute outranks
|
|
44
|
+
// any author rule (ADR 0094 §3). It is no longer LoginView's full-width submit, which is
|
|
45
|
+
// what this comment used to cite — a fixed 100% is layout policy rather than a measured
|
|
46
|
+
// value, and the sheet could reach it, so it is a rule on the login form now.
|
|
47
|
+
render(<Button style={{ width: "100%" }}>Wide</Button>);
|
|
48
|
+
expect(screen.getByRole("button", { name: "Wide" }).style.width).toBe("100%");
|
|
49
|
+
});
|
|
35
50
|
});
|
|
36
51
|
|
package/src/ui/Button.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { ButtonHTMLAttributes,
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { injectTerpStyles } from "../styles";
|
|
4
|
-
import { CONTROL_TEXT_STYLE } from "./controlStyles";
|
|
5
4
|
|
|
6
5
|
injectTerpStyles();
|
|
7
6
|
|
|
@@ -13,78 +12,26 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
13
12
|
icon?: ReactNode;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
const baseStyle: CSSProperties = {
|
|
17
|
-
...CONTROL_TEXT_STYLE,
|
|
18
|
-
display: "inline-flex",
|
|
19
|
-
alignItems: "center",
|
|
20
|
-
justifyContent: "center",
|
|
21
|
-
gap: "var(--space-2)",
|
|
22
|
-
fontWeight: "var(--font-weight-medium)" as never,
|
|
23
|
-
lineHeight: 1.2,
|
|
24
|
-
width: "fit-content",
|
|
25
|
-
maxWidth: "100%",
|
|
26
|
-
minHeight: "2.25rem",
|
|
27
|
-
padding: "0 var(--space-4)",
|
|
28
|
-
border: "1px solid transparent",
|
|
29
|
-
borderRadius: "var(--radius-md)",
|
|
30
|
-
boxSizing: "border-box",
|
|
31
|
-
cursor: "pointer",
|
|
32
|
-
whiteSpace: "normal",
|
|
33
|
-
textAlign: "center",
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const variantStyle: Record<ButtonVariant, CSSProperties> = {
|
|
37
|
-
primary: {
|
|
38
|
-
background: "var(--color-brand-primary)",
|
|
39
|
-
color: "var(--color-brand-primary-contrast)",
|
|
40
|
-
boxShadow: "var(--shadow-sm)",
|
|
41
|
-
},
|
|
42
|
-
secondary: {
|
|
43
|
-
background: "var(--color-neutral-0)",
|
|
44
|
-
color: "var(--color-neutral-900)",
|
|
45
|
-
borderColor: "var(--color-neutral-300)",
|
|
46
|
-
},
|
|
47
|
-
danger: {
|
|
48
|
-
background: "var(--color-status-danger)",
|
|
49
|
-
color: "var(--color-neutral-0)",
|
|
50
|
-
},
|
|
51
|
-
ghost: {
|
|
52
|
-
background: "transparent",
|
|
53
|
-
color: "var(--color-neutral-700)",
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const iconWrapStyle: CSSProperties = {
|
|
58
|
-
display: "inline-flex",
|
|
59
|
-
alignItems: "center",
|
|
60
|
-
justifyContent: "center",
|
|
61
|
-
flexShrink: 0,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
15
|
/**
|
|
65
|
-
* Token-styled button — use instead of a raw `<button>` (the module-boundary rule).
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
16
|
+
* Token-styled button — use instead of a raw `<button>` (the module-boundary rule).
|
|
17
|
+
*
|
|
18
|
+
* It renders no inline styles: the geometry, the per-variant colours and the hover /
|
|
19
|
+
* active / disabled / `:focus-visible` states all live in the injected react-core sheet,
|
|
20
|
+
* matched on the `data-terp` / `data-variant` attributes set below (ADR 0094). So the
|
|
21
|
+
* variant is a fact about the element rather than a style object chosen in here — which
|
|
22
|
+
* is what a test should assert, and what an app's `theme.css` can restyle.
|
|
69
23
|
*/
|
|
70
24
|
export function Button({
|
|
71
25
|
variant = "primary",
|
|
72
26
|
icon,
|
|
73
|
-
style,
|
|
74
27
|
type = "button",
|
|
75
28
|
children,
|
|
76
29
|
...rest
|
|
77
30
|
}: ButtonProps) {
|
|
78
31
|
return (
|
|
79
|
-
<button
|
|
80
|
-
type={type}
|
|
81
|
-
data-terp="button"
|
|
82
|
-
data-variant={variant}
|
|
83
|
-
{...rest}
|
|
84
|
-
style={{ ...baseStyle, ...variantStyle[variant], ...style }}
|
|
85
|
-
>
|
|
32
|
+
<button type={type} data-terp="button" data-variant={variant} {...rest}>
|
|
86
33
|
{icon !== undefined && (
|
|
87
|
-
<span aria-hidden="true"
|
|
34
|
+
<span aria-hidden="true" data-terp="button-icon">
|
|
88
35
|
{icon}
|
|
89
36
|
</span>
|
|
90
37
|
)}
|
package/src/ui/Card.test.tsx
CHANGED
|
@@ -17,8 +17,12 @@ describe("Card", () => {
|
|
|
17
17
|
expect(heading).toBeInTheDocument();
|
|
18
18
|
const card = heading.closest('[data-terp="card"]') as HTMLElement;
|
|
19
19
|
expect(card.tagName).toBe("SECTION");
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// The surface is a sheet rule (ADR 0094); the card's own claims are its element, its
|
|
21
|
+
// gap step and that the title is a real h3 inside the header row.
|
|
22
|
+
expect(card.getAttribute("style")).toBeNull();
|
|
23
|
+
expect(card).toHaveAttribute("data-gap", "3");
|
|
24
|
+
expect(heading).toHaveAttribute("data-terp", "card-title");
|
|
25
|
+
expect(heading.closest('[data-terp="card-header"]')).not.toBeNull();
|
|
22
26
|
expect(screen.getByText("Uren per maand.")).toBeInTheDocument();
|
|
23
27
|
expect(screen.getByText("body")).toBeInTheDocument();
|
|
24
28
|
});
|
package/src/ui/Card.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import type { SpaceToken } from "../layout";
|
|
4
|
+
import { injectTerpStyles } from "../styles";
|
|
4
5
|
import { useUiText } from "../uiText";
|
|
5
6
|
import type { UiText } from "../uiText";
|
|
6
7
|
|
|
8
|
+
injectTerpStyles();
|
|
9
|
+
|
|
7
10
|
export interface CardProps
|
|
8
11
|
extends Omit<HTMLAttributes<HTMLElement>, "style" | "title"> {
|
|
9
12
|
/** Optional section heading, rendered as an `<h3>` in the card's header row. */
|
|
@@ -19,37 +22,6 @@ export interface CardProps
|
|
|
19
22
|
children?: ReactNode;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
const cardStyle: CSSProperties = {
|
|
23
|
-
display: "flex",
|
|
24
|
-
flexDirection: "column",
|
|
25
|
-
background: "var(--color-neutral-0)",
|
|
26
|
-
border: "1px solid var(--color-neutral-200)",
|
|
27
|
-
borderRadius: "var(--radius-lg)",
|
|
28
|
-
padding: "var(--space-4)",
|
|
29
|
-
minWidth: 0,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const headerStyle: CSSProperties = {
|
|
33
|
-
display: "flex",
|
|
34
|
-
alignItems: "center",
|
|
35
|
-
justifyContent: "space-between",
|
|
36
|
-
flexWrap: "wrap",
|
|
37
|
-
gap: "var(--space-3)",
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const titleStyle: CSSProperties = {
|
|
41
|
-
margin: 0,
|
|
42
|
-
fontSize: "var(--font-size-base)",
|
|
43
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
44
|
-
lineHeight: 1.3,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const descriptionStyle: CSSProperties = {
|
|
48
|
-
margin: 0,
|
|
49
|
-
color: "var(--color-neutral-600)",
|
|
50
|
-
fontSize: "var(--font-size-sm)",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
25
|
/**
|
|
54
26
|
* A token-styled surface that groups one block of a page — the sanctioned way to give
|
|
55
27
|
* sections visual separation (border + background + padding) without module CSS, and
|
|
@@ -70,23 +42,23 @@ export function Card({
|
|
|
70
42
|
const resolve = useUiText();
|
|
71
43
|
const hasHeader = title !== undefined || actions !== undefined;
|
|
72
44
|
return (
|
|
73
|
-
<Component {...rest} data-terp="card"
|
|
45
|
+
<Component {...rest} data-terp="card" data-gap={String(gap)}>
|
|
74
46
|
{hasHeader ? (
|
|
75
|
-
<div data-terp="card-header"
|
|
76
|
-
<div
|
|
77
|
-
{title !== undefined ? <h3
|
|
47
|
+
<div data-terp="card-header">
|
|
48
|
+
<div data-terp="card-heading">
|
|
49
|
+
{title !== undefined ? <h3 data-terp="card-title">{resolve(title)}</h3> : null}
|
|
78
50
|
{description !== undefined ? (
|
|
79
|
-
<p
|
|
51
|
+
<p data-terp="card-description">{resolve(description)}</p>
|
|
80
52
|
) : null}
|
|
81
53
|
</div>
|
|
82
54
|
{actions !== undefined ? (
|
|
83
|
-
<div data-terp="card-actions"
|
|
55
|
+
<div data-terp="card-actions">
|
|
84
56
|
{actions}
|
|
85
57
|
</div>
|
|
86
58
|
) : null}
|
|
87
59
|
</div>
|
|
88
60
|
) : description !== undefined ? (
|
|
89
|
-
<p
|
|
61
|
+
<p data-terp="card-description">{resolve(description)}</p>
|
|
90
62
|
) : null}
|
|
91
63
|
{children}
|
|
92
64
|
</Component>
|