@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,467 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { EmptyState } from "../EmptyState";
5
+ import { ErrorState } from "../ErrorState";
6
+ import type { UiText } from "../uiText";
7
+ import { DataViewCardList } from "./DataViewCardList";
8
+ import { DataViewPagination } from "./DataViewPagination";
9
+ import type { DataViewRowActionsLayout } from "./DataViewRowActions";
10
+ import { DataViewTable } from "./DataViewTable";
11
+ import { DataViewToolbar } from "./DataViewToolbar";
12
+ import { useDataViewQuery } from "./hooks/useDataViewQuery";
13
+ import { useDataViewState } from "./hooks/useDataViewState";
14
+ import type { DataViewControlledQuery } from "./hooks/useDataViewState";
15
+ import { DataViewTextProvider, useDataViewText } from "./internal";
16
+ import type {
17
+ DataViewBatchAction,
18
+ DataViewColumn,
19
+ DataViewQuery,
20
+ DataViewRepository,
21
+ DataViewRowAction,
22
+ DataViewSearchScope,
23
+ DataViewStrings,
24
+ ViewStateRepository,
25
+ } from "./types";
26
+
27
+ const MOBILE_BREAKPOINT = "(max-width: 768px)";
28
+ /** Embedded views render all rows; the parent owns paging. */
29
+ const EMBEDDED_PAGE_SIZE = 10_000;
30
+
31
+ interface DataViewBaseProps<T> {
32
+ /** Stable key for persisted view preferences; omit to keep them in memory only. */
33
+ viewId?: string;
34
+ /** The data access seam — DataView never fetches on its own. */
35
+ repository: DataViewRepository<T>;
36
+ /** The preference persistence seam (e.g. `LocalStorageViewStateRepository`). */
37
+ viewStateRepository?: ViewStateRepository;
38
+ columns: DataViewColumn<T>[];
39
+ /** "embedded" renders a plain compact view: no view toggle, no page-size selector, no footer. */
40
+ variant?: "full" | "embedded";
41
+ enableSelection?: boolean;
42
+ batchActions?: DataViewBatchAction<T>[];
43
+ rowActions?: (row: T) => DataViewRowAction<T>[];
44
+ rowActionsLayout?: DataViewRowActionsLayout;
45
+ searchPlaceholder?: UiText;
46
+ searchDebounceMs?: number;
47
+ /** Caller-owned "search everything" toggle (see {@link DataViewSearchScope}). */
48
+ searchScope?: DataViewSearchScope;
49
+ pageSizeOptions?: number[];
50
+ initialPageSize?: number;
51
+ renderExpanded?: (row: T) => ReactNode;
52
+ /** Fully custom cards in the responsive card layout. */
53
+ renderCard?: (row: T) => ReactNode;
54
+ /** Custom filter controls, rendered in the toolbar. */
55
+ toolbarContent?: ReactNode;
56
+ /** Trailing toolbar slot (e.g. a "New" button). */
57
+ toolbarTrailing?: ReactNode;
58
+ /** Rendered inside the empty state (e.g. a "clear filters" button). */
59
+ emptyActions?: ReactNode;
60
+ emptyMessage?: UiText;
61
+ /** Custom error state; defaults to {@link ErrorState}. */
62
+ renderError?: (error: unknown) => ReactNode;
63
+ /** Shows the toolbar "clear filters" action and wires it to this reset. */
64
+ onClearFilters?: () => void;
65
+ /** URL-backed query state for server-side views (from {@link useServerDataView}). */
66
+ serverQuery?: DataViewControlledQuery;
67
+ /** Per-instance overrides of the DataView's user-facing strings. */
68
+ strings?: Partial<DataViewStrings>;
69
+ }
70
+
71
+ export type DataViewProps<T> = DataViewBaseProps<T> & (
72
+ | {
73
+ onRowClick?: undefined;
74
+ getRowLabel?: undefined;
75
+ }
76
+ | {
77
+ /** Pointer activation for overview-to-detail navigation. */
78
+ onRowClick: (row: T) => void;
79
+ /** Accessible record name used by the row's native activation button. */
80
+ getRowLabel: (row: T) => UiText;
81
+ }
82
+ );
83
+
84
+ /**
85
+ * The single sanctioned surface for rendering data collections: a repository-driven
86
+ * table/card view with search, sorting, pagination, column management, selection,
87
+ * batch actions, row actions, expandable rows and persisted view preferences.
88
+ *
89
+ * DataView never fetches and never touches `localStorage`: all data access goes
90
+ * through the {@link DataViewRepository} and all preference persistence through the
91
+ * {@link ViewStateRepository} — swapping either implementation requires zero changes
92
+ * to any component file.
93
+ */
94
+ export function DataView<T>(props: DataViewProps<T>) {
95
+ return (
96
+ <DataViewTextProvider overrides={props.strings}>
97
+ <DataViewInner {...props} />
98
+ </DataViewTextProvider>
99
+ );
100
+ }
101
+
102
+ function useIsMobile(): boolean {
103
+ const [isMobile, setIsMobile] = useState(
104
+ () =>
105
+ typeof window !== "undefined" &&
106
+ typeof window.matchMedia === "function" &&
107
+ window.matchMedia(MOBILE_BREAKPOINT).matches,
108
+ );
109
+ useEffect(() => {
110
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
111
+ return;
112
+ }
113
+ const media = window.matchMedia(MOBILE_BREAKPOINT);
114
+ const onChange = () => setIsMobile(media.matches);
115
+ media.addEventListener("change", onChange);
116
+ return () => media.removeEventListener("change", onChange);
117
+ }, []);
118
+ return isMobile;
119
+ }
120
+
121
+ function DataViewInner<T>(props: DataViewProps<T>) {
122
+ const { strings, resolve } = useDataViewText();
123
+ const embedded = props.variant === "embedded";
124
+ const serverSide = props.repository.capabilities.serverSide;
125
+ const initialPageSize = embedded
126
+ ? EMBEDDED_PAGE_SIZE
127
+ : (props.initialPageSize ?? props.pageSizeOptions?.[0] ?? 10);
128
+
129
+ const state = useDataViewState<T>({
130
+ viewId: props.viewId,
131
+ columns: props.columns,
132
+ viewStateRepository: props.viewStateRepository,
133
+ serverSide,
134
+ initialPageSize,
135
+ controlledQuery: props.serverQuery,
136
+ });
137
+
138
+ const query: DataViewQuery = useMemo(
139
+ () => ({
140
+ pagination: state.pagination,
141
+ sorting: state.sorting,
142
+ filters: state.filters,
143
+ search: state.search,
144
+ searchBroadened: props.searchScope?.broadened ?? false,
145
+ }),
146
+ [state.pagination, state.sorting, state.filters, state.search, props.searchScope?.broadened],
147
+ );
148
+
149
+ const { rows, totalCount, isLoading, isFetching, error } = useDataViewQuery(
150
+ props.repository,
151
+ query,
152
+ );
153
+
154
+ // An out-of-range page (stale deep link, shrunk result set) snaps back to the
155
+ // last valid page, so the emitted query and the footer never disagree.
156
+ const setPagination = state.setPagination;
157
+ useEffect(() => {
158
+ if (isLoading || isFetching || (error !== null && error !== undefined)) {
159
+ return;
160
+ }
161
+ const { pageIndex, pageSize } = query.pagination;
162
+ const lastPageIndex = Math.max(0, Math.ceil(totalCount / pageSize) - 1);
163
+ if (pageIndex > lastPageIndex) {
164
+ setPagination({ pageIndex: lastPageIndex, pageSize });
165
+ }
166
+ }, [isLoading, isFetching, error, totalCount, query.pagination, setPagination]);
167
+
168
+ // ----- selection (keyed by getRowId so it survives re-sorts and refetches) -----
169
+ const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
170
+ const [selectAllAcrossPages, setSelectAllAcrossPages] = useState(false);
171
+ // Row objects for every id ever selected, so batch actions receive rows even after paging.
172
+ const selectedRowsRef = useRef(new Map<string, T>());
173
+
174
+ const getRowId = useCallback(
175
+ (row: T) => props.repository.getRowId(row),
176
+ [props.repository],
177
+ );
178
+ const pageRowIds = useMemo(() => rows.map(getRowId), [rows, getRowId]);
179
+ const allPageSelected = pageRowIds.length > 0 && pageRowIds.every((id) => selectedIds.has(id));
180
+ const somePageSelected = pageRowIds.some((id) => selectedIds.has(id));
181
+
182
+ const toggleSelected = useCallback(
183
+ (rowId: string) => {
184
+ setSelectedIds((current) => {
185
+ const next = new Set(current);
186
+ if (next.has(rowId)) {
187
+ next.delete(rowId);
188
+ selectedRowsRef.current.delete(rowId);
189
+ // Breaking the page selection always exits select-all-across-pages mode.
190
+ setSelectAllAcrossPages(false);
191
+ } else {
192
+ next.add(rowId);
193
+ const row = rows.find((candidate) => getRowId(candidate) === rowId);
194
+ if (row !== undefined) {
195
+ selectedRowsRef.current.set(rowId, row);
196
+ }
197
+ }
198
+ return next;
199
+ });
200
+ },
201
+ [rows, getRowId],
202
+ );
203
+
204
+ const toggleSelectPage = useCallback(() => {
205
+ setSelectedIds((current) => {
206
+ const next = new Set(current);
207
+ if (pageRowIds.length > 0 && pageRowIds.every((id) => next.has(id))) {
208
+ for (const id of pageRowIds) {
209
+ next.delete(id);
210
+ selectedRowsRef.current.delete(id);
211
+ }
212
+ setSelectAllAcrossPages(false);
213
+ } else {
214
+ for (const row of rows) {
215
+ const id = getRowId(row);
216
+ next.add(id);
217
+ selectedRowsRef.current.set(id, row);
218
+ }
219
+ }
220
+ return next;
221
+ });
222
+ }, [pageRowIds, rows, getRowId]);
223
+
224
+ const clearSelection = useCallback(() => {
225
+ setSelectedIds((current) => (current.size === 0 ? current : new Set()));
226
+ setSelectAllAcrossPages(false);
227
+ selectedRowsRef.current.clear();
228
+ }, []);
229
+
230
+ // Changing what the result set *is* (filters, search, scope) invalidates the
231
+ // selection: "select all N results" must never carry over to a different query,
232
+ // or batch onSelectAll would act on records the user never confirmed.
233
+ const selectionScopeKey = JSON.stringify({
234
+ filters: query.filters,
235
+ search: query.search,
236
+ broadened: query.searchBroadened,
237
+ });
238
+ useEffect(() => {
239
+ clearSelection();
240
+ }, [selectionScopeKey, clearSelection]);
241
+
242
+ const onBatchAction = useCallback(
243
+ (action: DataViewBatchAction<T>) => {
244
+ const selectedRows = [...selectedIds]
245
+ .map((id) => selectedRowsRef.current.get(id))
246
+ .filter((row): row is T => row !== undefined);
247
+ if (selectAllAcrossPages && action.onSelectAll !== undefined) {
248
+ action.onSelectAll(selectedRows);
249
+ } else {
250
+ action.onClick(selectedRows);
251
+ }
252
+ },
253
+ [selectedIds, selectAllAcrossPages],
254
+ );
255
+
256
+ // ----- expansion (keyed by row id) -----
257
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set());
258
+ const toggleExpanded = useCallback((rowId: string) => {
259
+ setExpandedIds((current) => {
260
+ const next = new Set(current);
261
+ if (next.has(rowId)) {
262
+ next.delete(rowId);
263
+ } else {
264
+ next.add(rowId);
265
+ }
266
+ return next;
267
+ });
268
+ }, []);
269
+
270
+ // ----- responsive layout: auto until the user toggles, manual choice wins -----
271
+ const isMobile = useIsMobile();
272
+ const [manualLayout, setManualLayout] = useState<"table" | "cards" | null>(null);
273
+ const layout: "table" | "cards" = manualLayout ?? (isMobile ? "cards" : "table");
274
+
275
+ const hasActiveFilters = state.filters.length > 0 || state.search !== "";
276
+ const clearFilters = () => {
277
+ state.clearFilters();
278
+ state.setSearch("");
279
+ props.onClearFilters?.();
280
+ };
281
+
282
+ const content = (() => {
283
+ if (isLoading) {
284
+ return <DataViewSkeleton label={resolve(strings.loading)} />;
285
+ }
286
+ if (error !== null && error !== undefined) {
287
+ return props.renderError !== undefined ? (
288
+ <>{props.renderError(error)}</>
289
+ ) : (
290
+ <div style={{ padding: "var(--space-4)" }}>
291
+ <ErrorState title={strings.errorTitle} error={error} />
292
+ </div>
293
+ );
294
+ }
295
+ if (rows.length === 0) {
296
+ return <EmptyState title={props.emptyMessage ?? strings.empty} action={props.emptyActions} />;
297
+ }
298
+ if (layout === "cards") {
299
+ return (
300
+ <DataViewCardList
301
+ rows={rows}
302
+ columns={state.orderedColumns}
303
+ getRowId={getRowId}
304
+ onRowClick={props.onRowClick}
305
+ getRowLabel={props.getRowLabel}
306
+ renderCard={props.renderCard}
307
+ selectionEnabled={props.enableSelection === true}
308
+ isSelected={(id) => selectedIds.has(id)}
309
+ onToggleSelected={toggleSelected}
310
+ renderExpanded={props.renderExpanded}
311
+ isExpanded={(id) => expandedIds.has(id)}
312
+ onToggleExpanded={toggleExpanded}
313
+ rowActions={props.rowActions}
314
+ />
315
+ );
316
+ }
317
+ return (
318
+ <div style={{ overflowX: "auto" }}>
319
+ <DataViewTable
320
+ rows={rows}
321
+ columns={state.visibleColumns}
322
+ getRowId={getRowId}
323
+ onRowClick={props.onRowClick}
324
+ getRowLabel={props.getRowLabel}
325
+ isMobile={isMobile}
326
+ sorting={state.sorting}
327
+ onToggleSort={state.toggleSort}
328
+ columnSizing={state.columnSizing}
329
+ onCommitColumnSizing={state.commitColumnSizing}
330
+ selectionEnabled={props.enableSelection === true}
331
+ isSelected={(id) => selectedIds.has(id)}
332
+ onToggleSelected={toggleSelected}
333
+ allPageSelected={allPageSelected}
334
+ somePageSelected={somePageSelected}
335
+ onToggleSelectPage={toggleSelectPage}
336
+ renderExpanded={props.renderExpanded}
337
+ isExpanded={(id) => expandedIds.has(id)}
338
+ onToggleExpanded={toggleExpanded}
339
+ rowActions={props.rowActions}
340
+ rowActionsLayout={props.rowActionsLayout ?? "menu"}
341
+ />
342
+ </div>
343
+ );
344
+ })();
345
+
346
+ if (embedded) {
347
+ // Stable inputs only: keying this on transient fetch state would pop the
348
+ // whole band in and out on mount and on every background refetch.
349
+ const showsEmbeddedToolbar =
350
+ props.repository.capabilities.search ||
351
+ props.searchScope !== undefined ||
352
+ props.onClearFilters !== undefined ||
353
+ props.enableSelection === true ||
354
+ (props.batchActions?.length ?? 0) > 0 ||
355
+ props.toolbarContent !== undefined ||
356
+ props.toolbarTrailing !== undefined;
357
+ return (
358
+ <div data-terp="dataview" style={{ display: "grid" }}>
359
+ {showsEmbeddedToolbar && (
360
+ <DataViewToolbar<T>
361
+ searchEnabled={props.repository.capabilities.search}
362
+ search={state.search}
363
+ onSearchChange={state.setSearch}
364
+ searchPlaceholder={props.searchPlaceholder}
365
+ searchDebounceMs={props.searchDebounceMs}
366
+ searchScope={props.repository.capabilities.searchScope ? props.searchScope : undefined}
367
+ onClearFilters={props.onClearFilters !== undefined ? clearFilters : undefined}
368
+ hasActiveFilters={hasActiveFilters}
369
+ layout={layout}
370
+ selectedCount={selectedIds.size}
371
+ totalCount={totalCount}
372
+ selectAllAcrossPages={selectAllAcrossPages}
373
+ onSelectAllAcrossPages={
374
+ allPageSelected && totalCount > selectedIds.size
375
+ ? () => setSelectAllAcrossPages(true)
376
+ : undefined
377
+ }
378
+ onClearSelection={clearSelection}
379
+ batchActions={props.batchActions}
380
+ onBatchAction={onBatchAction}
381
+ isFetching={isFetching}
382
+ trailing={props.toolbarTrailing}
383
+ >
384
+ {props.toolbarContent}
385
+ </DataViewToolbar>
386
+ )}
387
+ {content}
388
+ </div>
389
+ );
390
+ }
391
+
392
+ return (
393
+ <div
394
+ data-terp="dataview"
395
+ style={{
396
+ display: "grid",
397
+ background: "var(--color-neutral-0)",
398
+ border: "1px solid var(--color-neutral-200)",
399
+ borderRadius: "var(--radius-lg)",
400
+ }}
401
+ >
402
+ <DataViewToolbar<T>
403
+ searchEnabled={props.repository.capabilities.search}
404
+ search={state.search}
405
+ onSearchChange={state.setSearch}
406
+ searchPlaceholder={props.searchPlaceholder}
407
+ searchDebounceMs={props.searchDebounceMs}
408
+ searchScope={props.repository.capabilities.searchScope ? props.searchScope : undefined}
409
+ onClearFilters={props.onClearFilters !== undefined ? clearFilters : undefined}
410
+ hasActiveFilters={hasActiveFilters}
411
+ columnSettings={{
412
+ columns: state.orderedColumns,
413
+ columnVisibility: state.columnVisibility,
414
+ onColumnVisibleChange: state.setColumnVisible,
415
+ onMoveColumn: state.moveColumn,
416
+ }}
417
+ layout={layout}
418
+ onLayoutChange={setManualLayout}
419
+ pageSize={state.pagination.pageSize}
420
+ pageSizeOptions={props.pageSizeOptions}
421
+ onPageSizeChange={(pageSize) =>
422
+ state.setPagination({ pageIndex: 0, pageSize })
423
+ }
424
+ selectedCount={selectedIds.size}
425
+ totalCount={totalCount}
426
+ selectAllAcrossPages={selectAllAcrossPages}
427
+ onSelectAllAcrossPages={
428
+ allPageSelected && totalCount > selectedIds.size
429
+ ? () => setSelectAllAcrossPages(true)
430
+ : undefined
431
+ }
432
+ onClearSelection={clearSelection}
433
+ batchActions={props.batchActions}
434
+ onBatchAction={onBatchAction}
435
+ isFetching={isFetching}
436
+ trailing={props.toolbarTrailing}
437
+ >
438
+ {props.toolbarContent}
439
+ </DataViewToolbar>
440
+ {content}
441
+ <DataViewPagination
442
+ pagination={state.pagination}
443
+ totalCount={totalCount}
444
+ onPaginationChange={state.setPagination}
445
+ />
446
+ </div>
447
+ );
448
+ }
449
+
450
+ /** Fixed-height placeholder rows so the initial load never causes a layout jump. */
451
+ function DataViewSkeleton({ label }: { label: string }) {
452
+ return (
453
+ <div role="status" aria-label={label} style={{ display: "grid", gap: "var(--space-2)", padding: "var(--space-3)" }}>
454
+ {Array.from({ length: 5 }, (_, index) => (
455
+ <div
456
+ key={index}
457
+ aria-hidden
458
+ style={{
459
+ height: "2.75rem",
460
+ background: "var(--color-neutral-100)",
461
+ borderRadius: "var(--radius-md)",
462
+ }}
463
+ />
464
+ ))}
465
+ </div>
466
+ );
467
+ }
@@ -0,0 +1,189 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ import type { UiText } from "../uiText";
4
+
5
+ import { DataViewExpandToggle } from "./DataViewExpandableRow";
6
+ import { DataViewRowActions } from "./DataViewRowActions";
7
+ import { useDataViewText } from "./internal";
8
+ import type { DataViewColumn, DataViewRowAction } from "./types";
9
+
10
+ export interface DataViewCardListProps<T> {
11
+ rows: T[];
12
+ columns: DataViewColumn<T>[];
13
+ getRowId: (row: T) => string;
14
+ onRowClick?: (row: T) => void;
15
+ getRowLabel?: (row: T) => UiText;
16
+ /** Escape hatch for fully custom cards. */
17
+ renderCard?: (row: T) => ReactNode;
18
+ // Selection
19
+ selectionEnabled: boolean;
20
+ isSelected: (rowId: string) => boolean;
21
+ onToggleSelected: (rowId: string) => void;
22
+ // Expansion
23
+ renderExpanded?: (row: T) => ReactNode;
24
+ isExpanded: (rowId: string) => boolean;
25
+ onToggleExpanded: (rowId: string) => void;
26
+ // Actions
27
+ rowActions?: (row: T) => DataViewRowAction<T>[];
28
+ }
29
+
30
+ const recordButtonStyle: CSSProperties = {
31
+ position: "absolute",
32
+ width: 1,
33
+ height: 1,
34
+ padding: 0,
35
+ margin: -1,
36
+ overflow: "hidden",
37
+ clip: "rect(0 0 0 0)",
38
+ whiteSpace: "nowrap",
39
+ border: 0,
40
+ };
41
+
42
+ function slotValue<T>(
43
+ columns: DataViewColumn<T>[],
44
+ row: T,
45
+ slot: "title" | "subtitle" | "status" | "date",
46
+ ): ReactNode {
47
+ const column = columns.find((candidate) => candidate.meta?.mobileSlot === slot);
48
+ if (column === undefined) {
49
+ return null;
50
+ }
51
+ if (column.cell !== undefined) {
52
+ return column.cell(row);
53
+ }
54
+ const value = column.accessor?.(row);
55
+ return value === null || value === undefined ? null : String(value);
56
+ }
57
+
58
+ /**
59
+ * The stacked card layout: each row becomes a card auto-composed from the columns'
60
+ * `mobileSlot` meta (title, subtitle, status, date), with `renderCard` as a full
61
+ * escape hatch. Selection, row actions and expansion keep working in card view
62
+ * (standard actions collapse into the ellipsis menu).
63
+ */
64
+ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
65
+ const { strings, resolve, format } = useDataViewText();
66
+
67
+ return (
68
+ <ul style={{ listStyle: "none", margin: 0, padding: "var(--space-2)", display: "grid", gap: "var(--space-2)" }}>
69
+ {props.rows.map((row) => {
70
+ const rowId = props.getRowId(row);
71
+ const expanded = props.isExpanded(rowId);
72
+ const clickable = props.onRowClick !== undefined;
73
+ return (
74
+ <li key={rowId}>
75
+ <div
76
+ onClick={clickable ? () => props.onRowClick?.(row) : undefined}
77
+ data-terp={clickable ? "dataview-card" : undefined}
78
+ style={{
79
+ display: "grid",
80
+ gap: "var(--space-2)",
81
+ padding: "var(--space-3)",
82
+ background: "var(--color-neutral-0)",
83
+ border: "1px solid var(--color-neutral-200)",
84
+ borderRadius: "var(--radius-lg)",
85
+ boxShadow: "var(--shadow-sm)",
86
+ cursor: clickable ? "pointer" : undefined,
87
+ }}
88
+ >
89
+ {clickable && (
90
+ <button
91
+ type="button"
92
+ data-terp="dataview-row-open"
93
+ aria-label={format(strings.openRow, {
94
+ label: resolve(props.getRowLabel?.(row) ?? ""),
95
+ })}
96
+ onClick={(event) => {
97
+ event.stopPropagation();
98
+ props.onRowClick?.(row);
99
+ }}
100
+ style={recordButtonStyle}
101
+ />
102
+ )}
103
+ <div style={{ display: "flex", alignItems: "flex-start", gap: "var(--space-2)" }}>
104
+ {props.renderExpanded !== undefined && (
105
+ <DataViewExpandToggle
106
+ expanded={expanded}
107
+ onToggle={() => props.onToggleExpanded(rowId)}
108
+ />
109
+ )}
110
+ {props.selectionEnabled && (
111
+ <span onClick={(event) => event.stopPropagation()} style={{ display: "inline-flex" }}>
112
+ <input
113
+ type="checkbox"
114
+ aria-label={resolve(strings.selectRow)}
115
+ checked={props.isSelected(rowId)}
116
+ onChange={() => props.onToggleSelected(rowId)}
117
+ />
118
+ </span>
119
+ )}
120
+ <div style={{ flex: 1, minWidth: 0 }}>
121
+ {props.renderCard !== undefined ? (
122
+ props.renderCard(row)
123
+ ) : (
124
+ <DefaultCardBody row={row} columns={props.columns} />
125
+ )}
126
+ </div>
127
+ {props.rowActions !== undefined && (
128
+ <DataViewRowActions
129
+ row={row}
130
+ actions={props.rowActions(row)}
131
+ layout="menu"
132
+ isMobile
133
+ />
134
+ )}
135
+ </div>
136
+ {expanded && props.renderExpanded !== undefined && (
137
+ <div
138
+ style={{
139
+ borderTop: "1px solid var(--color-neutral-200)",
140
+ paddingTop: "var(--space-2)",
141
+ }}
142
+ >
143
+ {props.renderExpanded(row)}
144
+ </div>
145
+ )}
146
+ </div>
147
+ </li>
148
+ );
149
+ })}
150
+ </ul>
151
+ );
152
+ }
153
+
154
+ function DefaultCardBody<T>({ row, columns }: { row: T; columns: DataViewColumn<T>[] }) {
155
+ const title = slotValue(columns, row, "title");
156
+ const subtitle = slotValue(columns, row, "subtitle");
157
+ const status = slotValue(columns, row, "status");
158
+ const date = slotValue(columns, row, "date");
159
+ return (
160
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
161
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flexWrap: "wrap" }}>
162
+ <span style={{ fontWeight: "var(--font-weight-medium)" as never }}>{title}</span>
163
+ {status !== null && (
164
+ <span
165
+ style={{
166
+ fontSize: "var(--font-size-sm)",
167
+ padding: "0 var(--space-2)",
168
+ background: "var(--color-neutral-100)",
169
+ borderRadius: "var(--radius-full)",
170
+ color: "var(--color-neutral-700)",
171
+ }}
172
+ >
173
+ {status}
174
+ </span>
175
+ )}
176
+ </div>
177
+ {subtitle !== null && (
178
+ <span style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}>
179
+ {subtitle}
180
+ </span>
181
+ )}
182
+ {date !== null && (
183
+ <span style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}>
184
+ {date}
185
+ </span>
186
+ )}
187
+ </div>
188
+ );
189
+ }