@terpjs/react-core 0.6.1 → 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/README.md +12 -2
- 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/ModuleNav.tsx +1 -1
- 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 +33 -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 +27 -4
- package/src/markers.test.ts +468 -0
- package/src/raw.d.ts +15 -1
- 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.test.tsx +39 -0
- package/src/theme.themes.test.ts +124 -0
- package/src/theme.tsx +62 -14
- package/src/toast.tsx +35 -71
- package/src/tokens.guard.test.ts +3 -12
- 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 +13 -25
- 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/uiText.tsx +9 -0
- package/src/ui/controlStyles.ts +0 -9
package/src/theme.test.tsx
CHANGED
|
@@ -34,6 +34,45 @@ describe("ThemeProvider + ThemeToggle", () => {
|
|
|
34
34
|
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it("applies a named theme beyond light and dark", () => {
|
|
38
|
+
// The named themes are the reason the semantic token layer exists. They are compiled,
|
|
39
|
+
// contrast-gated and published, and none of that reaches a user unless the control can
|
|
40
|
+
// actually pin one — so the whole path is exercised for one of them.
|
|
41
|
+
render(
|
|
42
|
+
<ThemeProvider>
|
|
43
|
+
<ThemeToggle />
|
|
44
|
+
</ThemeProvider>,
|
|
45
|
+
);
|
|
46
|
+
fireEvent.click(screen.getByRole("button", { name: "Theme" }));
|
|
47
|
+
fireEvent.click(screen.getByRole("menuitemradio", { name: "Midnight" }));
|
|
48
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("midnight");
|
|
49
|
+
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("midnight");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("offers every shipped theme, not only light and dark", () => {
|
|
53
|
+
// `theme.themes.test.ts` proves the list matches the contract's; this proves the list
|
|
54
|
+
// reaches the menu, which is a different failure — a name in `THEMES` that never renders.
|
|
55
|
+
render(
|
|
56
|
+
<ThemeProvider>
|
|
57
|
+
<ThemeToggle />
|
|
58
|
+
</ThemeProvider>,
|
|
59
|
+
);
|
|
60
|
+
fireEvent.click(screen.getByRole("button", { name: "Theme" }));
|
|
61
|
+
for (const label of ["Light", "Dark", "Midnight", "Twilight", "High contrast", "System"]) {
|
|
62
|
+
expect(screen.getByRole("menuitemradio", { name: label })).toBeInTheDocument();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("accepts a named theme as the app default", () => {
|
|
67
|
+
// How an app ships on a named theme: one prop, no other change anywhere.
|
|
68
|
+
render(
|
|
69
|
+
<ThemeProvider defaultTheme="contrast">
|
|
70
|
+
<ThemeToggle />
|
|
71
|
+
</ThemeProvider>,
|
|
72
|
+
);
|
|
73
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("contrast");
|
|
74
|
+
});
|
|
75
|
+
|
|
37
76
|
it("restores a persisted choice over the app default", () => {
|
|
38
77
|
window.localStorage.setItem(THEME_STORAGE_KEY, "light");
|
|
39
78
|
render(
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_STRINGS } from "./uiText";
|
|
6
|
+
|
|
7
|
+
// react-core's theme list against the contract's published one.
|
|
8
|
+
//
|
|
9
|
+
// `Theme` in `theme.tsx` is a hand-written union of the stylesheet's theme names, and
|
|
10
|
+
// `THEME_ICONS` and the label map are records over it. That is a restatement of a published
|
|
11
|
+
// contract, so it can drift from it — and both directions fail quietly:
|
|
12
|
+
//
|
|
13
|
+
// * A theme the sheet ships that this union omits is a palette no app can ever select. It
|
|
14
|
+
// is compiled, gated for contrast and completeness, published in the manifest, and
|
|
15
|
+
// unreachable.
|
|
16
|
+
// * A theme this union offers that the sheet has no block for is a menu entry that sets
|
|
17
|
+
// `data-theme` to a value nothing matches, so the app silently renders the base theme
|
|
18
|
+
// while the control reports the choice took.
|
|
19
|
+
//
|
|
20
|
+
// The union is not derived from the manifest at runtime on purpose: react-core publishes
|
|
21
|
+
// unbuilt TypeScript and imports nothing but React, so resolving a sibling package's JSON
|
|
22
|
+
// module would add a bundler and tsconfig requirement to every consumer. A test reading the
|
|
23
|
+
// file from disk is the same discipline the other mirrored tables in this repo use — the copy
|
|
24
|
+
// stays a copy, and the copy is checked.
|
|
25
|
+
|
|
26
|
+
const manifest: {
|
|
27
|
+
base: string;
|
|
28
|
+
systemDark: string;
|
|
29
|
+
themes: { name: string; label: string; appearance: "light" | "dark" }[];
|
|
30
|
+
} = JSON.parse(
|
|
31
|
+
readFileSync(new URL("../../contract/src/tokens.manifest.json", import.meta.url), "utf-8"),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Read as source rather than imported, because the union is a type: it does not survive to
|
|
35
|
+
// runtime, and `THEMES` alone would not prove the type and the array agree.
|
|
36
|
+
const themeSource = readFileSync(new URL("./theme.tsx", import.meta.url), "utf-8");
|
|
37
|
+
|
|
38
|
+
/** The names in `export type Theme = "a" | "b" | …`. */
|
|
39
|
+
function unionMembers(): string[] {
|
|
40
|
+
const match = /export type Theme =([^;]+);/.exec(themeSource);
|
|
41
|
+
if (!match) throw new Error("theme.tsx: could not find `export type Theme`");
|
|
42
|
+
return [...match[1]!.matchAll(/"([a-z-]+)"/g)].map((entry) => entry[1]!);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The names in the `THEMES` array literal. */
|
|
46
|
+
function themesArray(): string[] {
|
|
47
|
+
const match = /const THEMES: readonly Theme\[\] = \[([^\]]+)\]/.exec(themeSource);
|
|
48
|
+
if (!match) throw new Error("theme.tsx: could not find the `THEMES` array");
|
|
49
|
+
return [...match[1]!.matchAll(/"([a-z-]+)"/g)].map((entry) => entry[1]!);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The keys of a `Record<Theme, …>` literal assigned to `name`. */
|
|
53
|
+
function recordKeys(name: string): string[] {
|
|
54
|
+
const match = new RegExp(`const ${name}: Record<Theme, [^>]+> = \\{([^}]+)\\}`).exec(
|
|
55
|
+
themeSource,
|
|
56
|
+
);
|
|
57
|
+
if (!match) throw new Error(`theme.tsx: could not find \`${name}\``);
|
|
58
|
+
return [...match[1]!.matchAll(/^\s*([a-z-]+):/gm)].map((entry) => entry[1]!);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every theme the stylesheet ships, in registry order. */
|
|
62
|
+
const shipped = manifest.themes.map((theme) => theme.name);
|
|
63
|
+
|
|
64
|
+
/** What the control offers: every shipped theme, plus the OS-preference entry. */
|
|
65
|
+
const OS_PREFERENCE = "system";
|
|
66
|
+
const expected = [...shipped, OS_PREFERENCE];
|
|
67
|
+
|
|
68
|
+
describe("react-core's theme list", () => {
|
|
69
|
+
it("reads the sources it is asserting about", () => {
|
|
70
|
+
// Every assertion below is a comparison against a regex match. A regex that stopped
|
|
71
|
+
// matching — a reformatted union, a renamed constant — would compare two empty lists and
|
|
72
|
+
// report green, which is the one failure mode a parity test cannot afford.
|
|
73
|
+
expect(shipped.length).toBeGreaterThanOrEqual(3);
|
|
74
|
+
expect(unionMembers().length).toBe(expected.length);
|
|
75
|
+
expect(themesArray().length).toBe(expected.length);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("offers exactly the themes the contract ships, plus system", () => {
|
|
79
|
+
// The union is the app-facing type: a theme missing here cannot be passed to
|
|
80
|
+
// `ThemeProvider` at all, however completely the stylesheet defines it.
|
|
81
|
+
expect(unionMembers()).toEqual(expected);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lists them in the same order in the THEMES array", () => {
|
|
85
|
+
// `THEMES` is what `ThemeToggle` maps over and what `readStoredTheme` validates against,
|
|
86
|
+
// so a name in the type but not the array is selectable in code and absent from the menu.
|
|
87
|
+
expect(themesArray()).toEqual(expected);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("gives every theme an icon, and no two the same", () => {
|
|
91
|
+
// A picker whose entries share a glyph is a picker whose icons carry no information, and
|
|
92
|
+
// the trigger renders the active theme's icon — so a duplicate makes two states identical.
|
|
93
|
+
expect(recordKeys("THEME_ICONS")).toEqual(expected);
|
|
94
|
+
const glyphs = [...themeSource.matchAll(/^\s{2}([a-z-]+): "([a-z-]+)",$/gm)]
|
|
95
|
+
.filter((entry) => expected.includes(entry[1]!))
|
|
96
|
+
.map((entry) => entry[2]!);
|
|
97
|
+
expect(glyphs).toHaveLength(expected.length);
|
|
98
|
+
expect(new Set(glyphs).size).toBe(glyphs.length);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("has a translated label for every theme", () => {
|
|
102
|
+
// The label map is `Record<Theme, string>` so the compiler catches a missing entry, but
|
|
103
|
+
// not one pointing at a string key that does not exist in the catalog.
|
|
104
|
+
for (const name of expected) {
|
|
105
|
+
const key = `theme${name[0]!.toUpperCase()}${name.slice(1)}`;
|
|
106
|
+
expect(
|
|
107
|
+
Object.hasOwn(DEFAULT_STRINGS, key),
|
|
108
|
+
`${name} needs a ${key} string (LOCALE_NL's completeness gate then forces the Dutch one)`,
|
|
109
|
+
).toBe(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps the OS-preference entry from colliding with a theme name", () => {
|
|
114
|
+
// "system" is a sentinel, not a theme: it means *remove* `data-theme` and let the sheet's
|
|
115
|
+
// `prefers-color-scheme` block decide. A shipped theme actually named `system` would make
|
|
116
|
+
// the sentinel ambiguous — selecting it would clear the attribute rather than pin the
|
|
117
|
+
// theme, and the two would be indistinguishable in storage.
|
|
118
|
+
expect(shipped).not.toContain(OS_PREFERENCE);
|
|
119
|
+
// Both ends of what the sentinel resolves to have to be themes the control also offers,
|
|
120
|
+
// or "System" would render a palette the user cannot pick deliberately.
|
|
121
|
+
expect(shipped).toContain(manifest.systemDark);
|
|
122
|
+
expect(shipped).toContain(manifest.base);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/theme.tsx
CHANGED
|
@@ -7,16 +7,37 @@ import { useStrings } from "./uiText";
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The visual theme: an explicit choice, or "system" to follow the OS preference.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* The token stylesheet (`@terpjs/contract/tokens.css`) carries every palette: it applies
|
|
12
|
+
* each named theme's colours under `<html data-theme="<name>">` and — with no attribute —
|
|
13
|
+
* applies the dark palette under `@media (prefers-color-scheme: dark)`, so "system" simply
|
|
14
|
+
* removes the attribute.
|
|
15
|
+
*
|
|
16
|
+
* The names are the stylesheet's, so this union is a restatement of a published contract
|
|
17
|
+
* and could drift from it silently — a theme the sheet ships that no app can select, or one
|
|
18
|
+
* this offers that resolves to nothing. `theme.themes.test.ts` holds it against the token
|
|
19
|
+
* manifest. The union is written out rather than derived from the manifest at runtime because
|
|
20
|
+
* react-core publishes unbuilt source and imports nothing but React: resolving a JSON module
|
|
21
|
+
* from a sibling package would add a requirement to every consumer's bundler and tsconfig,
|
|
22
|
+
* which is the consumption-model change the framework spends real effort avoiding.
|
|
13
23
|
*/
|
|
14
|
-
export type Theme = "light" | "dark" | "system";
|
|
24
|
+
export type Theme = "light" | "dark" | "midnight" | "twilight" | "contrast" | "system";
|
|
25
|
+
|
|
26
|
+
const THEMES: readonly Theme[] = [
|
|
27
|
+
"light",
|
|
28
|
+
"dark",
|
|
29
|
+
"midnight",
|
|
30
|
+
"twilight",
|
|
31
|
+
"contrast",
|
|
32
|
+
"system",
|
|
33
|
+
];
|
|
15
34
|
|
|
16
|
-
const THEMES: readonly Theme[] = ["light", "dark", "system"];
|
|
17
35
|
const THEME_ICONS: Record<Theme, string> = {
|
|
18
36
|
light: "sun",
|
|
19
37
|
dark: "moon",
|
|
38
|
+
midnight: "moon-stars",
|
|
39
|
+
twilight: "sunset",
|
|
40
|
+
contrast: "contrast",
|
|
20
41
|
system: "monitor",
|
|
21
42
|
};
|
|
22
43
|
|
|
@@ -46,10 +67,13 @@ export interface ThemeProviderProps {
|
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
/**
|
|
49
|
-
* Owns the
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
70
|
+
* Owns the theme choice: applies it as `data-theme` on `<html>` (the token stylesheet does
|
|
71
|
+
* the rest — no component changes anywhere) and persists it in `localStorage`.
|
|
72
|
+
* `renderTerpApp` mounts one for every app; pair with {@link ThemeToggle} (the default
|
|
73
|
+
* {@link UserMenu} already includes it).
|
|
74
|
+
*
|
|
75
|
+
* `defaultTheme` is how an app ships on a named theme — `defaultTheme="midnight"` and
|
|
76
|
+
* nothing else — since it applies until the user chooses otherwise.
|
|
53
77
|
*/
|
|
54
78
|
export function ThemeProvider({ defaultTheme = "system", children }: ThemeProviderProps) {
|
|
55
79
|
const [theme, setThemeState] = useState<Theme>(() => {
|
|
@@ -93,8 +117,8 @@ export interface ThemeToggleProps {
|
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
/**
|
|
96
|
-
* The standard theme control: a token-themed
|
|
97
|
-
* outside a {@link ThemeProvider}, so shared chrome (the shell header) can
|
|
120
|
+
* The standard theme control: a token-themed menu over every shipped theme plus "system".
|
|
121
|
+
* Renders nothing outside a {@link ThemeProvider}, so shared chrome (the shell header) can
|
|
98
122
|
* include it unconditionally.
|
|
99
123
|
*/
|
|
100
124
|
export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
|
|
@@ -106,10 +130,34 @@ export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
|
|
|
106
130
|
const labels: Record<Theme, string> = {
|
|
107
131
|
light: strings.themeLight,
|
|
108
132
|
dark: strings.themeDark,
|
|
133
|
+
midnight: strings.themeMidnight,
|
|
134
|
+
twilight: strings.themeTwilight,
|
|
135
|
+
contrast: strings.themeContrast,
|
|
109
136
|
system: strings.themeSystem,
|
|
110
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";
|
|
111
143
|
const menu = (
|
|
112
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"
|
|
113
161
|
trigger={<Icon name={THEME_ICONS[context.theme]} size="1.15rem" />}
|
|
114
162
|
triggerLabel={strings.theme}
|
|
115
163
|
>
|
|
@@ -131,12 +179,12 @@ export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
|
|
|
131
179
|
)}
|
|
132
180
|
</Menu>
|
|
133
181
|
);
|
|
134
|
-
if (
|
|
182
|
+
if (isInline) {
|
|
135
183
|
return menu;
|
|
136
184
|
}
|
|
137
185
|
return (
|
|
138
|
-
<div
|
|
139
|
-
<span
|
|
186
|
+
<div data-terp="theme-toggle" data-variant="stacked">
|
|
187
|
+
<span data-terp="theme-toggle-label">{strings.theme}</span>
|
|
140
188
|
{menu}
|
|
141
189
|
</div>
|
|
142
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/tokens.guard.test.ts
CHANGED
|
@@ -7,18 +7,9 @@ const tokensCss = readFileSync(
|
|
|
7
7
|
"utf-8",
|
|
8
8
|
);
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
declare global {
|
|
14
|
-
interface ImportMeta {
|
|
15
|
-
glob: (
|
|
16
|
-
pattern: string,
|
|
17
|
-
options: { query: "?raw"; import: "default"; eager: true },
|
|
18
|
-
) => Record<string, string>;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
10
|
+
// The package keeps its deliberate `"types": []` isolation: react-core source must never
|
|
11
|
+
// see ambient Node globals. The source scan uses Vite's raw glob and the sheet needs fs;
|
|
12
|
+
// both are declared minimally in raw.d.ts, shared with the other scanning tests.
|
|
22
13
|
const sources = import.meta.glob("./**/*.{ts,tsx}", {
|
|
23
14
|
query: "?raw",
|
|
24
15
|
import: "default",
|
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,23 +36,13 @@ 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
|
-
return
|
|
42
|
+
return (
|
|
43
|
+
<span data-terp="badge" data-tone={tone}>
|
|
44
|
+
{resolve((label ?? children) as UiText)}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
59
47
|
}
|
|
60
48
|
|