@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.
Files changed (79) hide show
  1. package/README.md +12 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Breadcrumbs.test.tsx +24 -0
  6. package/src/Breadcrumbs.tsx +9 -32
  7. package/src/ConfirmDialog.tsx +13 -44
  8. package/src/EmptyState.tsx +8 -36
  9. package/src/ErrorState.tsx +8 -36
  10. package/src/Field.test.tsx +57 -0
  11. package/src/Field.tsx +46 -22
  12. package/src/HubPage.test.tsx +22 -13
  13. package/src/HubPage.tsx +25 -97
  14. package/src/LoadingState.tsx +3 -24
  15. package/src/ModuleNav.tsx +1 -1
  16. package/src/PageActions.tsx +5 -10
  17. package/src/UserMenu.test.tsx +12 -5
  18. package/src/UserMenu.tsx +33 -62
  19. package/src/dataview/DataView.test.tsx +109 -5
  20. package/src/dataview/DataView.tsx +41 -23
  21. package/src/dataview/DataViewCardList.tsx +14 -60
  22. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  23. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  24. package/src/dataview/DataViewPagination.tsx +2 -32
  25. package/src/dataview/DataViewRowActions.tsx +13 -33
  26. package/src/dataview/DataViewTable.tsx +16 -103
  27. package/src/dataview/DataViewToolbar.tsx +53 -76
  28. package/src/dataview/README.md +6 -0
  29. package/src/dataview/index.ts +1 -0
  30. package/src/dataview/internal.tsx +4 -1
  31. package/src/dataview/types.ts +13 -0
  32. package/src/feedback.test.tsx +26 -0
  33. package/src/files.test.tsx +18 -0
  34. package/src/files.tsx +13 -4
  35. package/src/icons.test.tsx +10 -6
  36. package/src/icons.tsx +33 -37
  37. package/src/index.ts +0 -3
  38. package/src/layout.test.tsx +24 -9
  39. package/src/layout.tsx +24 -21
  40. package/src/layoutContract.test.tsx +95 -0
  41. package/src/locale.tsx +27 -4
  42. package/src/markers.test.ts +468 -0
  43. package/src/raw.d.ts +15 -1
  44. package/src/router.tsx +6 -9
  45. package/src/ssr.test.tsx +1 -3
  46. package/src/styles.test.ts +823 -6
  47. package/src/styles.ts +2699 -153
  48. package/src/theme.test.tsx +39 -0
  49. package/src/theme.themes.test.ts +124 -0
  50. package/src/theme.tsx +62 -14
  51. package/src/toast.tsx +35 -71
  52. package/src/tokens.guard.test.ts +3 -12
  53. package/src/ui/Alert.test.tsx +12 -0
  54. package/src/ui/Alert.tsx +15 -43
  55. package/src/ui/Badge.test.tsx +14 -3
  56. package/src/ui/Badge.tsx +13 -25
  57. package/src/ui/Button.test.tsx +17 -4
  58. package/src/ui/Button.tsx +10 -63
  59. package/src/ui/Card.test.tsx +6 -2
  60. package/src/ui/Card.tsx +11 -39
  61. package/src/ui/Checkbox.tsx +2 -19
  62. package/src/ui/Combobox.test.tsx +22 -0
  63. package/src/ui/Combobox.tsx +31 -80
  64. package/src/ui/DatePicker.test.tsx +131 -4
  65. package/src/ui/DatePicker.tsx +158 -106
  66. package/src/ui/Input.tsx +6 -19
  67. package/src/ui/Markdown.test.tsx +26 -0
  68. package/src/ui/Markdown.tsx +28 -2
  69. package/src/ui/Menu.test.tsx +38 -4
  70. package/src/ui/Menu.tsx +50 -52
  71. package/src/ui/Popover.tsx +53 -19
  72. package/src/ui/Radio.tsx +5 -30
  73. package/src/ui/Select.tsx +7 -30
  74. package/src/ui/Switch.tsx +2 -20
  75. package/src/ui/Tabs.tsx +4 -28
  76. package/src/ui/Textarea.tsx +6 -17
  77. package/src/ui/Tooltip.tsx +9 -21
  78. package/src/uiText.tsx +9 -0
  79. package/src/ui/controlStyles.ts +0 -9
package/src/icons.tsx CHANGED
@@ -1,4 +1,8 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
+
3
+ import { injectTerpStyles } from "./styles";
4
+
5
+ injectTerpStyles();
2
6
 
3
7
  /**
4
8
  * The dependency-free icon layer: a small set of inline SVG glyphs the shell's
@@ -77,6 +81,30 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
77
81
  <path d="M8 21h8M12 17v4" />
78
82
  </svg>
79
83
  ),
84
+ // The three glyphs below exist for the named themes. `moon` and `sun` were already
85
+ // taken by dark and light, and a theme picker where two entries share a glyph is a
86
+ // picker whose icons carry no information.
87
+ "moon-stars": (
88
+ <svg {...svgProps}>
89
+ <path d="M20.5 15.6A8 8 0 0 1 9.4 4.5 8.4 8.4 0 1 0 20.5 15.6Z" />
90
+ <path d="M17 3v3M15.5 4.5h3M20.5 8v2M19.5 9h2" />
91
+ </svg>
92
+ ),
93
+ sunset: (
94
+ <svg {...svgProps}>
95
+ <path d="M12 4v2M5.6 7.6l1.4 1.4M17 9l1.4-1.4M3 15h1.5M19.5 15H21" />
96
+ <path d="M8 15a4 4 0 0 1 8 0" />
97
+ <path d="M3 19h18" />
98
+ </svg>
99
+ ),
100
+ contrast: (
101
+ <svg {...svgProps}>
102
+ <circle cx="12" cy="12" r="9" />
103
+ {/* Filled half, so the glyph reads as contrast rather than as a plain circle.
104
+ Stroked shapes elsewhere use `currentColor`; so does this fill. */}
105
+ <path d="M12 3a9 9 0 0 0 0 18Z" fill="currentColor" stroke="none" />
106
+ </svg>
107
+ ),
80
108
  document: (
81
109
  <svg {...svgProps}>
82
110
  <path d="M6 3h8l4 4v14H6Z" />
@@ -407,31 +435,6 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
407
435
  ),
408
436
  };
409
437
 
410
- const navIconStyle: CSSProperties = {
411
- display: "inline-flex",
412
- alignItems: "center",
413
- justifyContent: "center",
414
- width: "1.25rem",
415
- height: "1.25rem",
416
- flex: "0 0 1.25rem",
417
- fontSize: "1rem",
418
- lineHeight: 1,
419
- };
420
-
421
- const fallbackStyle: CSSProperties = {
422
- display: "inline-flex",
423
- alignItems: "center",
424
- justifyContent: "center",
425
- width: "100%",
426
- height: "100%",
427
- borderRadius: "var(--radius-sm, 4px)",
428
- background: "var(--color-brand-primary-soft, var(--color-neutral-200))",
429
- color: "var(--color-brand-primary, var(--color-neutral-700))",
430
- fontSize: "0.7em",
431
- fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
432
- lineHeight: 1,
433
- };
434
-
435
438
  export interface NavIconProps {
436
439
  /** Glyph name (a `NavItem.icon` value); unknown / missing falls back to the initial. */
437
440
  name?: string;
@@ -446,9 +449,9 @@ export interface NavIconProps {
446
449
  export function NavIcon({ name, label }: NavIconProps) {
447
450
  const glyph = name !== undefined ? ICON_GLYPHS[name] : undefined;
448
451
  return (
449
- <span aria-hidden="true" data-terp="nav-icon" style={navIconStyle}>
452
+ <span aria-hidden="true" data-terp="nav-icon">
450
453
  {glyph ?? (
451
- <span style={fallbackStyle}>
454
+ <span data-terp="nav-icon-fallback">
452
455
  {(label[0] ?? "?").toUpperCase()}
453
456
  </span>
454
457
  )}
@@ -487,15 +490,8 @@ export function Icon({ name, size = "1em", title }: IconProps) {
487
490
  aria-hidden={labelled ? undefined : true}
488
491
  role={labelled ? "img" : undefined}
489
492
  aria-label={labelled ? title : undefined}
490
- style={{
491
- display: "inline-flex",
492
- alignItems: "center",
493
- justifyContent: "center",
494
- width: px,
495
- height: px,
496
- lineHeight: 1,
497
- color: "inherit",
498
- }}
493
+ data-terp="icon"
494
+ style={{ width: px, height: px }}
499
495
  >
500
496
  {glyph}
501
497
  </span>
package/src/index.ts CHANGED
@@ -30,9 +30,6 @@ export type { RequireAuthProps } from "./RequireAuth";
30
30
  export { visibleNav } from "./nav";
31
31
  export {
32
32
  AppShell,
33
- NAV_LINK_STYLE,
34
- NAV_LINK_COLLAPSED_STYLE,
35
- NAV_LINK_ACTIVE_STYLE,
36
33
  SIDEBAR_STORAGE_KEY,
37
34
  } from "./AppShell";
38
35
  export type {
@@ -7,7 +7,7 @@ import { DetailList, Stack } from "./layout";
7
7
  afterEach(cleanup);
8
8
 
9
9
  describe("Stack", () => {
10
- it("renders a flex column with a token gap by default", () => {
10
+ it("names a column at the default gap, with no inline styling", () => {
11
11
  render(
12
12
  <Stack data-testid="stack">
13
13
  <span>a</span>
@@ -16,24 +16,39 @@ describe("Stack", () => {
16
16
  );
17
17
  const el = screen.getByTestId("stack");
18
18
  expect(el.tagName).toBe("DIV");
19
- expect(el.style.display).toBe("flex");
20
- expect(el.style.flexDirection).toBe("column");
21
- expect(el.style.gap).toBe("var(--space-2)");
19
+ expect(el).toHaveAttribute("data-direction", "column");
20
+ expect(el).toHaveAttribute("data-gap", "2");
21
+ expect(el).not.toHaveAttribute("data-wrap");
22
+ expect(el.getAttribute("style")).toBeNull();
22
23
  });
23
24
 
24
- it("renders the requested element with direction, gap, alignment and wrap", () => {
25
+ it("renders the requested element and names direction, gap and wrap", () => {
25
26
  render(
26
- <Stack data-testid="row" as="section" direction="row" gap={4} align="center" justify="space-between" wrap>
27
+ <Stack data-testid="row" as="section" direction="row" gap={4} wrap>
27
28
  <span>a</span>
28
29
  </Stack>,
29
30
  );
30
31
  const el = screen.getByTestId("row");
31
32
  expect(el.tagName).toBe("SECTION");
32
- expect(el.style.flexDirection).toBe("row");
33
- expect(el.style.gap).toBe("var(--space-4)");
33
+ expect(el).toHaveAttribute("data-direction", "row");
34
+ expect(el).toHaveAttribute("data-gap", "4");
35
+ expect(el).toHaveAttribute("data-wrap", "true");
36
+ expect(el.getAttribute("style")).toBeNull();
37
+ });
38
+
39
+ it("keeps alignment inline, because it is an open set of CSS keywords", () => {
40
+ // align/justify accept any alignment keyword, so they cannot become a rule per value
41
+ // without inventing a vocabulary CSS already has (ADR 0094). They stay inline, and
42
+ // the geometry attributes stay attributes — the two halves of the same element.
43
+ render(
44
+ <Stack data-testid="aligned" align="center" justify="space-between">
45
+ <span>a</span>
46
+ </Stack>,
47
+ );
48
+ const el = screen.getByTestId("aligned");
34
49
  expect(el.style.alignItems).toBe("center");
35
50
  expect(el.style.justifyContent).toBe("space-between");
36
- expect(el.style.flexWrap).toBe("wrap");
51
+ expect(el.style.display).toBe("");
37
52
  });
38
53
 
39
54
  it("works as a form (submit handler fires)", () => {
package/src/layout.tsx CHANGED
@@ -38,16 +38,27 @@ export function Stack({
38
38
  wrap = false,
39
39
  ...rest
40
40
  }: StackProps) {
41
- const style: CSSProperties = {
42
- display: "flex",
43
- flexDirection: direction,
44
- gap: `var(--space-${gap})`,
45
- margin: 0,
46
- ...(align !== undefined ? { alignItems: align } : undefined),
47
- ...(justify !== undefined ? { justifyContent: justify } : undefined),
48
- ...(wrap ? { flexWrap: "wrap" } : undefined),
49
- };
50
- return <Component {...rest} data-terp="stack" style={style} />;
41
+ // `direction`, `gap` and `wrap` are closed sets, so they are attributes the sheet keys
42
+ // on. `align` and `justify` take any alignment keyword CSS accepts, so they stay inline
43
+ // rather than turning an open vocabulary into a rule per value (ADR 0094). Undefined on
44
+ // both means no style attribute at all.
45
+ const alignment: CSSProperties | undefined =
46
+ align === undefined && justify === undefined
47
+ ? undefined
48
+ : {
49
+ ...(align !== undefined ? { alignItems: align } : undefined),
50
+ ...(justify !== undefined ? { justifyContent: justify } : undefined),
51
+ };
52
+ return (
53
+ <Component
54
+ {...rest}
55
+ data-terp="stack"
56
+ data-direction={direction}
57
+ data-gap={String(gap)}
58
+ data-wrap={wrap ? "true" : undefined}
59
+ style={alignment}
60
+ />
61
+ );
51
62
  }
52
63
 
53
64
  export interface DetailItem {
@@ -62,14 +73,6 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
62
73
  items: readonly DetailItem[];
63
74
  }
64
75
 
65
- const detailListStyle: CSSProperties = {
66
- margin: 0,
67
- display: "grid",
68
- gap: "var(--space-1)",
69
- };
70
- const detailTermStyle: CSSProperties = { display: "inline", fontWeight: "var(--font-weight-medium)" };
71
- const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
72
-
73
76
  /**
74
77
  * Token-styled label/value pairs as a semantic `<dl>` — record metadata on a detail page,
75
78
  * an expanded row's summary. Centralizes the "Label: value" pattern so modules never
@@ -78,11 +81,11 @@ const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
78
81
  export function DetailList({ items, ...rest }: DetailListProps) {
79
82
  const text = useUiText();
80
83
  return (
81
- <dl {...rest} data-terp="detail-list" style={detailListStyle}>
84
+ <dl {...rest} data-terp="detail-list">
82
85
  {items.map((item, index) => (
83
86
  <div key={index}>
84
- <dt style={detailTermStyle}>{text(item.label)}: </dt>
85
- <dd style={detailValueStyle}>{item.value}</dd>
87
+ <dt data-terp="detail-list-term">{text(item.label)}: </dt>
88
+ <dd data-terp="detail-list-value">{item.value}</dd>
86
89
  </div>
87
90
  ))}
88
91
  </dl>
@@ -21,6 +21,9 @@ import {
21
21
  import { OverviewPage } from "./OverviewPage";
22
22
  import { Page } from "./Page";
23
23
  import { DetailList, Stack } from "./layout";
24
+ import { PageActions } from "./PageActions";
25
+ import { ThemeProvider, ThemeToggle } from "./theme";
26
+ import { Button } from "./ui/Button";
24
27
  import { Card } from "./ui/Card";
25
28
 
26
29
  afterEach(cleanup);
@@ -179,3 +182,95 @@ describe("runtime slot enforcement", () => {
179
182
  expect(verifySlotChildren("standard", "Page", [])).toBeNull();
180
183
  });
181
184
  });
185
+
186
+ // The styling migration (ADR 0094) is renaming rendered roots: components that used to emit
187
+ // an unmarked <div>, or to borrow Popover's `popover`, now name themselves. Every one of those
188
+ // names is read by this check, because the check IS the marker join — so the question is not
189
+ // whether the names look sensible but whether any slot's verdict moved.
190
+ //
191
+ // It did not, and the reason is worth stating rather than re-derived: verifySlotChildren
192
+ // refuses a direct body-slot child whose data-terp is not in the slot's allow table, and a
193
+ // missing attribute is refused too (`marker === null`). None of the new names is in any table,
194
+ // and neither was the unmarked element each replaced. So each of these was refused before the
195
+ // migration and is refused after it. What changed is the MESSAGE — describeElement now reports
196
+ // a named component instead of a bare tag, which is strictly more useful to the person reading
197
+ // the refusal, and is also a string a test could have pinned.
198
+ describe("layout contract survives the roots the styling migration renames", () => {
199
+ it("still refuses a chrome menu in a governed body, and now names it", async () => {
200
+ underContract(
201
+ // The provider renders no element of its own, so the slot's direct child is still the
202
+ // toggle. Without it ThemeToggle returns null and the body is empty — which passes for
203
+ // no reason at all.
204
+ <ThemeProvider>
205
+ <OverviewPage title="Records">
206
+ <ThemeToggle variant="stacked" />
207
+ </OverviewPage>
208
+ </ThemeProvider>,
209
+ );
210
+ await waitFor(() => {
211
+ expect(screen.getByTestId("refused").textContent).toBe(
212
+ slotViolationMessage("standard", "OverviewPage", '<div data-terp="theme-toggle">'),
213
+ );
214
+ });
215
+ });
216
+
217
+ it("still refuses an action cluster in a governed body, and now names it", async () => {
218
+ underContract(
219
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
220
+ <PageActions primary={<Button>Publish</Button>} />
221
+ </DetailPage>,
222
+ );
223
+ await waitFor(() => {
224
+ expect(screen.getByTestId("refused").textContent).toBe(
225
+ slotViolationMessage("standard", "DetailPage", '<div data-terp="page-actions">'),
226
+ );
227
+ });
228
+ });
229
+
230
+ it("keeps every marker the allow tables name out of the migration's way", () => {
231
+ // The tables are the audit surface: a rename of one of THESE would widen or close a slot
232
+ // silently, so they are the markers the migration may not touch without changing both
233
+ // halves of the contract. Asserted as a set so an addition has to be deliberate.
234
+ const named = new Set(
235
+ Object.values(LAYOUT_CONTRACTS.standard!.slots).flatMap((slot) =>
236
+ Object.values(slot.components),
237
+ ),
238
+ );
239
+ expect([...named].sort()).toEqual([
240
+ "alert",
241
+ "card",
242
+ "dataview",
243
+ "detail-list",
244
+ "dialog",
245
+ "empty-state",
246
+ "error-state",
247
+ "hubcard",
248
+ "loading-state",
249
+ "module-nav",
250
+ "resource-list",
251
+ "stack",
252
+ "tabs",
253
+ ]);
254
+ });
255
+
256
+ it("passes a governed body whose children are still allowed components", async () => {
257
+ // The other direction: the migration must not have widened anything either. A Stack and a
258
+ // Card are allowed, and they stay allowed with their markers unchanged.
259
+ underContract(
260
+ <ThemeProvider>
261
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
262
+ <Card title="A section">body</Card>
263
+ <Stack>
264
+ <ThemeToggle variant="stacked" />
265
+ </Stack>
266
+ </DetailPage>
267
+ </ThemeProvider>,
268
+ );
269
+ await new Promise((resolve) => setTimeout(resolve, 20));
270
+ // And the toggle really did render, or this asserts nothing.
271
+ expect(document.querySelector('[data-terp="theme-toggle"]')).not.toBeNull();
272
+ // Nested inside an allowed container is sanctioned composition — the check reads direct
273
+ // children only, which is what makes a marked root safe to put anywhere below one.
274
+ expect(screen.queryByTestId("refused")).toBeNull();
275
+ });
276
+ });
package/src/locale.tsx CHANGED
@@ -67,6 +67,9 @@ export const LOCALE_NL: LocaleCatalog = {
67
67
  theme: "Thema",
68
68
  themeLight: "Licht",
69
69
  themeDark: "Donker",
70
+ themeMidnight: "Middernacht",
71
+ themeTwilight: "Schemering",
72
+ themeContrast: "Hoog contrast",
70
73
  themeSystem: "Systeem",
71
74
  language: "Taal",
72
75
  admin: "Beheer",
@@ -215,8 +218,28 @@ export function LanguageSwitcher({ variant = "stacked" }: LanguageSwitcherProps)
215
218
  if (context === null || context.locales.length < 2) {
216
219
  return null;
217
220
  }
221
+ // Hoisted out of the attribute rather than inlined as `variant === "inline"`, and not for
222
+ // readability: the marker inventory scanner reads every string literal inside a
223
+ // `data-terp={…}` expression as a marker, so the comparison's own "inline" was picked up as
224
+ // a component marker that nothing styles. Keep marker expressions to marker literals.
225
+ const isInline = variant === "inline";
218
226
  const menu = (
219
- <Menu trigger={<Icon name="globe" size="1.15rem" />} triggerLabel={strings.language}>
227
+ <Menu
228
+ // Claim the root ONLY when this Menu is the root, which is the inline variant. The
229
+ // stacked variant renders its own div and puts the menu inside it, so stamping the
230
+ // same marker unconditionally put it on BOTH elements — and the inner wrapper then
231
+ // matched the stacked grid rule instead of the popover wrapper's geometry. The
232
+ // baselines did not catch that: a one-child grid and a one-child inline-flex box
233
+ // shrink-wrap to the same pixels, so it was wrong and invisible at the same time.
234
+ data-terp={isInline ? "language-switcher" : undefined}
235
+ data-variant={isInline ? "inline" : undefined}
236
+ // Unconditional, unlike the root marker: the panel is the same panel in both variants, so
237
+ // a rule for it must reach both. Deriving the owner from the conditional root marker made
238
+ // this panel "language-switcher" when inline and "popover" when stacked.
239
+ data-owner="language-switcher"
240
+ trigger={<Icon name="globe" size="1.15rem" />}
241
+ triggerLabel={strings.language}
242
+ >
220
243
  {({ close }) => (
221
244
  <>
222
245
  {context.locales.map((code) => (
@@ -234,12 +257,12 @@ export function LanguageSwitcher({ variant = "stacked" }: LanguageSwitcherProps)
234
257
  )}
235
258
  </Menu>
236
259
  );
237
- if (variant === "inline") {
260
+ if (isInline) {
238
261
  return menu;
239
262
  }
240
263
  return (
241
- <div style={{ display: "grid", justifyItems: "start", gap: "var(--space-1)", fontSize: "var(--font-size-sm)" }}>
242
- <span style={{ color: "var(--color-neutral-600)" }}>{strings.language}</span>
264
+ <div data-terp="language-switcher" data-variant="stacked">
265
+ <span data-terp="language-switcher-label">{strings.language}</span>
243
266
  {menu}
244
267
  </div>
245
268
  );