@terpjs/react-core 0.1.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 (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
@@ -0,0 +1,40 @@
1
+ import type { CSSProperties, SelectHTMLAttributes } from "react";
2
+
3
+ import { injectTerpStyles } from "../styles";
4
+ import { CONTROL_TEXT_STYLE } from "./controlStyles";
5
+
6
+ injectTerpStyles();
7
+
8
+ // SVG chevron rendered via a data URL background — keeps the token-only styling
9
+ // contract while giving <select> a consistent affordance across platforms.
10
+ const chevronUrl =
11
+ "url(\"data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 8 5 5 5-5'/%3E%3C/svg%3E\")";
12
+
13
+ const selectStyle: CSSProperties = {
14
+ ...CONTROL_TEXT_STYLE,
15
+ lineHeight: 1.2,
16
+ maxWidth: "100%",
17
+ minWidth: 0,
18
+ minHeight: "2.25rem",
19
+ padding: "0 calc(var(--space-3) + 1.25rem) 0 var(--space-3)",
20
+ border: "1px solid var(--color-neutral-300)",
21
+ borderRadius: "var(--radius-md)",
22
+ color: "var(--color-neutral-900)",
23
+ background: `${chevronUrl} no-repeat right var(--space-2) center / 1rem 1rem, var(--color-neutral-0)`,
24
+ boxSizing: "border-box",
25
+ appearance: "none",
26
+ WebkitAppearance: "none",
27
+ MozAppearance: "none",
28
+ };
29
+
30
+ export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
31
+
32
+ /**
33
+ * Token-styled select — use instead of a raw `<select>` (the module-boundary rule). Pass `<option>`
34
+ * children as usual. The `data-terp="input"` marker opts the element into the shared focus
35
+ * ring; a subtle SVG chevron replaces the native affordance for a consistent look.
36
+ */
37
+ export function Select({ style, ...rest }: SelectProps) {
38
+ return <select data-terp="input" {...rest} style={{ ...selectStyle, ...style }} />;
39
+ }
40
+
@@ -0,0 +1,17 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { Switch } from "./Switch";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("Switch", () => {
10
+ it("renders an accessible switch and reports checked changes", () => {
11
+ const onChange = vi.fn();
12
+ render(<Switch label="Email alerts" checked={false} onChange={onChange} />);
13
+ const toggle = screen.getByRole("switch", { name: "Email alerts" });
14
+ fireEvent.click(toggle);
15
+ expect(onChange).toHaveBeenCalledWith(true);
16
+ });
17
+ });
@@ -0,0 +1,53 @@
1
+ import type { CSSProperties, InputHTMLAttributes } from "react";
2
+
3
+ import { injectTerpStyles } from "../styles";
4
+ import { useUiText } from "../uiText";
5
+ import type { UiText } from "../uiText";
6
+
7
+ injectTerpStyles();
8
+
9
+ const labelStyle: CSSProperties = {
10
+ display: "inline-flex",
11
+ alignItems: "center",
12
+ gap: "var(--space-2)",
13
+ color: "var(--color-neutral-900)",
14
+ cursor: "pointer",
15
+ fontSize: "var(--font-size-sm)",
16
+ };
17
+
18
+ const inputStyle: CSSProperties = {
19
+ inlineSize: "2.25rem",
20
+ blockSize: "1.25rem",
21
+ accentColor: "var(--color-brand-primary)",
22
+ cursor: "pointer",
23
+ transition: "background-color 150ms ease",
24
+ };
25
+
26
+ export interface SwitchProps
27
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "role"> {
28
+ label: UiText;
29
+ checked?: boolean;
30
+ defaultChecked?: boolean;
31
+ onChange?: (checked: boolean) => void;
32
+ }
33
+
34
+ /** Token-styled labelled switch for boolean settings. */
35
+ export function Switch({ label, checked, defaultChecked, onChange, style, ...rest }: SwitchProps) {
36
+ const resolve = useUiText();
37
+ return (
38
+ <label style={{ ...labelStyle, ...style }}>
39
+ <input
40
+ {...rest}
41
+ type="checkbox"
42
+ role="switch"
43
+ data-terp="switch"
44
+ checked={checked}
45
+ defaultChecked={defaultChecked}
46
+ onChange={(event) => onChange?.(event.currentTarget.checked)}
47
+ style={inputStyle}
48
+ />
49
+ <span>{resolve(label)}</span>
50
+ </label>
51
+ );
52
+ }
53
+
@@ -0,0 +1,29 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { Tabs } from "./Tabs";
6
+
7
+ const tabs = [
8
+ { value: "overview", label: "Overview", content: <p>Overview panel</p> },
9
+ { value: "audit", label: "Audit", content: <p>Audit panel</p> },
10
+ ];
11
+
12
+ afterEach(cleanup);
13
+
14
+ describe("Tabs", () => {
15
+ it("renders tabs and switches uncontrolled selection by click", () => {
16
+ render(<Tabs label="Sections" tabs={tabs} defaultValue="overview" />);
17
+ expect(screen.getByRole("tablist", { name: "Sections" })).toBeInTheDocument();
18
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Overview panel");
19
+ fireEvent.click(screen.getByRole("tab", { name: "Audit" }));
20
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Audit panel");
21
+ });
22
+
23
+ it("reports controlled changes and supports arrow navigation", () => {
24
+ const onChange = vi.fn();
25
+ render(<Tabs label="Sections" tabs={tabs} value="overview" onChange={onChange} />);
26
+ fireEvent.keyDown(screen.getByRole("tablist"), { key: "ArrowRight" });
27
+ expect(onChange).toHaveBeenCalledWith("audit");
28
+ });
29
+ });
@@ -0,0 +1,128 @@
1
+ import { useId, useMemo, useState } from "react";
2
+ import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
3
+
4
+ import { injectTerpStyles } from "../styles";
5
+ import { useUiText } from "../uiText";
6
+ import type { UiText } from "../uiText";
7
+ import { CONTROL_TEXT_STYLE } from "./controlStyles";
8
+
9
+ injectTerpStyles();
10
+
11
+ const rootStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
12
+ const tabListStyle: CSSProperties = {
13
+ display: "flex",
14
+ flexWrap: "wrap",
15
+ gap: "var(--space-1)",
16
+ borderBlockEnd: "1px solid var(--color-neutral-200)",
17
+ };
18
+ const tabStyle = (selected: boolean): CSSProperties => ({
19
+ ...CONTROL_TEXT_STYLE,
20
+ fontWeight: (selected
21
+ ? "var(--font-weight-semibold)"
22
+ : "var(--font-weight-medium)") as never,
23
+ padding: "var(--space-2) var(--space-3)",
24
+ border: 0,
25
+ borderBlockEnd: selected ? "2px solid var(--color-brand-primary)" : "2px solid transparent",
26
+ color: selected ? "var(--color-brand-primary)" : "var(--color-neutral-600)",
27
+ background: "transparent",
28
+ cursor: "pointer",
29
+ marginBlockEnd: "-1px",
30
+ });
31
+ const panelStyle: CSSProperties = { color: "var(--color-neutral-900)" };
32
+
33
+ export interface TabItem {
34
+ value: string;
35
+ label: UiText;
36
+ content: ReactNode;
37
+ disabled?: boolean;
38
+ }
39
+
40
+ export interface TabsProps {
41
+ tabs: readonly TabItem[];
42
+ value?: string;
43
+ defaultValue?: string;
44
+ onChange?: (value: string) => void;
45
+ label?: UiText;
46
+ }
47
+
48
+ /** In-page tab set with controlled/uncontrolled selection and arrow-key navigation. */
49
+ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps) {
50
+ const baseId = useId();
51
+ const resolve = useUiText();
52
+ const enabledTabs = useMemo(() => tabs.filter((tab) => !tab.disabled), [tabs]);
53
+ const firstValue = enabledTabs[0]?.value ?? tabs[0]?.value ?? "";
54
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? firstValue);
55
+ const selectedValue = value ?? uncontrolledValue;
56
+ const selectedTab = tabs.find((tab) => tab.value === selectedValue) ?? enabledTabs[0] ?? tabs[0];
57
+
58
+ function select(next: string) {
59
+ if (value === undefined) {
60
+ setUncontrolledValue(next);
61
+ }
62
+ onChange?.(next);
63
+ }
64
+
65
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
66
+ if (!["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"].includes(event.key)) {
67
+ return;
68
+ }
69
+ event.preventDefault();
70
+ if (enabledTabs.length === 0) {
71
+ return;
72
+ }
73
+ const currentIndex = Math.max(
74
+ 0,
75
+ enabledTabs.findIndex((tab) => tab.value === selectedTab?.value),
76
+ );
77
+ const nextIndex =
78
+ event.key === "Home"
79
+ ? 0
80
+ : event.key === "End"
81
+ ? enabledTabs.length - 1
82
+ : event.key === "ArrowRight" || event.key === "ArrowDown"
83
+ ? (currentIndex + 1) % enabledTabs.length
84
+ : (currentIndex - 1 + enabledTabs.length) % enabledTabs.length;
85
+ const next = enabledTabs[nextIndex];
86
+ if (next) {
87
+ select(next.value);
88
+ document.getElementById(`${baseId}-tab-${next.value}`)?.focus();
89
+ }
90
+ }
91
+
92
+ return (
93
+ <div data-terp="tabs" style={rootStyle}>
94
+ <div role="tablist" aria-label={label === undefined ? undefined : resolve(label)} style={tabListStyle} onKeyDown={onKeyDown}>
95
+ {tabs.map((tab) => {
96
+ const selected = tab.value === selectedTab?.value;
97
+ return (
98
+ <button
99
+ key={tab.value}
100
+ id={`${baseId}-tab-${tab.value}`}
101
+ type="button"
102
+ role="tab"
103
+ data-terp="tab"
104
+ aria-selected={selected}
105
+ aria-controls={`${baseId}-panel-${tab.value}`}
106
+ tabIndex={selected ? 0 : -1}
107
+ disabled={tab.disabled}
108
+ onClick={() => select(tab.value)}
109
+ style={tabStyle(selected)}
110
+ >
111
+ {resolve(tab.label)}
112
+ </button>
113
+ );
114
+ })}
115
+ </div>
116
+ {selectedTab && (
117
+ <div
118
+ id={`${baseId}-panel-${selectedTab.value}`}
119
+ role="tabpanel"
120
+ aria-labelledby={`${baseId}-tab-${selectedTab.value}`}
121
+ style={panelStyle}
122
+ >
123
+ {selectedTab.content}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,27 @@
1
+ import type { CSSProperties, TextareaHTMLAttributes } from "react";
2
+
3
+ import { injectTerpStyles } from "../styles";
4
+ import { CONTROL_TEXT_STYLE } from "./controlStyles";
5
+
6
+ injectTerpStyles();
7
+
8
+ const textareaStyle: CSSProperties = {
9
+ ...CONTROL_TEXT_STYLE,
10
+ lineHeight: 1.4,
11
+ padding: "var(--space-2) var(--space-3)",
12
+ border: "1px solid var(--color-neutral-300)",
13
+ borderRadius: "var(--radius-md)",
14
+ color: "var(--color-neutral-900)",
15
+ background: "var(--color-neutral-0)",
16
+ boxSizing: "border-box",
17
+ };
18
+
19
+ export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
20
+
21
+ /**
22
+ * Token-styled multiline input — use instead of a raw `<textarea>` (the module-boundary rule).
23
+ */
24
+ export function Textarea({ style, ...rest }: TextareaProps) {
25
+ return <textarea data-terp="input" {...rest} style={{ ...textareaStyle, ...style }} />;
26
+ }
27
+
@@ -0,0 +1,28 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { Button } from "./Button";
6
+ import { Tooltip } from "./Tooltip";
7
+
8
+ afterEach(cleanup);
9
+
10
+ describe("Tooltip", () => {
11
+ it("describes its trigger and opens on focus and hover", () => {
12
+ render(
13
+ <Tooltip content="More information">
14
+ <Button>Help</Button>
15
+ </Tooltip>,
16
+ );
17
+ const trigger = screen.getByRole("button", { name: "Help" });
18
+ const tooltip = screen.getByRole("tooltip", { hidden: true });
19
+ expect(trigger).toHaveAttribute("aria-describedby", tooltip.id);
20
+ expect(tooltip).not.toBeVisible();
21
+ fireEvent.focus(trigger);
22
+ expect(tooltip).toBeVisible();
23
+ fireEvent.blur(trigger);
24
+ expect(tooltip).not.toBeVisible();
25
+ fireEvent.mouseEnter(trigger.parentElement!);
26
+ expect(tooltip).toBeVisible();
27
+ });
28
+ });
@@ -0,0 +1,67 @@
1
+ import { cloneElement, isValidElement, useId, useState } from "react";
2
+ import type { CSSProperties, FocusEvent, MouseEvent, ReactElement } from "react";
3
+
4
+ import { useUiText } from "../uiText";
5
+ import type { UiText } from "../uiText";
6
+
7
+ const wrapperStyle: CSSProperties = { position: "relative", display: "inline-flex" };
8
+ const tooltipStyle: CSSProperties = {
9
+ position: "absolute",
10
+ zIndex: 1,
11
+ insetBlockEnd: "calc(100% + var(--space-1))",
12
+ insetInlineStart: 0,
13
+ maxInlineSize: "min(18rem, calc(100vw - 2 * var(--space-4)))",
14
+ padding: "var(--space-1) var(--space-2)",
15
+ borderRadius: "var(--radius-sm)",
16
+ color: "var(--color-neutral-0)",
17
+ background: "var(--color-neutral-900)",
18
+ fontSize: "var(--font-size-xs)",
19
+ fontWeight: "var(--font-weight-medium)" as never,
20
+ lineHeight: 1.4,
21
+ boxShadow: "var(--shadow-md)",
22
+ pointerEvents: "none",
23
+ whiteSpace: "normal",
24
+ };
25
+
26
+ export interface TooltipProps {
27
+ content: UiText;
28
+ children: ReactElement;
29
+ }
30
+
31
+ interface TriggerHandlers {
32
+ onFocus?: (event: FocusEvent) => void;
33
+ onBlur?: (event: FocusEvent) => void;
34
+ onMouseEnter?: (event: MouseEvent) => void;
35
+ onMouseLeave?: (event: MouseEvent) => void;
36
+ "aria-describedby"?: string;
37
+ }
38
+
39
+ /** Accessible focus/hover tooltip. */
40
+ export function Tooltip({ content, children }: TooltipProps) {
41
+ const id = useId();
42
+ const resolve = useUiText();
43
+ const [open, setOpen] = useState(false);
44
+
45
+ if (!isValidElement<TriggerHandlers>(children)) {
46
+ return children;
47
+ }
48
+
49
+ return (
50
+ <span style={wrapperStyle} onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
51
+ {cloneElement(children, {
52
+ "aria-describedby": id,
53
+ onFocus: (event: FocusEvent) => {
54
+ children.props.onFocus?.(event);
55
+ setOpen(true);
56
+ },
57
+ onBlur: (event: FocusEvent) => {
58
+ children.props.onBlur?.(event);
59
+ setOpen(false);
60
+ },
61
+ })}
62
+ <span id={id} role="tooltip" hidden={!open} style={tooltipStyle}>
63
+ {resolve(content)}
64
+ </span>
65
+ </span>
66
+ );
67
+ }
@@ -0,0 +1,9 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ /** Stable typography for interactive controls, independent of surrounding display text. */
4
+ export const CONTROL_TEXT_STYLE: CSSProperties = {
5
+ fontFamily: "var(--font-family-sans)",
6
+ fontSize: "var(--font-size-sm)",
7
+ fontWeight: "var(--font-weight-normal)" as CSSProperties["fontWeight"],
8
+ lineHeight: 1.25,
9
+ };
@@ -0,0 +1,93 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { Page } from "./Page";
6
+ import { ResourceList } from "./ResourceList";
7
+ import { TerpProvider } from "./TerpProvider";
8
+ import { resolveUiText, UiTextProvider } from "./uiText";
9
+
10
+ afterEach(cleanup);
11
+
12
+ describe("resolveUiText", () => {
13
+ it("passes plain strings through and falls back to a descriptor's message", () => {
14
+ expect(resolveUiText("Tasks")).toBe("Tasks");
15
+ expect(resolveUiText({ id: "tasks.title", message: "Tasks" })).toBe("Tasks");
16
+ });
17
+ });
18
+
19
+ describe("UiTextProvider", () => {
20
+ it("components use the bundled defaults without a provider", () => {
21
+ render(
22
+ <Page title="Tasks" isLoading>
23
+ x
24
+ </Page>,
25
+ );
26
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
27
+ });
28
+
29
+ it("overrides framework strings per key", () => {
30
+ render(
31
+ <UiTextProvider strings={{ loading: "Laden..." }}>
32
+ <Page title="Taken" isLoading>
33
+ x
34
+ </Page>
35
+ </UiTextProvider>,
36
+ );
37
+ expect(screen.getByText("Laden...")).toBeInTheDocument();
38
+ expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
39
+ });
40
+
41
+ it("routes UiText props through a custom resolver (the i18n runtime hook)", () => {
42
+ render(
43
+ <UiTextProvider
44
+ resolveText={(text) =>
45
+ typeof text === "string" ? text : `[${text.id}] ${text.message}`
46
+ }
47
+ >
48
+ <Page title={{ id: "tasks.title", message: "Tasks" }}>x</Page>
49
+ </UiTextProvider>,
50
+ );
51
+ expect(
52
+ screen.getByRole("heading", { level: 1, name: "[tasks.title] Tasks" }),
53
+ ).toBeInTheDocument();
54
+ });
55
+
56
+ it("nested providers merge string overrides", () => {
57
+ render(
58
+ <UiTextProvider strings={{ loading: "Laden..." }}>
59
+ <UiTextProvider strings={{ emptyList: "Nog niets." }}>
60
+ <Page title="Taken" isLoading>
61
+ x
62
+ </Page>
63
+ </UiTextProvider>
64
+ </UiTextProvider>,
65
+ );
66
+ expect(screen.getByText("Laden...")).toBeInTheDocument();
67
+ });
68
+
69
+ it("localises ResourceList's empty message and create button", () => {
70
+ render(
71
+ <TerpProvider baseUrl="http://api.test">
72
+ <UiTextProvider strings={{ emptyList: "Nog niets.", add: "Toevoegen" }}>
73
+ <ResourceList
74
+ resource={{
75
+ items: [],
76
+ loading: false,
77
+ error: null,
78
+ cause: null,
79
+ create: async () => {},
80
+ reload: async () => {},
81
+ mutate: async (run: () => Promise<unknown>) => {
82
+ await run();
83
+ },
84
+ }}
85
+ renderItem={() => null}
86
+ createPlaceholder="Titel"
87
+ />
88
+ </UiTextProvider>
89
+ </TerpProvider>,
90
+ );
91
+ expect(screen.getByText("Nog niets.")).toBeInTheDocument();
92
+ });
93
+ });