@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,359 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import type { CSSProperties, ReactNode } from "react";
3
+
4
+ import { injectTerpStyles } from "../styles";
5
+ import type { UiText } from "../uiText";
6
+ import { DataViewExpandToggle, DataViewExpandableRow } from "./DataViewExpandableRow";
7
+ import { DataViewRowActions } from "./DataViewRowActions";
8
+ import type { DataViewRowActionsLayout } from "./DataViewRowActions";
9
+ import { SortAscGlyph, SortDescGlyph, SortNoneGlyph } from "./glyphs";
10
+ import { useDataViewText } from "./internal";
11
+
12
+ injectTerpStyles();
13
+ import type { DataViewColumn, DataViewRowAction } from "./types";
14
+
15
+ const MIN_COLUMN_WIDTH = 60;
16
+
17
+ export interface DataViewTableProps<T> {
18
+ rows: T[];
19
+ columns: DataViewColumn<T>[];
20
+ getRowId: (row: T) => string;
21
+ onRowClick?: (row: T) => void;
22
+ getRowLabel?: (row: T) => UiText;
23
+ isMobile: boolean;
24
+ // Sorting
25
+ sorting: { id: string; desc: boolean }[];
26
+ onToggleSort: (columnId: string) => void;
27
+ // Sizing
28
+ columnSizing: Record<string, number>;
29
+ onCommitColumnSizing: (sizing: Record<string, number>) => void;
30
+ // Selection
31
+ selectionEnabled: boolean;
32
+ isSelected: (rowId: string) => boolean;
33
+ onToggleSelected: (rowId: string) => void;
34
+ allPageSelected: boolean;
35
+ somePageSelected: boolean;
36
+ onToggleSelectPage: () => void;
37
+ // Expansion
38
+ renderExpanded?: (row: T) => ReactNode;
39
+ isExpanded: (rowId: string) => boolean;
40
+ onToggleExpanded: (rowId: string) => void;
41
+ // Actions
42
+ rowActions?: (row: T) => DataViewRowAction<T>[];
43
+ rowActionsLayout: DataViewRowActionsLayout;
44
+ }
45
+
46
+ const headerCellStyle: CSSProperties = {
47
+ position: "relative",
48
+ padding: "var(--space-2) var(--space-3)",
49
+ textAlign: "left",
50
+ fontSize: "var(--font-size-xs)",
51
+ fontWeight: "var(--font-weight-semibold)" as never,
52
+ color: "var(--color-neutral-500)",
53
+ textTransform: "uppercase",
54
+ letterSpacing: "0.04em",
55
+ borderBottom: "1px solid var(--color-neutral-200)",
56
+ whiteSpace: "nowrap",
57
+ background: "var(--color-neutral-0)",
58
+ };
59
+
60
+ const bodyCellStyle: CSSProperties = {
61
+ padding: "var(--space-3)",
62
+ borderBottom: "1px solid var(--color-neutral-100)",
63
+ fontSize: "var(--font-size-sm)",
64
+ color: "var(--color-neutral-900)",
65
+ overflow: "hidden",
66
+ textOverflow: "ellipsis",
67
+ };
68
+
69
+ const recordButtonStyle: CSSProperties = {
70
+ position: "absolute",
71
+ width: 1,
72
+ height: 1,
73
+ padding: 0,
74
+ margin: -1,
75
+ overflow: "hidden",
76
+ clip: "rect(0 0 0 0)",
77
+ whiteSpace: "nowrap",
78
+ border: 0,
79
+ };
80
+
81
+ /**
82
+ * The table layout of {@link DataView}: sortable, resizable headers; system columns
83
+ * (expand → select → user columns → actions); row click, selection, expansion.
84
+ *
85
+ * Width resolution precedence: pinned system columns → user-resized → static meta
86
+ * hint → auto (content-based).
87
+ */
88
+ export function DataViewTable<T>(props: DataViewTableProps<T>) {
89
+ const { strings, resolve, format } = useDataViewText();
90
+ const tableRef = useRef<HTMLTableElement>(null);
91
+
92
+ // Live widths during a resize drag only — persisted once, on pointer-up.
93
+ const [liveSizing, setLiveSizing] = useState<Record<string, number> | null>(null);
94
+ const dragCleanupRef = useRef<(() => void) | null>(null);
95
+ useEffect(() => () => dragCleanupRef.current?.(), []);
96
+
97
+ const startResize = useCallback(
98
+ (columnId: string, startX: number) => {
99
+ const table = tableRef.current;
100
+ if (table === null) {
101
+ return;
102
+ }
103
+ // Snapshot every rendered width so switching from auto to fixed layout does
104
+ // not make the other columns jump.
105
+ const snapshot: Record<string, number> = {};
106
+ for (const th of table.querySelectorAll<HTMLTableCellElement>("th[data-column-id]")) {
107
+ const id = th.dataset.columnId;
108
+ if (id !== undefined) {
109
+ snapshot[id] = th.offsetWidth;
110
+ }
111
+ }
112
+ const startWidth = snapshot[columnId] ?? MIN_COLUMN_WIDTH;
113
+ let current = snapshot;
114
+ setLiveSizing(snapshot);
115
+
116
+ const onPointerMove = (event: PointerEvent) => {
117
+ const width = Math.max(MIN_COLUMN_WIDTH, startWidth + (event.clientX - startX));
118
+ current = { ...current, [columnId]: width };
119
+ setLiveSizing(current);
120
+ };
121
+ const cleanup = () => {
122
+ window.removeEventListener("pointermove", onPointerMove);
123
+ window.removeEventListener("pointerup", onPointerUp);
124
+ dragCleanupRef.current = null;
125
+ };
126
+ const onPointerUp = () => {
127
+ cleanup();
128
+ setLiveSizing(null);
129
+ props.onCommitColumnSizing(current); // one persistence write per drag
130
+ };
131
+ window.addEventListener("pointermove", onPointerMove);
132
+ window.addEventListener("pointerup", onPointerUp);
133
+ dragCleanupRef.current = cleanup;
134
+ },
135
+ [props.onCommitColumnSizing],
136
+ );
137
+
138
+ const widthOf = (column: DataViewColumn<T>): number | string | undefined => {
139
+ const live = liveSizing?.[column.id];
140
+ if (live !== undefined) {
141
+ return live;
142
+ }
143
+ const resized = props.columnSizing[column.id];
144
+ if (resized !== undefined) {
145
+ return resized;
146
+ }
147
+ return column.meta?.width;
148
+ };
149
+
150
+ const hasExpand = props.renderExpanded !== undefined;
151
+ const hasActions = props.rowActions !== undefined;
152
+ const columnCount =
153
+ props.columns.length + (hasExpand ? 1 : 0) + (props.selectionEnabled ? 1 : 0) + (hasActions ? 1 : 0);
154
+
155
+ const selectAllRef = useRef<HTMLInputElement>(null);
156
+ useEffect(() => {
157
+ if (selectAllRef.current !== null) {
158
+ selectAllRef.current.indeterminate = props.somePageSelected && !props.allPageSelected;
159
+ }
160
+ }, [props.somePageSelected, props.allPageSelected]);
161
+
162
+ return (
163
+ <table
164
+ ref={tableRef}
165
+ data-terp="dataview-table"
166
+ style={{
167
+ width: "100%",
168
+ borderCollapse: "collapse",
169
+ tableLayout: liveSizing !== null ? "fixed" : "auto",
170
+ }}
171
+ >
172
+ <thead>
173
+ <tr>
174
+ {hasExpand && <th style={{ ...headerCellStyle, width: 40 }} aria-hidden />}
175
+ {props.selectionEnabled && (
176
+ <th style={{ ...headerCellStyle, width: 40 }}>
177
+ <input
178
+ ref={selectAllRef}
179
+ type="checkbox"
180
+ aria-label={resolve(strings.selectAllPage)}
181
+ checked={props.allPageSelected}
182
+ onChange={props.onToggleSelectPage}
183
+ />
184
+ </th>
185
+ )}
186
+ {props.columns.map((column) => {
187
+ const sort = props.sorting.find((entry) => entry.id === column.id);
188
+ const sortable = column.enableSorting !== false;
189
+ const width = widthOf(column);
190
+ return (
191
+ <th
192
+ key={column.id}
193
+ data-column-id={column.id}
194
+ aria-sort={
195
+ sort === undefined ? undefined : sort.desc ? "descending" : "ascending"
196
+ }
197
+ style={{ ...headerCellStyle, width }}
198
+ >
199
+ {sortable ? (
200
+ <button
201
+ type="button"
202
+ onClick={() => props.onToggleSort(column.id)}
203
+ style={{
204
+ font: "inherit",
205
+ color: "inherit",
206
+ display: "inline-flex",
207
+ alignItems: "center",
208
+ gap: "var(--space-1)",
209
+ background: "transparent",
210
+ border: "none",
211
+ padding: 0,
212
+ cursor: "pointer",
213
+ }}
214
+ >
215
+ {resolve(column.header)}
216
+ {sort === undefined ? (
217
+ <SortNoneGlyph size={12} style={{ opacity: 0.5 }} />
218
+ ) : sort.desc ? (
219
+ <SortDescGlyph size={12} />
220
+ ) : (
221
+ <SortAscGlyph size={12} />
222
+ )}
223
+ </button>
224
+ ) : (
225
+ resolve(column.header)
226
+ )}
227
+ <span
228
+ role="separator"
229
+ aria-orientation="vertical"
230
+ aria-label={`${resolve(strings.resizeColumn)}: ${resolve(column.meta?.label ?? column.header)}`}
231
+ onPointerDown={(event) => {
232
+ event.preventDefault();
233
+ startResize(column.id, event.clientX);
234
+ }}
235
+ style={{
236
+ position: "absolute",
237
+ top: 0,
238
+ right: -3,
239
+ width: 7,
240
+ height: "100%",
241
+ cursor: "col-resize",
242
+ zIndex: 1,
243
+ touchAction: "none",
244
+ }}
245
+ />
246
+ </th>
247
+ );
248
+ })}
249
+ {hasActions && (
250
+ <th style={{ ...headerCellStyle, width: 56 }}>
251
+ <span
252
+ style={{
253
+ position: "absolute",
254
+ width: 1,
255
+ height: 1,
256
+ padding: 0,
257
+ margin: -1,
258
+ overflow: "hidden",
259
+ clip: "rect(0 0 0 0)",
260
+ whiteSpace: "nowrap",
261
+ border: 0,
262
+ }}
263
+ >
264
+ {resolve(strings.actions)}
265
+ </span>
266
+ </th>
267
+ )}
268
+ </tr>
269
+ </thead>
270
+ <tbody>
271
+ {props.rows.map((row) => {
272
+ const rowId = props.getRowId(row);
273
+ const expanded = props.isExpanded(rowId);
274
+ const clickable = props.onRowClick !== undefined;
275
+ return (
276
+ <RowGroup key={rowId}>
277
+ <tr
278
+ onClick={clickable ? () => props.onRowClick?.(row) : undefined}
279
+ data-terp={clickable ? "dataview-row" : undefined}
280
+ data-selected={props.isSelected(rowId) || undefined}
281
+ style={{
282
+ cursor: clickable ? "pointer" : undefined,
283
+ background: props.isSelected(rowId) ? "var(--color-neutral-50)" : undefined,
284
+ }}
285
+ >
286
+ {hasExpand && (
287
+ <td style={bodyCellStyle}>
288
+ <DataViewExpandToggle
289
+ expanded={expanded}
290
+ onToggle={() => props.onToggleExpanded(rowId)}
291
+ />
292
+ </td>
293
+ )}
294
+ {props.selectionEnabled && (
295
+ <td style={bodyCellStyle} onClick={(event) => event.stopPropagation()}>
296
+ <input
297
+ type="checkbox"
298
+ aria-label={resolve(strings.selectRow)}
299
+ checked={props.isSelected(rowId)}
300
+ onChange={() => props.onToggleSelected(rowId)}
301
+ />
302
+ </td>
303
+ )}
304
+ {props.columns.map((column) => (
305
+ <td key={column.id} style={bodyCellStyle}>
306
+ {clickable && column === props.columns[0] && (
307
+ <button
308
+ type="button"
309
+ data-terp="dataview-row-open"
310
+ aria-label={format(strings.openRow, {
311
+ label: resolve(props.getRowLabel?.(row) ?? ""),
312
+ })}
313
+ onClick={(event) => {
314
+ event.stopPropagation();
315
+ props.onRowClick?.(row);
316
+ }}
317
+ style={recordButtonStyle}
318
+ />
319
+ )}
320
+ {column.cell !== undefined
321
+ ? column.cell(row)
322
+ : formatCell(column.accessor?.(row))}
323
+ </td>
324
+ ))}
325
+ {hasActions && (
326
+ <td style={{ ...bodyCellStyle, textAlign: "right" }}>
327
+ <DataViewRowActions
328
+ row={row}
329
+ actions={props.rowActions?.(row) ?? []}
330
+ layout={props.rowActionsLayout}
331
+ isMobile={props.isMobile}
332
+ />
333
+ </td>
334
+ )}
335
+ </tr>
336
+ {expanded && props.renderExpanded !== undefined && (
337
+ <DataViewExpandableRow colSpan={columnCount}>
338
+ {props.renderExpanded(row)}
339
+ </DataViewExpandableRow>
340
+ )}
341
+ </RowGroup>
342
+ );
343
+ })}
344
+ </tbody>
345
+ </table>
346
+ );
347
+ }
348
+
349
+ /** Keys a row + its expansion panel together without extra DOM. */
350
+ function RowGroup({ children }: { children: ReactNode }) {
351
+ return <>{children}</>;
352
+ }
353
+
354
+ function formatCell(value: unknown): ReactNode {
355
+ if (value === null || value === undefined) {
356
+ return null;
357
+ }
358
+ return String(value);
359
+ }
@@ -0,0 +1,260 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Button } from "../ui/Button";
4
+ import { Input } from "../ui/Input";
5
+ import { Select } from "../ui/Select";
6
+ import { DataViewColumnSettings } from "./DataViewColumnSettings";
7
+ import type { DataViewColumnSettingsProps } from "./DataViewColumnSettings";
8
+ import { CardsGlyph, CloseGlyph, EllipsisGlyph, SearchGlyph, TableGlyph } from "./glyphs";
9
+ import { DataViewMenu, DataViewMenuItem, useDataViewText } from "./internal";
10
+ import { useViewSearch } from "./hooks/useViewSearch";
11
+ import type { DataViewBatchAction, DataViewSearchScope } from "./types";
12
+ import type { UiText } from "../uiText";
13
+
14
+ export interface DataViewToolbarProps<T> {
15
+ // Search
16
+ searchEnabled: boolean;
17
+ search: string;
18
+ onSearchChange: (search: string) => void;
19
+ searchPlaceholder?: UiText;
20
+ searchDebounceMs?: number;
21
+ searchScope?: DataViewSearchScope;
22
+ onClearFilters?: () => void;
23
+ hasActiveFilters: boolean;
24
+
25
+ // View controls
26
+ columnSettings?: DataViewColumnSettingsProps<T>;
27
+ layout: "table" | "cards";
28
+ onLayoutChange?: (layout: "table" | "cards") => void;
29
+ pageSize?: number;
30
+ pageSizeOptions?: number[];
31
+ onPageSizeChange?: (pageSize: number) => void;
32
+
33
+ // Selection mode
34
+ selectedCount: number;
35
+ totalCount: number;
36
+ selectAllAcrossPages: boolean;
37
+ onSelectAllAcrossPages?: () => void;
38
+ onClearSelection: () => void;
39
+ batchActions?: DataViewBatchAction<T>[];
40
+ onBatchAction: (action: DataViewBatchAction<T>) => void;
41
+
42
+ isFetching: boolean;
43
+ /** Custom filter controls. */
44
+ children?: ReactNode;
45
+ trailing?: ReactNode;
46
+ }
47
+
48
+ /**
49
+ * The DataView toolbar. In its normal mode it hosts search, caller filter controls,
50
+ * the page-size selector, the table/cards toggle and the column-settings menu; when
51
+ * rows are selected it switches to selection mode ("N selected", batch actions,
52
+ * select-all-across-pages, clear selection).
53
+ */
54
+ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
55
+ const { strings, resolve, format } = useDataViewText();
56
+ const search = useViewSearch(props.search, props.onSearchChange, props.searchDebounceMs ?? 0);
57
+
58
+ const selectionMode = props.selectedCount > 0;
59
+
60
+ const barStyle = {
61
+ display: "flex",
62
+ alignItems: "center",
63
+ gap: "var(--space-2)",
64
+ flexWrap: "wrap",
65
+ padding: "var(--space-2) var(--space-3)",
66
+ borderBottom: "1px solid var(--color-neutral-200)",
67
+ background: selectionMode ? "var(--color-neutral-50)" : "var(--color-neutral-0)",
68
+ borderTopLeftRadius: "var(--radius-lg)",
69
+ borderTopRightRadius: "var(--radius-lg)",
70
+ minHeight: "3rem",
71
+ } as const;
72
+
73
+ if (selectionMode) {
74
+ const inlineActions = (props.batchActions ?? []).filter((action) => action.inline !== false);
75
+ const overflowActions = (props.batchActions ?? []).filter((action) => action.inline === false);
76
+ return (
77
+ <div data-terp="dataview-toolbar" style={barStyle}>
78
+ <span style={{ fontWeight: "var(--font-weight-medium)" as never }}>
79
+ {format(strings.selected, { count: props.selectedCount })}
80
+ </span>
81
+ {props.onSelectAllAcrossPages !== undefined && !props.selectAllAcrossPages && (
82
+ <Button variant="secondary" onClick={props.onSelectAllAcrossPages}>
83
+ {format(strings.selectAllResults, { total: props.totalCount })}
84
+ </Button>
85
+ )}
86
+ <span style={{ display: "inline-flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
87
+ {inlineActions.map((action, index) => (
88
+ <Button
89
+ key={index}
90
+ variant={action.variant === "destructive" ? "danger" : "secondary"}
91
+ onClick={() => props.onBatchAction(action)}
92
+ >
93
+ {action.icon !== undefined && (
94
+ <span aria-hidden style={{ display: "inline-flex", marginRight: "var(--space-1)" }}>
95
+ {action.icon}
96
+ </span>
97
+ )}
98
+ {resolve(action.label)}
99
+ </Button>
100
+ ))}
101
+ {overflowActions.length > 0 && (
102
+ <DataViewMenu trigger={<EllipsisGlyph />} triggerLabel={resolve(strings.moreActions)}>
103
+ {(close) => (
104
+ <>
105
+ {overflowActions.map((action, index) => (
106
+ <DataViewMenuItem
107
+ key={index}
108
+ label={resolve(action.label)}
109
+ icon={action.icon}
110
+ destructive={action.variant === "destructive"}
111
+ onSelect={() => {
112
+ close();
113
+ props.onBatchAction(action);
114
+ }}
115
+ />
116
+ ))}
117
+ </>
118
+ )}
119
+ </DataViewMenu>
120
+ )}
121
+ </span>
122
+ <span style={{ flex: 1 }} />
123
+ <Button variant="secondary" onClick={props.onClearSelection}>
124
+ {resolve(strings.clearSelection)}
125
+ </Button>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <div data-terp="dataview-toolbar" style={barStyle}>
132
+ {props.searchEnabled && (
133
+ <span style={{ position: "relative", display: "inline-flex", alignItems: "center" }}>
134
+ <span
135
+ aria-hidden
136
+ style={{
137
+ position: "absolute",
138
+ left: "var(--space-2)",
139
+ display: "inline-flex",
140
+ color: "var(--color-neutral-500)",
141
+ pointerEvents: "none",
142
+ }}
143
+ >
144
+ <SearchGlyph size={14} />
145
+ </span>
146
+ <Input
147
+ type="search"
148
+ value={search.inputValue}
149
+ onChange={(event) => search.setInputValue(event.target.value)}
150
+ placeholder={resolve(props.searchPlaceholder ?? strings.searchPlaceholder)}
151
+ aria-label={resolve(props.searchPlaceholder ?? strings.searchPlaceholder)}
152
+ style={{
153
+ paddingLeft: "var(--space-6)",
154
+ paddingRight: "var(--space-6)",
155
+ width: "16rem",
156
+ maxWidth: "100%",
157
+ }}
158
+ />
159
+ {search.inputValue !== "" && (
160
+ <button
161
+ type="button"
162
+ aria-label={resolve(strings.clearSearch)}
163
+ onClick={search.clear}
164
+ style={{
165
+ position: "absolute",
166
+ right: "var(--space-1)",
167
+ display: "inline-flex",
168
+ padding: "var(--space-1)",
169
+ background: "transparent",
170
+ border: "none",
171
+ cursor: "pointer",
172
+ color: "var(--color-neutral-500)",
173
+ }}
174
+ >
175
+ <CloseGlyph size={14} />
176
+ </button>
177
+ )}
178
+ </span>
179
+ )}
180
+ {props.searchScope !== undefined && props.search.trim() !== "" && (
181
+ <Button
182
+ variant="secondary"
183
+ aria-pressed={props.searchScope.broadened}
184
+ onClick={() => props.searchScope?.onBroadenedChange(!props.searchScope.broadened)}
185
+ >
186
+ {resolve(
187
+ props.searchScope.broadened ? props.searchScope.broadenedLabel : props.searchScope.label,
188
+ )}
189
+ </Button>
190
+ )}
191
+ {props.children}
192
+ {props.onClearFilters !== undefined && props.hasActiveFilters && (
193
+ <Button variant="secondary" onClick={props.onClearFilters}>
194
+ {resolve(strings.clearFilters)}
195
+ </Button>
196
+ )}
197
+ {props.isFetching && (
198
+ <span
199
+ role="status"
200
+ style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}
201
+ >
202
+ {resolve(strings.refreshing)}
203
+ </span>
204
+ )}
205
+ <span style={{ flex: 1 }} />
206
+ {props.onPageSizeChange !== undefined && props.pageSize !== undefined && (
207
+ <Select
208
+ value={String(props.pageSize)}
209
+ aria-label={resolve(strings.pageSize)}
210
+ onChange={(event) => props.onPageSizeChange?.(Number(event.target.value))}
211
+ >
212
+ {(props.pageSizeOptions ?? [10, 25, 50, 100]).map((option) => (
213
+ <option key={option} value={option}>
214
+ {option}
215
+ </option>
216
+ ))}
217
+ </Select>
218
+ )}
219
+ {props.onLayoutChange !== undefined && (
220
+ <span style={{ display: "inline-flex", gap: "var(--space-1)" }}>
221
+ <button
222
+ type="button"
223
+ aria-label={resolve(strings.tableView)}
224
+ aria-pressed={props.layout === "table"}
225
+ onClick={() => props.onLayoutChange?.("table")}
226
+ style={layoutToggleStyle(props.layout === "table")}
227
+ >
228
+ <TableGlyph />
229
+ </button>
230
+ <button
231
+ type="button"
232
+ aria-label={resolve(strings.cardView)}
233
+ aria-pressed={props.layout === "cards"}
234
+ onClick={() => props.onLayoutChange?.("cards")}
235
+ style={layoutToggleStyle(props.layout === "cards")}
236
+ >
237
+ <CardsGlyph />
238
+ </button>
239
+ </span>
240
+ )}
241
+ {props.columnSettings !== undefined && <DataViewColumnSettings {...props.columnSettings} />}
242
+ {props.trailing}
243
+ </div>
244
+ );
245
+ }
246
+
247
+ function layoutToggleStyle(active: boolean) {
248
+ return {
249
+ display: "inline-flex",
250
+ alignItems: "center",
251
+ justifyContent: "center",
252
+ minHeight: "2rem",
253
+ padding: "var(--space-1) var(--space-2)",
254
+ background: active ? "var(--color-neutral-100)" : "transparent",
255
+ border: "1px solid var(--color-neutral-300)",
256
+ borderRadius: "var(--radius-md)",
257
+ cursor: "pointer",
258
+ color: active ? "var(--color-neutral-900)" : "var(--color-neutral-500)",
259
+ } as const;
260
+ }