@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.
Files changed (94) hide show
  1. package/README.md +6 -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/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
3
3
  import { useEffect } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
 
6
- import { Authorized } from "./Authorized";
6
+ import { Authorized, useHasPermission, usePermissions } from "./Authorized";
7
7
  import { TerpProvider, useAuth } from "./TerpProvider";
8
8
 
9
9
  function jsonResponse(body: unknown): Response {
@@ -57,4 +57,66 @@ describe("Authorized", () => {
57
57
  expect(screen.getByText("no-admin")).toBeInTheDocument();
58
58
  expect(screen.queryByText("admin-only")).not.toBeInTheDocument();
59
59
  });
60
+
61
+ it("gates on a named grant, not only rank (ADR 0096)", async () => {
62
+ // The hole this closes: rank was all the wire carried, so a screen whose write needs
63
+ // `definitions.publish` hid by rank as a proxy and handled the 403 anyway.
64
+ vi.stubGlobal(
65
+ "fetch",
66
+ vi.fn<typeof fetch>(async (input) => {
67
+ const url = (input as Request).url;
68
+ if (url.endsWith("/api/v1/auth/login")) {
69
+ return jsonResponse({ access_token: "token", token_type: "bearer" });
70
+ }
71
+ return jsonResponse({
72
+ id: "u1",
73
+ email: "editor@example.com",
74
+ role_rank: 20,
75
+ role_name: "editor",
76
+ permissions: ["definitions.manage"],
77
+ });
78
+ }),
79
+ );
80
+
81
+ render(
82
+ <TerpProvider baseUrl="https://api.test">
83
+ <LogInOnMount />
84
+ <Authorized action="write" permission="definitions.manage">
85
+ <span>held</span>
86
+ </Authorized>
87
+ <Authorized action="write" permission="definitions.publish">
88
+ <span>not held</span>
89
+ </Authorized>
90
+ {/* Rank alone still fails closed even when the grant is held. */}
91
+ <Authorized action="admin" permission="definitions.manage">
92
+ <span>rank too low</span>
93
+ </Authorized>
94
+ </TerpProvider>,
95
+ );
96
+
97
+ await waitFor(() => expect(screen.getByText("held")).toBeInTheDocument());
98
+ expect(screen.queryByText("not held")).toBeNull();
99
+ expect(screen.queryByText("rank too low")).toBeNull();
100
+ });
101
+
102
+ it("reports no permissions when signed out, and none for an app that grants none", async () => {
103
+ vi.stubGlobal(
104
+ "fetch",
105
+ vi.fn<typeof fetch>(async () => new Response("", { status: 401 })),
106
+ );
107
+
108
+ function Probe() {
109
+ return (
110
+ <span>{`n=${usePermissions().length} has=${String(useHasPermission("anything"))}`}</span>
111
+ );
112
+ }
113
+ render(
114
+ <TerpProvider baseUrl="https://api.test">
115
+ <Probe />
116
+ </TerpProvider>,
117
+ );
118
+
119
+ // Empty rather than undefined, so a screen can read it without guarding first.
120
+ await waitFor(() => expect(screen.getByText("n=0 has=false")).toBeInTheDocument());
121
+ });
60
122
  });
@@ -8,14 +8,47 @@ export function useCan(action: Action): boolean {
8
8
  return useAuth().can(action);
9
9
  }
10
10
 
11
+ /**
12
+ * The caller's effective permission names, from `GET /me` (ADR 0096).
13
+ *
14
+ * `useCan` compares role *rank*, which is all the wire used to carry. A screen whose write
15
+ * needs a **named** grant (`definitions.publish`) therefore had nothing to ask: it hid by
16
+ * rank as a proxy and handled the 403 anyway — showing a button it knew might fail, or
17
+ * hiding one the user was entitled to. This is the same set the server's guard enforces,
18
+ * projected for display.
19
+ *
20
+ * Empty when signed out, and empty for an app that mounts no grant capability (it has no
21
+ * named permissions). A *display* input only: the server re-checks every request, and a
22
+ * client that treats this as authority has moved the gate to the wrong side of the wire.
23
+ */
24
+ export function usePermissions(): readonly string[] {
25
+ return useAuth().currentUser()?.permissions ?? [];
26
+ }
27
+
28
+ /** Whether the current user holds the named permission grant (the UI gate). */
29
+ export function useHasPermission(permission: string): boolean {
30
+ return usePermissions().includes(permission);
31
+ }
32
+
11
33
  export interface AuthorizedProps {
12
34
  action: Action;
13
35
  children: ReactNode;
36
+ /**
37
+ * Also require this named permission grant.
38
+ *
39
+ * Both must pass, which is deliberately what the server does: a `Policy` carrying a
40
+ * `Permission` enforces the permission's role floor **and** the grant, so a UI that
41
+ * checked only one would disagree with the endpoint in one direction or the other.
42
+ */
43
+ permission?: string;
14
44
  /** Rendered when the user may not perform `action` (default: nothing). */
15
45
  fallback?: ReactNode;
16
46
  }
17
47
 
18
48
  /** Render `children` only when the current user may perform `action`, else `fallback`. */
19
- export function Authorized({ action, children, fallback = null }: AuthorizedProps) {
20
- return <>{useCan(action) ? children : fallback}</>;
49
+ export function Authorized({ action, permission, children, fallback = null }: AuthorizedProps) {
50
+ const allowedByRank = useCan(action);
51
+ const permissions = usePermissions();
52
+ const allowed = allowedByRank && (permission === undefined || permissions.includes(permission));
53
+ return <>{allowed ? children : fallback}</>;
21
54
  }
@@ -42,4 +42,28 @@ describe("Breadcrumbs", () => {
42
42
  expect(screen.queryByRole("link")).not.toBeInTheDocument();
43
43
  expect(screen.getByText("Section")).not.toHaveAttribute("aria-current");
44
44
  });
45
+
46
+ it("marks only the final crumb as current, on a marker of its own", () => {
47
+ // The current-crumb styling deliberately does NOT key on aria-current. A router's Link
48
+ // stamps aria-current="page" on every link whose path is a prefix of the current one —
49
+ // which every ancestor crumb is — so borrowing that attribute painted the whole trail as
50
+ // the current page. The marker says what this component means, not what a router infers.
51
+ const { container } = render(
52
+ <Breadcrumbs
53
+ items={[{ label: "Tasks", to: "/tasks" }, { label: "Open", to: "/tasks/open" }, { label: "Here" }]}
54
+ renderLink={(item) => (
55
+ <a href={item.to} aria-current="page">
56
+ {item.label}
57
+ </a>
58
+ )}
59
+ />,
60
+ );
61
+
62
+ const current = container.querySelectorAll('[data-terp="breadcrumbs-current"]');
63
+ expect(current).toHaveLength(1);
64
+ expect(current[0]).toHaveTextContent("Here");
65
+ // Both ancestors claim aria-current here, which is exactly the router behaviour that
66
+ // made the old selector wrong — and none of them may pick up the current styling.
67
+ expect(container.querySelectorAll('[aria-current="page"]')).toHaveLength(3);
68
+ });
45
69
  });
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "./styles";
4
4
  import { useNavLink } from "./navLink";
@@ -29,32 +29,6 @@ export interface BreadcrumbsProps {
29
29
  renderLink?: RenderBreadcrumbLink;
30
30
  }
31
31
 
32
- const navStyle: CSSProperties = {
33
- fontSize: "var(--font-size-sm)",
34
- color: "var(--color-neutral-600)",
35
- };
36
-
37
- const listStyle: CSSProperties = {
38
- listStyle: "none",
39
- margin: 0,
40
- padding: 0,
41
- display: "flex",
42
- flexWrap: "wrap",
43
- alignItems: "center",
44
- gap: "var(--space-2)",
45
- };
46
-
47
- const currentStyle: CSSProperties = {
48
- color: "var(--color-neutral-900)",
49
- fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
50
- };
51
-
52
- const separatorStyle: CSSProperties = {
53
- display: "inline-flex",
54
- color: "var(--color-neutral-400)",
55
- lineHeight: 0,
56
- };
57
-
58
32
  function ChevronSeparator() {
59
33
  return (
60
34
  <svg
@@ -92,22 +66,25 @@ export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
92
66
  const strings = useStrings();
93
67
  const resolve = useUiText();
94
68
  return (
95
- <nav aria-label={strings.breadcrumbsLabel} data-terp="breadcrumbs" style={navStyle}>
96
- <ol style={listStyle}>
69
+ <nav aria-label={strings.breadcrumbsLabel} data-terp="breadcrumbs">
70
+ <ol>
97
71
  {items.map((item, index) => {
98
72
  const isLast = index === items.length - 1;
99
73
  const label = resolve(item.label);
100
74
  return (
101
- <li key={`${index}-${label}`} style={listStyle}>
75
+ <li key={`${index}-${label}`}>
102
76
  {!isLast && item.to !== undefined ? (
103
77
  renderCrumbLink({ label, to: item.to })
104
78
  ) : (
105
- <span aria-current={isLast ? "page" : undefined} style={isLast ? currentStyle : undefined}>
79
+ <span
80
+ aria-current={isLast ? "page" : undefined}
81
+ data-terp={isLast ? "breadcrumbs-current" : undefined}
82
+ >
106
83
  {label}
107
84
  </span>
108
85
  )}
109
86
  {!isLast && (
110
- <span aria-hidden="true" style={separatorStyle}>
87
+ <span aria-hidden="true" data-terp="breadcrumbs-separator">
111
88
  <ChevronSeparator />
112
89
  </span>
113
90
  )}
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useId, useRef } from "react";
2
- import type { CSSProperties, ReactNode } from "react";
2
+ import type { ReactNode } from "react";
3
3
 
4
4
  import { Button } from "./ui/Button";
5
5
  import { injectTerpStyles } from "./styles";
@@ -32,44 +32,6 @@ export interface ConfirmDialogProps {
32
32
  confirmDisabled?: boolean;
33
33
  }
34
34
 
35
- const dialogStyle: CSSProperties = {
36
- width: "100%",
37
- maxWidth: "26rem",
38
- padding: 0,
39
- border: "1px solid var(--color-neutral-200)",
40
- borderRadius: "var(--radius-lg)",
41
- boxShadow: "var(--shadow-lg)",
42
- background: "var(--color-neutral-0)",
43
- color: "var(--color-neutral-900)",
44
- };
45
-
46
- const bodyStyle: CSSProperties = {
47
- display: "grid",
48
- gap: "var(--space-3)",
49
- padding: "var(--space-6)",
50
- };
51
-
52
- const dialogTitleStyle: CSSProperties = {
53
- margin: 0,
54
- fontSize: "var(--font-size-lg)",
55
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
56
- letterSpacing: 0,
57
- color: "var(--color-neutral-900)",
58
- };
59
-
60
- const descriptionStyle: CSSProperties = {
61
- color: "var(--color-neutral-600)",
62
- fontSize: "var(--font-size-sm)",
63
- lineHeight: 1.5,
64
- };
65
-
66
- const footerStyle: CSSProperties = {
67
- display: "flex",
68
- justifyContent: "flex-end",
69
- gap: "var(--space-2)",
70
- marginTop: "var(--space-2)",
71
- };
72
-
73
35
  /**
74
36
  * The shared confirmation dialog — replaces `window.confirm()` with an accessible,
75
37
  * token-styled modal. Use it before any destructive or irreversible action (delete,
@@ -99,6 +61,7 @@ export function ConfirmDialog({
99
61
  const strings = useStrings();
100
62
  const resolve = useUiText();
101
63
  const titleId = useId();
64
+ const descriptionId = useId();
102
65
  const dialogRef = useRef<HTMLDialogElement>(null);
103
66
 
104
67
  useEffect(() => {
@@ -130,7 +93,11 @@ export function ConfirmDialog({
130
93
  ref={dialogRef}
131
94
  data-terp="dialog"
132
95
  aria-labelledby={titleId}
133
- style={dialogStyle}
96
+ // The consequence is the information a user needs before confirming a destructive action,
97
+ // and a modal announces its name and its description on open — unlinked body copy is
98
+ // reached only by manual exploration. Conditional, so a description-less dialog carries
99
+ // no dangling idref.
100
+ aria-describedby={description !== undefined ? descriptionId : undefined}
134
101
  onCancel={(event) => {
135
102
  // Escape: stay controlled — never let the platform close the element itself.
136
103
  event.preventDefault();
@@ -145,14 +112,16 @@ export function ConfirmDialog({
145
112
  }
146
113
  }}
147
114
  >
148
- <div style={bodyStyle}>
149
- <h2 id={titleId} style={dialogTitleStyle}>
115
+ <div data-terp="dialog-body">
116
+ <h2 id={titleId} data-terp="dialog-title">
150
117
  {resolve(title)}
151
118
  </h2>
152
119
  {description !== undefined && (
153
- <div style={descriptionStyle}>{description}</div>
120
+ <div id={descriptionId} data-terp="dialog-description">
121
+ {description}
122
+ </div>
154
123
  )}
155
- <div style={footerStyle}>
124
+ <div data-terp="dialog-actions">
156
125
  <Button variant="secondary" disabled={isPending} onClick={() => onOpenChange(false)}>
157
126
  {resolve(cancelLabel ?? strings.cancel)}
158
127
  </Button>
@@ -1,9 +1,12 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
3
  import { Icon } from "./icons";
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 EmptyStateProps {
8
11
  /**
9
12
  * Optional leading visual (any rendered node — react-core takes no icon dependency).
@@ -18,37 +21,6 @@ export interface EmptyStateProps {
18
21
  action?: ReactNode;
19
22
  }
20
23
 
21
- const wrapStyle: CSSProperties = {
22
- display: "grid",
23
- justifyItems: "center",
24
- gap: "var(--space-3)",
25
- padding: "var(--space-8) var(--space-6)",
26
- textAlign: "center",
27
- color: "var(--color-neutral-600)",
28
- border: "1px dashed var(--color-neutral-300)",
29
- borderRadius: "var(--radius-lg)",
30
- background: "var(--color-neutral-0)",
31
- };
32
-
33
- const iconStyle: CSSProperties = {
34
- color: "var(--color-neutral-400)",
35
- display: "inline-flex",
36
- };
37
-
38
- const titleStyle: CSSProperties = {
39
- margin: 0,
40
- color: "var(--color-neutral-900)",
41
- fontSize: "var(--font-size-base)",
42
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
43
- };
44
-
45
- const descriptionStyle: CSSProperties = {
46
- color: "var(--color-neutral-600)",
47
- fontSize: "var(--font-size-sm)",
48
- lineHeight: 1.5,
49
- maxWidth: "36ch",
50
- };
51
-
52
24
  /**
53
25
  * The standard "nothing here yet" block: use whenever a query legitimately returns zero
54
26
  * rows, a module is not wired to data yet, or a feature is gated. One recognisable empty
@@ -58,15 +30,15 @@ const descriptionStyle: CSSProperties = {
58
30
  export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
59
31
  const resolve = useUiText();
60
32
  const leading = icon ?? (
61
- <span style={iconStyle}>
33
+ <span data-terp="empty-state-icon">
62
34
  <Icon name="inbox" size="2rem" />
63
35
  </span>
64
36
  );
65
37
  return (
66
- <div data-terp="empty-state" style={wrapStyle}>
38
+ <div data-terp="empty-state">
67
39
  {leading}
68
- <p style={titleStyle}>{resolve(title)}</p>
69
- {description !== undefined && <div style={descriptionStyle}>{description}</div>}
40
+ <p data-terp="empty-state-title">{resolve(title)}</p>
41
+ {description !== undefined && <div data-terp="empty-state-description">{description}</div>}
70
42
  {action}
71
43
  </div>
72
44
  );
@@ -1,10 +1,13 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
3
  import { useErrorMessage } from "./errorMessages";
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
  /**
9
12
  * Best-effort human-readable message for a caught failure. `unwrap` already throws
10
13
  * `Error`s carrying the backend envelope's `detail`, so most callers just surface
@@ -47,37 +50,6 @@ export interface ErrorStateProps {
47
50
  action?: ReactNode;
48
51
  }
49
52
 
50
- const wrapStyle: CSSProperties = {
51
- display: "grid",
52
- justifyItems: "center",
53
- gap: "var(--space-3)",
54
- padding: "var(--space-6)",
55
- textAlign: "center",
56
- color: "var(--color-neutral-700)",
57
- background: "var(--color-status-danger-soft)",
58
- border: "1px solid var(--color-status-danger)",
59
- borderRadius: "var(--radius-lg)",
60
- };
61
-
62
- const titleStyle: CSSProperties = {
63
- margin: 0,
64
- color: "var(--color-status-danger)",
65
- fontSize: "var(--font-size-base)",
66
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
67
- };
68
-
69
- const iconStyle: CSSProperties = {
70
- color: "var(--color-status-danger)",
71
- display: "inline-flex",
72
- };
73
-
74
- const descriptionStyle: CSSProperties = {
75
- color: "var(--color-neutral-700)",
76
- fontSize: "var(--font-size-sm)",
77
- lineHeight: 1.5,
78
- maxWidth: "48ch",
79
- };
80
-
81
53
  /**
82
54
  * The standard "something went wrong" block: use whenever a query fails (404 / 403 /
83
55
  * network) and the page frame is already rendered. Announced as an `alert` so assistive
@@ -93,15 +65,15 @@ export function ErrorState({ icon, title, description, error, action }: ErrorSta
93
65
  (error !== undefined ? (messageForCode(error) ?? describeError(error)) : null);
94
66
  const leading =
95
67
  icon ?? (
96
- <span style={iconStyle}>
68
+ <span data-terp="error-state-icon">
97
69
  <Icon name="x" size="1.75rem" />
98
70
  </span>
99
71
  );
100
72
  return (
101
- <div role="alert" data-terp="error-state" style={wrapStyle}>
73
+ <div role="alert" data-terp="error-state">
102
74
  {leading}
103
- <p style={titleStyle}>{resolve(title ?? strings.errorTitle)}</p>
104
- {message !== null && message !== undefined && <div style={descriptionStyle}>{message}</div>}
75
+ <p data-terp="error-state-title">{resolve(title ?? strings.errorTitle)}</p>
76
+ {message !== null && message !== undefined && <div data-terp="error-state-description">{message}</div>}
105
77
  {action}
106
78
  </div>
107
79
  );
@@ -30,8 +30,65 @@ describe("Field", () => {
30
30
  );
31
31
  expect(screen.getByLabelText("Name")).toBeInTheDocument();
32
32
  });
33
+ it("points the control at its hint and its error, and marks it invalid", () => {
34
+ render(
35
+ <Field label="Email" hint="we never share it" error="required">
36
+ <Input defaultValue="" />
37
+ </Field>,
38
+ );
39
+ const input = screen.getByLabelText("Email");
40
+ // Text beside a control is invisible to a screen reader unless something points at it. The
41
+ // label needs no wiring because the control sits inside it; the hint and the error do.
42
+ const described = (input.getAttribute("aria-describedby") ?? "").split(" ").filter(Boolean);
43
+ expect(described).toHaveLength(2);
44
+ expect(described.map((id) => document.getElementById(id)?.textContent)).toEqual([
45
+ "we never share it",
46
+ "required",
47
+ ]);
48
+ // An error also opts the control into the sheet's invalid border, so the field does not
49
+ // depend on every caller remembering to pass aria-invalid alongside its error text.
50
+ expect(input).toHaveAttribute("aria-invalid", "true");
51
+ });
52
+
53
+ it("describes a hint with no error, and does not claim invalid", () => {
54
+ render(
55
+ <Field label="Name" hint="as it appears on the account">
56
+ <Input defaultValue="" />
57
+ </Field>,
58
+ );
59
+ const input = screen.getByLabelText("Name");
60
+ const id = input.getAttribute("aria-describedby");
61
+ expect(document.getElementById(id!)?.textContent).toBe("as it appears on the account");
62
+ expect(input).not.toHaveAttribute("aria-invalid");
63
+ });
64
+
65
+ it("adds no attributes when there is nothing to describe", () => {
66
+ render(
67
+ <Field label="Plain">
68
+ <Input defaultValue="" />
69
+ </Field>,
70
+ );
71
+ const input = screen.getByLabelText("Plain");
72
+ expect(input).not.toHaveAttribute("aria-describedby");
73
+ expect(input).not.toHaveAttribute("aria-invalid");
74
+ });
75
+
76
+ it("keeps a control's own aria-describedby and aria-invalid rather than replacing them", () => {
77
+ render(
78
+ <Field label="Email" hint="we never share it" error="required">
79
+ <Input defaultValue="" aria-describedby="caller-note" aria-invalid={false} />
80
+ </Field>,
81
+ );
82
+ const input = screen.getByLabelText("Email");
83
+ // The field appends; it does not clobber. And a control that deliberately says it is NOT
84
+ // invalid keeps saying so — the field supplies a default, not an override.
85
+ expect(input.getAttribute("aria-describedby")?.split(" ")[0]).toBe("caller-note");
86
+ expect(input.getAttribute("aria-describedby")?.split(" ")).toHaveLength(3);
87
+ expect(input).toHaveAttribute("aria-invalid", "false");
88
+ });
33
89
  });
34
90
 
91
+
35
92
  describe("Select / Textarea primitives", () => {
36
93
  it("render token-styled controls reachable by their Field label", () => {
37
94
  render(
package/src/Field.tsx CHANGED
@@ -1,23 +1,11 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import { cloneElement, isValidElement, useId } from "react";
2
+ import type { ReactNode } from "react";
2
3
 
4
+ import { injectTerpStyles } from "./styles";
3
5
  import { useUiText } from "./uiText";
4
6
  import type { UiText } from "./uiText";
5
7
 
6
- const fieldStyle: CSSProperties = { display: "grid", gap: "var(--space-1)" };
7
- const labelStyle: CSSProperties = {
8
- fontWeight: "var(--font-weight-medium)" as never,
9
- fontSize: "var(--font-size-sm)",
10
- color: "var(--color-neutral-700)",
11
- };
12
- const hintStyle: CSSProperties = {
13
- color: "var(--color-neutral-500)",
14
- fontSize: "var(--font-size-xs)",
15
- };
16
- const errorStyle: CSSProperties = {
17
- color: "var(--color-status-danger)",
18
- fontSize: "var(--font-size-xs)",
19
- fontWeight: "var(--font-weight-medium)" as never,
20
- };
8
+ injectTerpStyles();
21
9
 
22
10
  export interface FieldProps {
23
11
  /** The field label (also the control's accessible name — the control is wrapped in the `<label>`). */
@@ -35,17 +23,53 @@ export interface FieldProps {
35
23
  * id wiring) and renders an optional hint + a field-level error. Compose it with the token-styled
36
24
  * `Input` / `Select` / `Textarea` primitives to build a multi-field form — the centralized, accessible
37
25
  * way every module authors inputs.
26
+ *
27
+ * The label needs no wiring because the control sits inside it, but the hint and the error do: text
28
+ * beside a control is invisible to a screen reader unless something points at it. So the field
29
+ * gives each one an id and hands the control an `aria-describedby` — and, when there is an error, an
30
+ * `aria-invalid` that also opts the control into the sheet's invalid border. A control that already
31
+ * declares either keeps its own value; the field adds to `aria-describedby` rather than replacing
32
+ * it. `Input`, `Select`, `Textarea` and `Combobox` all spread their props onto the DOM element, so
33
+ * the attributes land where assistive tech reads them.
38
34
  */
39
35
  export function Field({ label, children, error, hint }: FieldProps) {
40
36
  const resolve = useUiText();
37
+ const baseId = useId();
38
+ const hasError = error !== undefined && error !== null;
39
+ const hintId = hint !== undefined ? `${baseId}-hint` : undefined;
40
+ const errorId = hasError ? `${baseId}-error` : undefined;
41
+ const described = [hintId, errorId].filter((id) => id !== undefined).join(" ");
42
+
43
+ // Only a single element child can be described — which is the documented contract ("the
44
+ // control"). Anything else is passed through untouched rather than guessed at.
45
+ const control = described.length > 0 && isValidElement<{
46
+ "aria-describedby"?: string;
47
+ "aria-invalid"?: boolean | "true" | "false";
48
+ }>(children)
49
+ ? cloneElement(children, {
50
+ "aria-describedby": [children.props["aria-describedby"], described]
51
+ .filter((id) => id !== undefined && id !== "")
52
+ .join(" "),
53
+ "aria-invalid": children.props["aria-invalid"] ?? (hasError ? true : undefined),
54
+ })
55
+ : children;
56
+
41
57
  return (
42
- <div style={fieldStyle}>
43
- <label style={fieldStyle}>
44
- <span style={labelStyle}>{resolve(label)}</span>
45
- {children}
58
+ <div data-terp="field">
59
+ <label data-terp="field-label">
60
+ <span data-terp="field-label-text">{resolve(label)}</span>
61
+ {control}
46
62
  </label>
47
- {hint !== undefined && <span style={hintStyle}>{hint}</span>}
48
- {error !== undefined && error !== null && <span style={errorStyle}>{error}</span>}
63
+ {hint !== undefined && (
64
+ <span id={hintId} data-terp="field-hint">
65
+ {hint}
66
+ </span>
67
+ )}
68
+ {hasError && (
69
+ <span id={errorId} data-terp="field-error">
70
+ {error}
71
+ </span>
72
+ )}
49
73
  </div>
50
74
  );
51
75
  }
@@ -18,7 +18,12 @@ describe("HubPage", () => {
18
18
  expect(screen.getByRole("heading", { level: 1, name: "Administration" })).toBeInTheDocument();
19
19
  expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
20
20
  expect(screen.getAllByText("Administration")).toHaveLength(1);
21
- expect(screen.getByRole("list")).toHaveStyle({ gridAutoRows: "1fr" });
21
+ // The marker, not the declaration. jsdom does not compute the cascade, so toHaveStyle
22
+ // can only ever see an inline style — asserting gridAutoRows here was asserting that the
23
+ // grid is styled from a style object, which is the thing ADR 0094 removes. What a test
24
+ // should assert is the fact the sheet keys on; the geometry is gated by styles.test.ts
25
+ // (the rule exists) and by the hub-page baselines (it does what it says).
26
+ expect(screen.getByRole("list")).toHaveAttribute("data-terp", "hubpage-grid");
22
27
  expect(screen.getByRole("link", { name: /Users/ })).toBeInTheDocument();
23
28
  expect(screen.getByRole("link", { name: /Roles/ })).toBeInTheDocument();
24
29
  expect(screen.getByText("Manage accounts")).toBeInTheDocument();
@@ -91,18 +96,22 @@ describe("HubCard", () => {
91
96
  (title) => title.closest('[data-terp="hubcard-body"]'),
92
97
  );
93
98
  expect(bodies).toHaveLength(2);
94
- for (const body of bodies) {
95
- expect(body).toHaveStyle({
96
- gridTemplateRows: "auto minmax(3rem, 1fr) auto",
97
- minHeight: "10rem",
98
- });
99
- }
99
+ // What this test can actually establish, and it is the load-bearing half: both rows are
100
+ // PRESENT on the bare card, carrying a placeholder, so the body has three grid children
101
+ // either way. The equal-height claim is the tracks rule plus these placeholders, and the
102
+ // tracks rule is now in the sheet — gated by styles.test.ts for existence and by
103
+ // hub-card-bare, whose whole subject is a bare card sitting flush with a full one.
100
104
  const shortBody = bodies[0]!;
101
- expect(shortBody.querySelector('[data-terp="hubcard-description"]')).toHaveStyle({
102
- visibility: "hidden",
103
- });
104
- expect(shortBody.querySelector('[data-terp="hubcard-stat"]')).toHaveStyle({
105
- visibility: "hidden",
106
- });
105
+ const fullBody = bodies[1]!;
106
+ expect(shortBody.children).toHaveLength(fullBody.children.length);
107
+ // And the attribute the rule reads. Asserting visibility: hidden here asserted an inline
108
+ // style object; asserting data-empty asserts the fact, which is what survives the move.
109
+ for (const part of ["hubcard-description", "hubcard-stat"] as const) {
110
+ expect(shortBody.querySelector(`[data-terp="${part}"]`)).toHaveAttribute(
111
+ "data-empty",
112
+ "true",
113
+ );
114
+ expect(fullBody.querySelector(`[data-terp="${part}"]`)).not.toHaveAttribute("data-empty");
115
+ }
107
116
  });
108
117
  });