@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,85 @@
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 { Menu, MenuItem } from "./Menu";
6
+
7
+ const items = ["Archive", "Duplicate", "Delete"];
8
+
9
+ afterEach(cleanup);
10
+
11
+ describe("Menu", () => {
12
+ it("portals the panel outside clipping ancestors without treating panel clicks as outside", () => {
13
+ render(
14
+ <div data-testid="clip" style={{ overflow: "hidden" }}>
15
+ <Menu trigger="Open" triggerLabel="Actions">
16
+ {({ close }) => <MenuItem label="Archive" onSelect={() => close()} />}
17
+ </Menu>
18
+ </div>,
19
+ );
20
+
21
+ fireEvent.click(screen.getByRole("button", { name: "Actions" }));
22
+ const menu = screen.getByRole("menu");
23
+ expect(screen.getByTestId("clip")).not.toContainElement(menu);
24
+ expect(menu.parentElement).toHaveStyle({
25
+ fontFamily: "var(--font-family-sans)",
26
+ color: "var(--color-neutral-900)",
27
+ });
28
+ fireEvent.pointerDown(menu);
29
+ expect(menu).toBeInTheDocument();
30
+ });
31
+
32
+ it("opens, roves enabled items, selects and restores trigger focus", () => {
33
+ const onDelete = vi.fn();
34
+ render(
35
+ <Menu trigger="⋯" triggerLabel="More actions">
36
+ {({ close }) => (
37
+ <>
38
+ <MenuItem label="Archive" onSelect={() => {}} />
39
+ <MenuItem label="Duplicate" disabled onSelect={() => {}} />
40
+ <MenuItem label="Delete" destructive onSelect={() => { onDelete(); close(true); }} />
41
+ </>
42
+ )}
43
+ </Menu>,
44
+ );
45
+
46
+ const trigger = screen.getByRole("button", { name: "More actions" });
47
+ fireEvent.click(trigger);
48
+ const menu = screen.getByRole("menu");
49
+ expect(screen.getByRole("menuitem", { name: "Archive" })).toHaveFocus();
50
+ fireEvent.keyDown(menu, { key: "ArrowDown" });
51
+ expect(screen.getByRole("menuitem", { name: "Delete" })).toHaveFocus();
52
+ fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
53
+ expect(onDelete).toHaveBeenCalledOnce();
54
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
55
+ expect(trigger).toHaveFocus();
56
+ });
57
+
58
+ it("closes on Escape and outside click", () => {
59
+ render(
60
+ <Menu trigger="Open" triggerLabel="Actions">
61
+ {() => items.map((item) => <MenuItem key={item} label={item} onSelect={() => {}} />)}
62
+ </Menu>,
63
+ );
64
+ fireEvent.click(screen.getByRole("button", { name: "Actions" }));
65
+ fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
66
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
67
+
68
+ fireEvent.click(screen.getByRole("button", { name: "Actions" }));
69
+ fireEvent.pointerDown(document.body);
70
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
71
+ });
72
+
73
+ it("reports one controlled close for one outside pointer interaction", () => {
74
+ const onOpenChange = vi.fn();
75
+ render(
76
+ <Menu trigger="Open" triggerLabel="Actions" open onOpenChange={onOpenChange}>
77
+ {() => <MenuItem label="Archive" onSelect={() => {}} />}
78
+ </Menu>,
79
+ );
80
+
81
+ fireEvent.pointerDown(document.body);
82
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
83
+ expect(onOpenChange).toHaveBeenCalledWith(false);
84
+ });
85
+ });
@@ -0,0 +1,216 @@
1
+ import { useRef } from "react";
2
+ import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
3
+
4
+ import { Icon } from "../icons";
5
+ import { useUiText } from "../uiText";
6
+ import type { UiText } from "../uiText";
7
+ import { CONTROL_TEXT_STYLE } from "./controlStyles";
8
+ import { Popover } from "./Popover";
9
+ import type { PopoverAlign, PopoverPlacement } from "./Popover";
10
+
11
+ const triggerStyle: CSSProperties = {
12
+ ...CONTROL_TEXT_STYLE,
13
+ display: "inline-flex",
14
+ alignItems: "center",
15
+ justifyContent: "center",
16
+ minHeight: "2rem",
17
+ gap: "var(--space-1)",
18
+ padding: "var(--space-1) var(--space-2)",
19
+ background: "transparent",
20
+ color: "var(--color-neutral-700)",
21
+ border: "1px solid var(--color-neutral-300)",
22
+ borderRadius: "var(--radius-md)",
23
+ cursor: "pointer",
24
+ };
25
+
26
+ const menuStyle: CSSProperties = { display: "grid", gap: "var(--space-1)" };
27
+
28
+ const itemStyle = (destructive: boolean, disabled: boolean): CSSProperties => ({
29
+ ...CONTROL_TEXT_STYLE,
30
+ display: "flex",
31
+ alignItems: "center",
32
+ gap: "var(--space-2)",
33
+ width: "100%",
34
+ padding: "var(--space-2)",
35
+ background: "transparent",
36
+ border: "none",
37
+ borderRadius: "var(--radius-sm)",
38
+ textAlign: "left",
39
+ justifyContent: "flex-start",
40
+ cursor: disabled ? "not-allowed" : "pointer",
41
+ color: disabled
42
+ ? "var(--color-neutral-300)"
43
+ : destructive
44
+ ? "var(--color-status-danger)"
45
+ : "var(--color-neutral-900)",
46
+ });
47
+
48
+ export interface MenuProps {
49
+ trigger: ReactNode;
50
+ triggerLabel: UiText;
51
+ children: (api: { close: (restoreFocus?: boolean) => void }) => ReactNode;
52
+ open?: boolean;
53
+ defaultOpen?: boolean;
54
+ onOpenChange?: (open: boolean) => void;
55
+ align?: PopoverAlign;
56
+ placement?: PopoverPlacement;
57
+ triggerStyle?: CSSProperties;
58
+ panelStyle?: CSSProperties;
59
+ }
60
+
61
+ /** Dropdown menu built on Popover with roving focus and ARIA menu semantics. */
62
+ export function Menu({
63
+ trigger,
64
+ triggerLabel,
65
+ children,
66
+ open,
67
+ defaultOpen,
68
+ onOpenChange,
69
+ align = "end",
70
+ placement = "bottom",
71
+ triggerStyle: triggerStyleOverride,
72
+ panelStyle,
73
+ }: MenuProps) {
74
+ const resolve = useUiText();
75
+ const menuRef = useRef<HTMLDivElement>(null);
76
+
77
+ function focusItem(direction: 1 | -1 | "first" | "last") {
78
+ const items = menuItems(menuRef.current).filter((item) => !item.disabled);
79
+ if (items.length === 0) {
80
+ return;
81
+ }
82
+ const currentIndex = items.findIndex((item) => item === document.activeElement);
83
+ const nextIndex = direction === "first"
84
+ ? 0
85
+ : direction === "last"
86
+ ? items.length - 1
87
+ : currentIndex < 0
88
+ ? 0
89
+ : (currentIndex + direction + items.length) % items.length;
90
+ items[nextIndex]?.focus();
91
+ }
92
+
93
+ return (
94
+ <Popover
95
+ open={open}
96
+ defaultOpen={defaultOpen}
97
+ onOpenChange={onOpenChange}
98
+ align={align}
99
+ placement={placement}
100
+ panelStyle={panelStyle}
101
+ trigger={
102
+ <button
103
+ type="button"
104
+ data-terp="iconbutton"
105
+ aria-label={resolve(triggerLabel)}
106
+ aria-haspopup="menu"
107
+ style={{ ...triggerStyle, ...triggerStyleOverride }}
108
+ >
109
+ {trigger}
110
+ </button>
111
+ }
112
+ >
113
+ {({ close }) => (
114
+ <div
115
+ ref={(node) => {
116
+ menuRef.current = node;
117
+ if (node !== null && !node.contains(document.activeElement)) {
118
+ menuItems(node).find((item) => !item.disabled)?.focus();
119
+ }
120
+ }}
121
+ role="menu"
122
+ data-terp="menu"
123
+ style={menuStyle}
124
+ onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
125
+ switch (event.key) {
126
+ case "Escape":
127
+ event.preventDefault();
128
+ close(true);
129
+ break;
130
+ case "ArrowDown":
131
+ event.preventDefault();
132
+ focusItem(1);
133
+ break;
134
+ case "ArrowUp":
135
+ event.preventDefault();
136
+ focusItem(-1);
137
+ break;
138
+ case "Home":
139
+ event.preventDefault();
140
+ focusItem("first");
141
+ break;
142
+ case "End":
143
+ event.preventDefault();
144
+ focusItem("last");
145
+ break;
146
+ case "Tab":
147
+ close(false);
148
+ break;
149
+ default:
150
+ break;
151
+ }
152
+ }}
153
+ >
154
+ {children({ close })}
155
+ </div>
156
+ )}
157
+ </Popover>
158
+ );
159
+ }
160
+
161
+
162
+ export interface MenuItemProps {
163
+ label: UiText;
164
+ icon?: ReactNode;
165
+ /** Marks one choice in a mutually exclusive menu (renders `menuitemradio`). */
166
+ selected?: boolean;
167
+ destructive?: boolean;
168
+ disabled?: boolean;
169
+ onSelect: () => void;
170
+ }
171
+
172
+ /** One actionable item inside a Menu. */
173
+ export function MenuItem({
174
+ label,
175
+ icon,
176
+ selected,
177
+ destructive = false,
178
+ disabled = false,
179
+ onSelect,
180
+ }: MenuItemProps) {
181
+ const resolve = useUiText();
182
+ return (
183
+ <button
184
+ type="button"
185
+ role={selected === undefined ? "menuitem" : "menuitemradio"}
186
+ aria-checked={selected}
187
+ data-terp="menu-item"
188
+ data-selected={selected === true ? "true" : undefined}
189
+ tabIndex={-1}
190
+ disabled={disabled}
191
+ onClick={() => {
192
+ if (!disabled) {
193
+ onSelect();
194
+ }
195
+ }}
196
+ style={itemStyle(destructive, disabled)}
197
+ >
198
+ {icon !== undefined && <span aria-hidden="true" style={{ display: "inline-flex" }}>{icon}</span>}
199
+ {resolve(label)}
200
+ {selected === true && (
201
+ <span aria-hidden="true" style={{ display: "inline-flex", marginLeft: "auto" }}>
202
+ <Icon name="check" />
203
+ </span>
204
+ )}
205
+ </button>
206
+ );
207
+ }
208
+
209
+ function menuItems(menu: HTMLDivElement | null): HTMLButtonElement[] {
210
+ if (menu === null) {
211
+ return [];
212
+ }
213
+ return Array.from(
214
+ menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"], [role="menuitemradio"]'),
215
+ );
216
+ }
@@ -0,0 +1,218 @@
1
+ import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
+ import type { CSSProperties, KeyboardEvent, ReactElement, ReactNode } from "react";
3
+ import { createPortal } from "react-dom";
4
+
5
+ import { injectTerpStyles } from "../styles";
6
+
7
+ injectTerpStyles();
8
+
9
+ export type PopoverPlacement = "bottom" | "top";
10
+ export type PopoverAlign = "start" | "end";
11
+
12
+ export interface PopoverProps {
13
+ trigger: ReactElement;
14
+ children: (api: { close: (restoreFocus?: boolean) => void; panelId: string }) => ReactNode;
15
+ open?: boolean;
16
+ defaultOpen?: boolean;
17
+ onOpenChange?: (open: boolean) => void;
18
+ placement?: PopoverPlacement;
19
+ align?: PopoverAlign;
20
+ focusOnOpen?: boolean;
21
+ panelStyle?: CSSProperties;
22
+ }
23
+
24
+ const rootStyle: CSSProperties = { position: "relative", display: "inline-flex" };
25
+
26
+ const basePanelStyle: CSSProperties = {
27
+ position: "fixed",
28
+ zIndex: 60,
29
+ minWidth: "12rem",
30
+ padding: "var(--space-1)",
31
+ fontFamily: "var(--font-family-sans)",
32
+ color: "var(--color-neutral-900)",
33
+ background: "var(--color-neutral-0)",
34
+ border: "1px solid var(--color-neutral-200)",
35
+ borderRadius: "var(--radius-lg)",
36
+ boxShadow: "var(--shadow-lg)",
37
+ };
38
+
39
+ const VIEWPORT_GUTTER = 8;
40
+ const PANEL_GAP = 4;
41
+
42
+ interface PanelPosition {
43
+ left: number;
44
+ top: number;
45
+ visibility: CSSProperties["visibility"];
46
+ }
47
+
48
+ /** Anchored disclosure panel with outside-click, Escape close and focus return. */
49
+ export function Popover({
50
+ trigger,
51
+ children,
52
+ open,
53
+ defaultOpen = false,
54
+ onOpenChange,
55
+ placement = "bottom",
56
+ align = "end",
57
+ focusOnOpen = false,
58
+ panelStyle,
59
+ }: PopoverProps) {
60
+ const panelId = useId();
61
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
62
+ const isOpen = open ?? uncontrolledOpen;
63
+ const rootRef = useRef<HTMLDivElement>(null);
64
+ const triggerRef = useRef<HTMLElement | null>(null);
65
+ const panelRef = useRef<HTMLDivElement>(null);
66
+ const [panelPosition, setPanelPosition] = useState<PanelPosition>({
67
+ left: 0,
68
+ top: 0,
69
+ visibility: "hidden",
70
+ });
71
+
72
+ const setOpen = useCallback(
73
+ (next: boolean) => {
74
+ if (open === undefined) {
75
+ setUncontrolledOpen(next);
76
+ }
77
+ onOpenChange?.(next);
78
+ },
79
+ [onOpenChange, open],
80
+ );
81
+
82
+ const close = useCallback(
83
+ (restoreFocus = true) => {
84
+ setOpen(false);
85
+ if (restoreFocus) {
86
+ triggerRef.current?.focus();
87
+ }
88
+ },
89
+ [setOpen],
90
+ );
91
+
92
+ useEffect(() => {
93
+ if (!isOpen) {
94
+ return;
95
+ }
96
+ if (focusOnOpen) {
97
+ window.setTimeout(() => {
98
+ const first = panelRef.current?.querySelector<HTMLElement>(
99
+ 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
100
+ );
101
+ (first ?? panelRef.current)?.focus();
102
+ }, 0);
103
+ }
104
+ function onPointerDown(event: PointerEvent) {
105
+ const root = rootRef.current;
106
+ const panel = panelRef.current;
107
+ if (
108
+ root !== null &&
109
+ event.target instanceof Node &&
110
+ !root.contains(event.target) &&
111
+ !panel?.contains(event.target)
112
+ ) {
113
+ close(false);
114
+ }
115
+ }
116
+ function onKeyDown(event: globalThis.KeyboardEvent) {
117
+ if (event.key === "Escape") {
118
+ event.preventDefault();
119
+ close(true);
120
+ }
121
+ }
122
+ document.addEventListener("pointerdown", onPointerDown);
123
+ document.addEventListener("keydown", onKeyDown);
124
+ return () => {
125
+ document.removeEventListener("pointerdown", onPointerDown);
126
+ document.removeEventListener("keydown", onKeyDown);
127
+ };
128
+ }, [close, focusOnOpen, isOpen]);
129
+
130
+ useLayoutEffect(() => {
131
+ if (!isOpen) {
132
+ return;
133
+ }
134
+ function updatePosition() {
135
+ const trigger = triggerRef.current;
136
+ const panel = panelRef.current;
137
+ if (trigger === null || panel === null) {
138
+ return;
139
+ }
140
+ const anchor = trigger.getBoundingClientRect();
141
+ const panelRect = panel.getBoundingClientRect();
142
+ const below = anchor.bottom + PANEL_GAP;
143
+ const above = anchor.top - panelRect.height - PANEL_GAP;
144
+ const preferredTop = placement === "bottom" ? below : above;
145
+ const fallbackTop = placement === "bottom" ? above : below;
146
+ const fitsPreferred = preferredTop >= VIEWPORT_GUTTER &&
147
+ preferredTop + panelRect.height <= window.innerHeight - VIEWPORT_GUTTER;
148
+ const rawTop = fitsPreferred ? preferredTop : fallbackTop;
149
+ const rawLeft = align === "end"
150
+ ? anchor.right - panelRect.width
151
+ : anchor.left;
152
+ setPanelPosition({
153
+ left: Math.max(
154
+ VIEWPORT_GUTTER,
155
+ Math.min(rawLeft, window.innerWidth - panelRect.width - VIEWPORT_GUTTER),
156
+ ),
157
+ top: Math.max(
158
+ VIEWPORT_GUTTER,
159
+ Math.min(rawTop, window.innerHeight - panelRect.height - VIEWPORT_GUTTER),
160
+ ),
161
+ visibility: "visible",
162
+ });
163
+ }
164
+ updatePosition();
165
+ window.addEventListener("resize", updatePosition);
166
+ window.addEventListener("scroll", updatePosition, true);
167
+ return () => {
168
+ window.removeEventListener("resize", updatePosition);
169
+ window.removeEventListener("scroll", updatePosition, true);
170
+ };
171
+ }, [align, isOpen, placement]);
172
+
173
+ const triggerProps = trigger.props as Record<string, unknown>;
174
+ const cloned = cloneElement(trigger, {
175
+ ref: (node: HTMLElement | null) => {
176
+ triggerRef.current = node;
177
+ const originalRef = (trigger as ReactElement & { ref?: React.Ref<HTMLElement> }).ref;
178
+ if (typeof originalRef === "function") {
179
+ originalRef(node);
180
+ } else if (originalRef && typeof originalRef === "object") {
181
+ (originalRef as React.MutableRefObject<HTMLElement | null>).current = node;
182
+ }
183
+ },
184
+ "aria-expanded": isOpen,
185
+ "aria-controls": isOpen ? panelId : undefined,
186
+ onClick: (event: MouseEvent) => {
187
+ (triggerProps.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
188
+ if (!event.defaultPrevented) {
189
+ setOpen(!isOpen);
190
+ }
191
+ },
192
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => {
193
+ (triggerProps.onKeyDown as ((event: KeyboardEvent<HTMLElement>) => void) | undefined)?.(event);
194
+ if (!event.defaultPrevented && (event.key === "ArrowDown" || event.key === "ArrowUp") && !isOpen) {
195
+ event.preventDefault();
196
+ setOpen(true);
197
+ }
198
+ },
199
+ } as Partial<typeof trigger.props>);
200
+
201
+ return (
202
+ <div ref={rootRef} data-terp="popover" style={rootStyle}>
203
+ {cloned}
204
+ {isOpen && createPortal(
205
+ <div
206
+ id={panelId}
207
+ ref={panelRef}
208
+ data-terp="popover-panel"
209
+ tabIndex={-1}
210
+ style={{ ...basePanelStyle, ...panelStyle, ...panelPosition }}
211
+ >
212
+ {children({ close, panelId })}
213
+ </div>,
214
+ document.body,
215
+ )}
216
+ </div>
217
+ );
218
+ }
@@ -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 { RadioGroup } from "./Radio";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("RadioGroup", () => {
10
+ it("renders an accessible group and reports selected values", () => {
11
+ const onChange = vi.fn();
12
+ render(
13
+ <RadioGroup
14
+ label="Status"
15
+ name="status"
16
+ value="open"
17
+ onChange={onChange}
18
+ options={[
19
+ { value: "open", label: "Open" },
20
+ { value: "closed", label: "Closed" },
21
+ ]}
22
+ />,
23
+ );
24
+ expect(screen.getByRole("group", { name: "Status" })).toBeInTheDocument();
25
+ expect(screen.getByRole("radio", { name: "Open" })).toBeChecked();
26
+ fireEvent.click(screen.getByRole("radio", { name: "Closed" }));
27
+ expect(onChange).toHaveBeenCalledWith("closed");
28
+ });
29
+ });
@@ -0,0 +1,127 @@
1
+ import { useId, useState } from "react";
2
+ import type { CSSProperties, InputHTMLAttributes, ReactNode } from "react";
3
+
4
+ import { injectTerpStyles } from "../styles";
5
+ import { useUiText } from "../uiText";
6
+ import type { UiText } from "../uiText";
7
+
8
+ injectTerpStyles();
9
+
10
+ const groupStyle: CSSProperties = { display: "grid", gap: "var(--space-2)", border: 0, padding: 0, margin: 0 };
11
+ const legendStyle: CSSProperties = {
12
+ fontWeight: "var(--font-weight-medium)" as never,
13
+ padding: 0,
14
+ marginBlockEnd: "var(--space-1)",
15
+ fontSize: "var(--font-size-sm)",
16
+ color: "var(--color-neutral-700)",
17
+ };
18
+ const optionsStyle: CSSProperties = { display: "grid", gap: "var(--space-2)" };
19
+ const labelStyle: CSSProperties = {
20
+ display: "inline-flex",
21
+ alignItems: "center",
22
+ gap: "var(--space-2)",
23
+ color: "var(--color-neutral-900)",
24
+ cursor: "pointer",
25
+ fontSize: "var(--font-size-sm)",
26
+ };
27
+ const inputStyle: CSSProperties = {
28
+ inlineSize: "1rem",
29
+ blockSize: "1rem",
30
+ accentColor: "var(--color-brand-primary)",
31
+ cursor: "pointer",
32
+ };
33
+
34
+ export interface RadioProps
35
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "value"> {
36
+ label: UiText;
37
+ value: string;
38
+ checked?: boolean;
39
+ defaultChecked?: boolean;
40
+ onChange?: (checked: boolean) => void;
41
+ }
42
+
43
+ /** Token-styled labelled radio — usually rendered by {@link RadioGroup}. */
44
+ export function Radio({ label, value, checked, defaultChecked, onChange, style, ...rest }: RadioProps) {
45
+ const resolve = useUiText();
46
+ return (
47
+ <label style={{ ...labelStyle, ...style }}>
48
+ <input
49
+ {...rest}
50
+ type="radio"
51
+ data-terp="radio"
52
+ value={value}
53
+ checked={checked}
54
+ defaultChecked={defaultChecked}
55
+ onChange={(event) => onChange?.(event.currentTarget.checked)}
56
+ style={inputStyle}
57
+ />
58
+ <span>{resolve(label)}</span>
59
+ </label>
60
+ );
61
+ }
62
+
63
+ export interface RadioOption {
64
+ value: string;
65
+ label: UiText;
66
+ disabled?: boolean;
67
+ }
68
+
69
+ export interface RadioGroupProps {
70
+ label: UiText;
71
+ name?: string;
72
+ options?: readonly RadioOption[];
73
+ children?: ReactNode;
74
+ value?: string;
75
+ defaultValue?: string;
76
+ onChange?: (value: string) => void;
77
+ disabled?: boolean;
78
+ }
79
+
80
+ /** Accessible token-styled radio group; pass `options` for the standard generated radios. */
81
+ export function RadioGroup({
82
+ label,
83
+ name,
84
+ options,
85
+ children,
86
+ value,
87
+ defaultValue,
88
+ onChange,
89
+ disabled = false,
90
+ }: RadioGroupProps) {
91
+ const generatedName = useId();
92
+ const resolve = useUiText();
93
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
94
+ const selected = value ?? uncontrolledValue;
95
+ const groupName = name ?? generatedName;
96
+
97
+ function select(next: string) {
98
+ if (value === undefined) {
99
+ setUncontrolledValue(next);
100
+ }
101
+ onChange?.(next);
102
+ }
103
+
104
+ return (
105
+ <fieldset style={groupStyle}>
106
+ <legend style={legendStyle}>{resolve(label)}</legend>
107
+ <div style={optionsStyle}>
108
+ {options?.map((option) => (
109
+ <Radio
110
+ key={option.value}
111
+ name={groupName}
112
+ value={option.value}
113
+ label={option.label}
114
+ checked={selected === option.value}
115
+ disabled={disabled || option.disabled}
116
+ onChange={(checked) => {
117
+ if (checked) {
118
+ select(option.value);
119
+ }
120
+ }}
121
+ />
122
+ ))}
123
+ {children}
124
+ </div>
125
+ </fieldset>
126
+ );
127
+ }