@gtivr4/a1-design-system-react 0.1.0 → 0.2.4

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 (111) hide show
  1. package/guidelines/Guidelines.md +228 -0
  2. package/package.json +4 -1
  3. package/src/breakpoints.css +29 -0
  4. package/src/color-scheme.css +586 -24
  5. package/src/components/accordion/Accordion.jsx +80 -0
  6. package/src/components/accordion/accordion.css +118 -0
  7. package/src/components/banner/Banner.jsx +66 -0
  8. package/src/components/banner/banner.css +205 -0
  9. package/src/components/bleed/Bleed.jsx +27 -0
  10. package/src/components/bleed/bleed.css +5 -0
  11. package/src/components/blockquote/Blockquote.jsx +40 -0
  12. package/src/components/blockquote/blockquote.css +166 -0
  13. package/src/components/breadcrumb/Breadcrumb.jsx +82 -0
  14. package/src/components/breadcrumb/breadcrumb.css +133 -0
  15. package/src/components/button/button.css +42 -12
  16. package/src/components/button-container/ButtonContainer.jsx +20 -1
  17. package/src/components/button-container/button-container.css +19 -1
  18. package/src/components/calendar/Calendar.jsx +383 -0
  19. package/src/components/calendar/calendar.css +225 -0
  20. package/src/components/card/Card.jsx +50 -12
  21. package/src/components/card/card.css +178 -14
  22. package/src/components/checkbox-group/CheckboxGroup.jsx +120 -0
  23. package/src/components/checkbox-group/checkbox-group.css +304 -0
  24. package/src/components/cluster/Cluster.jsx +52 -0
  25. package/src/components/cluster/cluster.css +9 -0
  26. package/src/components/code/Code.jsx +135 -0
  27. package/src/components/code/code.css +60 -0
  28. package/src/components/data-table/DataTable.jsx +721 -0
  29. package/src/components/data-table/DataTableFilters.jsx +339 -0
  30. package/src/components/data-table/data-table-filters.css +259 -0
  31. package/src/components/data-table/data-table.css +425 -0
  32. package/src/components/dialog/Dialog.jsx +45 -2
  33. package/src/components/dialog/dialog.css +13 -4
  34. package/src/components/divider/Divider.jsx +64 -0
  35. package/src/components/divider/divider.css +170 -0
  36. package/src/components/field/CreditCardField.jsx +131 -0
  37. package/src/components/field/DateField.jsx +11 -0
  38. package/src/components/field/NumberField.jsx +11 -0
  39. package/src/components/field/PhoneField.jsx +107 -0
  40. package/src/components/field/SelectField.jsx +86 -0
  41. package/src/components/field/TextField.jsx +83 -0
  42. package/src/components/field/TextareaField.jsx +147 -0
  43. package/src/components/field/TimeField.jsx +11 -0
  44. package/src/components/field/ZipField.jsx +114 -0
  45. package/src/components/field/credit-card.css +30 -0
  46. package/src/components/field/field.css +380 -0
  47. package/src/components/field/textarea-field.css +185 -0
  48. package/src/components/field-row/FieldRow.jsx +23 -0
  49. package/src/components/field-row/field-row.css +51 -0
  50. package/src/components/fieldset/Fieldset.jsx +49 -0
  51. package/src/components/fieldset/fieldset.css +75 -0
  52. package/src/components/figure/Figure.jsx +63 -0
  53. package/src/components/figure/figure.css +97 -0
  54. package/src/components/grid/Grid.jsx +36 -2
  55. package/src/components/grid/grid.css +129 -4
  56. package/src/components/heading/Heading.jsx +41 -1
  57. package/src/components/heading/heading.css +65 -4
  58. package/src/components/icon/icon.css +1 -0
  59. package/src/components/icon-button/icon-button.css +1 -0
  60. package/src/components/inline/inline.css +51 -0
  61. package/src/components/inline-editable/InlineEditable.jsx +77 -0
  62. package/src/components/inline-editable/inline-editable.css +47 -0
  63. package/src/components/inset/Inset.jsx +27 -0
  64. package/src/components/inset/inset.css +6 -0
  65. package/src/components/labels/Labels.jsx +5 -5
  66. package/src/components/link/Link.jsx +2 -3
  67. package/src/components/link/link.css +30 -1
  68. package/src/components/list/List.jsx +92 -0
  69. package/src/components/list/list.css +178 -0
  70. package/src/components/menu/Menu.jsx +243 -10
  71. package/src/components/menu/menu.css +157 -17
  72. package/src/components/message/Message.jsx +25 -50
  73. package/src/components/message/message.css +50 -33
  74. package/src/components/notification/Notification.jsx +1 -1
  75. package/src/components/page-layout/PageLayout.jsx +16 -1
  76. package/src/components/page-layout/page-layout.css +97 -4
  77. package/src/components/page-nav/PageNav.jsx +110 -0
  78. package/src/components/page-nav/page-nav.css +167 -0
  79. package/src/components/paragraph/Paragraph.jsx +35 -2
  80. package/src/components/paragraph/paragraph.css +38 -1
  81. package/src/components/radio-group/RadioGroup.jsx +121 -0
  82. package/src/components/radio-group/radio-group.css +268 -0
  83. package/src/components/section/Section.jsx +108 -0
  84. package/src/components/section/section.css +280 -0
  85. package/src/components/segmented-control/SegmentedControl.jsx +4 -0
  86. package/src/components/segmented-control/segmented.css +13 -0
  87. package/src/components/side-nav/SideNav.jsx +29 -9
  88. package/src/components/side-nav/scrim.css +1 -1
  89. package/src/components/side-nav/side-nav.css +70 -32
  90. package/src/components/snackbar/Snackbar.jsx +56 -0
  91. package/src/components/snackbar/snackbar.css +113 -0
  92. package/src/components/spacer/Spacer.jsx +36 -0
  93. package/src/components/spacer/spacer.css +44 -0
  94. package/src/components/stack/Stack.jsx +100 -0
  95. package/src/components/stack/stack.css +37 -0
  96. package/src/components/switch/Switch.jsx +114 -0
  97. package/src/components/switch/switch.css +276 -0
  98. package/src/components/system-banner/SystemBanner.jsx +57 -0
  99. package/src/components/system-banner/system-banner.css +118 -0
  100. package/src/components/tabs/Tabs.jsx +96 -28
  101. package/src/components/tabs/tabs.css +352 -15
  102. package/src/components/token-select/TokenSelect.jsx +159 -0
  103. package/src/components/token-select/token-select.css +110 -0
  104. package/src/components/top-header/TopHeader.jsx +641 -0
  105. package/src/components/top-header/top-header.css +337 -0
  106. package/src/illustrations/ComponentThumbnails.jsx +227 -0
  107. package/src/index.js +41 -5
  108. package/src/themes.css +256 -5
  109. package/src/tokens.css +919 -0
  110. package/src/utilities/spacing.css +8 -0
  111. package/src/utilities/sr-only.css +16 -0
@@ -0,0 +1,721 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Button } from "../button/Button.jsx";
3
+ import { SelectField } from "../field/SelectField.jsx";
4
+ import { Icon } from "../icon/Icon.jsx";
5
+ import { Link } from "../link/Link.jsx";
6
+ import { MessageBadge, MessageEmptyState } from "../message/Message.jsx";
7
+ import { Pagination } from "../pagination/Pagination.jsx";
8
+ import { DataTableFilters } from "./DataTableFilters.jsx";
9
+ import "./data-table.css";
10
+
11
+ /**
12
+ * columns: Array<{
13
+ * key: string,
14
+ * label: string,
15
+ * type?: "text" | "number" | "currency" | "date" | "badge" | "avatar" | "link" | "actions",
16
+ * align?: "start" | "center" | "end",
17
+ * width?: string,
18
+ * sortable?: boolean,
19
+ * sortAccessor?: (row: Record<string, any>) => any,
20
+ * statusMap?: Record<string, "neutral"|"info"|"success"|"warn"|"error">,
21
+ * currencySymbol?: string,
22
+ * }>
23
+ * rows: Array<Record<string, any>>
24
+ * getRowId?: (row: Record<string, any>, index: number) => string | number
25
+ * density?: "auto" | "comfortable" | "default" | "compact"
26
+ * "auto" (default) — switches between densities based on available width and column definitions
27
+ */
28
+
29
+ // Estimated minimum content width per column type at a "neutral" padding level
30
+ const COL_BASE_WIDTH = {
31
+ avatar: 160, // avatar circle + name text
32
+ date: 110, // "Jan 12, 2026"
33
+ actions: 120, // one or two compact buttons
34
+ link: 120, // linked text
35
+ text: 120, // generic text — assume moderate length
36
+ badge: 95, // short label in a chip
37
+ currency: 85, // $XX,XXX
38
+ number: 75, // numeric digits
39
+ };
40
+
41
+ // Extra horizontal padding added per column at each density (both sides)
42
+ const DENSITY_PADDING = {
43
+ comfortable: 40, // 20px × 2
44
+ default: 32, // 16px × 2
45
+ compact: 24, // 12px × 2
46
+ };
47
+
48
+ function minWidthForDensity(columns, density) {
49
+ return columns.reduce((sum, col) => {
50
+ const content = col.width
51
+ ? parseFloat(col.width) || COL_BASE_WIDTH.text
52
+ : (COL_BASE_WIDTH[col.type] ?? COL_BASE_WIDTH.text);
53
+ return sum + content + DENSITY_PADDING[density];
54
+ }, 0);
55
+ }
56
+
57
+ function normalizeSort(sort) {
58
+ if (!sort?.key) return null;
59
+ return {
60
+ key: sort.key,
61
+ direction: sort.direction === "desc" ? "desc" : "asc",
62
+ };
63
+ }
64
+
65
+ function getSortValue(row, col) {
66
+ const value = typeof col.sortAccessor === "function"
67
+ ? col.sortAccessor(row)
68
+ : row[col.key];
69
+
70
+ if (value == null || value === "") return null;
71
+
72
+ if (col.type === "number" || col.type === "currency" || col.numeric) {
73
+ const num = typeof value === "number"
74
+ ? value
75
+ : parseFloat(String(value).replace(/[^0-9.-]/g, ""));
76
+ return Number.isNaN(num) ? value : num;
77
+ }
78
+
79
+ if (col.type === "date") {
80
+ const time = value instanceof Date ? value.getTime() : Date.parse(value);
81
+ return Number.isNaN(time) ? value : time;
82
+ }
83
+
84
+ return value;
85
+ }
86
+
87
+ function compareSortValues(a, b) {
88
+ if (a == null && b == null) return 0;
89
+ if (a == null) return 1;
90
+ if (b == null) return -1;
91
+
92
+ if (typeof a === "number" && typeof b === "number") {
93
+ return a - b;
94
+ }
95
+
96
+ return String(a).localeCompare(String(b), undefined, {
97
+ numeric: true,
98
+ sensitivity: "base",
99
+ });
100
+ }
101
+
102
+ function defaultGetRowId(row, index) {
103
+ return row.id ?? row.key ?? row.name ?? index;
104
+ }
105
+
106
+ function normalizeRowIds(ids) {
107
+ return (ids ?? []).map((id) => String(id));
108
+ }
109
+
110
+ function normalizeFilterValue(filters, value = {}) {
111
+ return filters.reduce((next, filter) => {
112
+ next[filter.key] = value[filter.key] ?? (filter.type === "multi" ? [] : "");
113
+ return next;
114
+ }, {});
115
+ }
116
+
117
+ function filterMatches(rowValue, selectedValue, type) {
118
+ if (type === "multi") {
119
+ const selected = Array.isArray(selectedValue) ? selectedValue : [];
120
+ if (selected.length === 0) return true;
121
+ if (Array.isArray(rowValue)) return selected.some((value) => rowValue.includes(value));
122
+ return selected.includes(rowValue);
123
+ }
124
+
125
+ if (!selectedValue) return true;
126
+ if (Array.isArray(rowValue)) return rowValue.includes(selectedValue);
127
+ return rowValue === selectedValue;
128
+ }
129
+
130
+ function getSearchValue(row, column) {
131
+ if (typeof column.searchAccessor === "function") return column.searchAccessor(row);
132
+ return row[column.key];
133
+ }
134
+
135
+ function applyFiltersAndSearch(rows, filters, filterValue, searchValue, searchColumn, searchableColumns, columns) {
136
+ let result = rows;
137
+
138
+ if (filters.length > 0) {
139
+ result = result.filter((row) =>
140
+ filters.every((filter) => filterMatches(row[filter.key], filterValue[filter.key], filter.type))
141
+ );
142
+ }
143
+
144
+ const query = String(searchValue ?? "").trim().toLowerCase().replace(/\s+/g, "_");
145
+ if (!query) return result;
146
+
147
+ const searchColumns = searchableColumns?.length > 0
148
+ ? searchableColumns
149
+ : columns.map((column) => ({ key: column.key, label: column.label }));
150
+
151
+ return result.filter((row) => {
152
+ if (searchColumn) {
153
+ const column = searchColumns.find((item) => item.key === searchColumn) ?? { key: searchColumn };
154
+ return String(getSearchValue(row, column) ?? "").toLowerCase().includes(query);
155
+ }
156
+
157
+ return searchColumns.some((column) =>
158
+ String(getSearchValue(row, column) ?? "").toLowerCase().includes(query)
159
+ );
160
+ });
161
+ }
162
+
163
+ function isInteractiveColumn(col) {
164
+ return col.type === "link" || col.type === "actions";
165
+ }
166
+
167
+ function hasInteractiveValue(value) {
168
+ if (Array.isArray(value)) return value.length > 0;
169
+ return value != null && value !== "";
170
+ }
171
+
172
+ function SelectionCheckbox({ checked, indeterminate = false, label, onChange }) {
173
+ const ref = useRef(null);
174
+
175
+ useEffect(() => {
176
+ if (ref.current) ref.current.indeterminate = indeterminate;
177
+ }, [indeterminate]);
178
+
179
+ return (
180
+ <input
181
+ ref={ref}
182
+ type="checkbox"
183
+ className="a1-data-table__checkbox"
184
+ checked={checked}
185
+ aria-label={label}
186
+ onChange={(event) => onChange(event.target.checked)}
187
+ />
188
+ );
189
+ }
190
+
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+
193
+ export function DataTable({
194
+ columns = [],
195
+ rows = [],
196
+ density = "default",
197
+ zebra = false,
198
+ scrollable = false,
199
+ caption,
200
+ page,
201
+ defaultPage = 1,
202
+ pageSize,
203
+ totalPages,
204
+ totalRows,
205
+ onPageChange,
206
+ sort,
207
+ defaultSort,
208
+ onSortChange,
209
+ filters = [],
210
+ filterValue,
211
+ defaultFilterValue = {},
212
+ onFilterChange,
213
+ searchValue,
214
+ defaultSearchValue = "",
215
+ onSearchChange,
216
+ searchColumn,
217
+ defaultSearchColumn = "",
218
+ onSearchColumnChange,
219
+ searchableColumns,
220
+ selectable = false,
221
+ selectedRowIds,
222
+ defaultSelectedRowIds = [],
223
+ onSelectedRowIdsChange,
224
+ onDeleteSelected,
225
+ getRowId = defaultGetRowId,
226
+ emptyTitle = "No results",
227
+ emptyDescription,
228
+ emptyIcon = "inbox",
229
+ className = "",
230
+ ...props
231
+ }) {
232
+ const wrapperRef = useRef(null);
233
+ const [autoDensity, setAutoDensity] = useState("default");
234
+ const [internalSort, setInternalSort] = useState(() => normalizeSort(defaultSort));
235
+ const [internalPage, setInternalPage] = useState(defaultPage);
236
+ const [internalFilterValue, setInternalFilterValue] = useState(() => normalizeFilterValue(filters, defaultFilterValue));
237
+ const [internalSearchValue, setInternalSearchValue] = useState(defaultSearchValue);
238
+ const [internalSearchColumn, setInternalSearchColumn] = useState(defaultSearchColumn);
239
+ const [internalSelectedRowIds, setInternalSelectedRowIds] = useState(() => normalizeRowIds(defaultSelectedRowIds));
240
+
241
+ const isAuto = density === "auto";
242
+ const isSortControlled = sort !== undefined;
243
+ const isPageControlled = page !== undefined;
244
+ const isFilterControlled = filterValue !== undefined;
245
+ const isSearchControlled = searchValue !== undefined;
246
+ const isSearchColumnControlled = searchColumn !== undefined;
247
+ const isSelectionControlled = selectedRowIds !== undefined;
248
+ const activeDensity = isAuto ? autoDensity : density;
249
+ const activeSort = isSortControlled ? normalizeSort(sort) : internalSort;
250
+ const activePage = isPageControlled ? page : internalPage;
251
+ const activeFilterValue = isFilterControlled
252
+ ? normalizeFilterValue(filters, filterValue)
253
+ : normalizeFilterValue(filters, internalFilterValue);
254
+ const activeSearchValue = isSearchControlled ? searchValue : internalSearchValue;
255
+ const activeSearchColumn = isSearchColumnControlled ? searchColumn : internalSearchColumn;
256
+ const activeSelectedRowIds = isSelectionControlled
257
+ ? normalizeRowIds(selectedRowIds)
258
+ : internalSelectedRowIds;
259
+ const selectedRowIdSet = new Set(activeSelectedRowIds);
260
+
261
+ // Compute the density that best fits the current container width
262
+ useEffect(() => {
263
+ if (!isAuto) return;
264
+ const el = wrapperRef.current;
265
+ if (!el) return;
266
+
267
+ const comfortable = minWidthForDensity(columns, "comfortable");
268
+ const dflt = minWidthForDensity(columns, "default");
269
+
270
+ const pick = (width) => {
271
+ if (width >= comfortable) return "comfortable";
272
+ if (width >= dflt) return "default";
273
+ return "compact";
274
+ };
275
+
276
+ const ro = new ResizeObserver(([entry]) => {
277
+ setAutoDensity(pick(entry.contentRect.width));
278
+ });
279
+ ro.observe(el);
280
+ return () => ro.disconnect();
281
+ }, [isAuto, columns]);
282
+
283
+ // ── Derived values ──────────────────────────────────────────────────────
284
+
285
+ const tableClass = [
286
+ "a1-data-table",
287
+ activeDensity !== "default" && `a1-data-table--${activeDensity}`,
288
+ zebra && "a1-data-table--zebra",
289
+ className,
290
+ ]
291
+ .filter(Boolean)
292
+ .join(" ");
293
+
294
+ const filteredRows = applyFiltersAndSearch(
295
+ rows,
296
+ filters,
297
+ activeFilterValue,
298
+ activeSearchValue,
299
+ activeSearchColumn,
300
+ searchableColumns,
301
+ columns
302
+ );
303
+ const sortableColumns = columns.filter((col) => col.sortable);
304
+ const sortedRows = activeSort
305
+ ? [...filteredRows].sort((a, b) => {
306
+ const col = columns.find((column) => column.key === activeSort.key);
307
+ if (!col) return 0;
308
+
309
+ const result = compareSortValues(getSortValue(a, col), getSortValue(b, col));
310
+ return activeSort.direction === "desc" ? -result : result;
311
+ })
312
+ : filteredRows;
313
+ const hasInternalPagination = Number.isFinite(pageSize) && pageSize > 0;
314
+ const resolvedTotalPages = hasInternalPagination
315
+ ? Math.max(1, Math.ceil(sortedRows.length / pageSize))
316
+ : totalPages;
317
+ const clampedPage = resolvedTotalPages != null
318
+ ? Math.min(Math.max(activePage ?? 1, 1), resolvedTotalPages)
319
+ : activePage;
320
+ const paginatedRows = hasInternalPagination
321
+ ? sortedRows.slice((clampedPage - 1) * pageSize, clampedPage * pageSize)
322
+ : sortedRows;
323
+ const showPagination = resolvedTotalPages != null && resolvedTotalPages > 1;
324
+ const rowStart = hasInternalPagination
325
+ ? (paginatedRows.length > 0 ? (clampedPage - 1) * pageSize + 1 : 0)
326
+ : (page != null ? (page - 1) * filteredRows.length + 1 : 1);
327
+ const rowEnd = hasInternalPagination
328
+ ? (paginatedRows.length > 0 ? rowStart + paginatedRows.length - 1 : 0)
329
+ : (page != null ? rowStart + filteredRows.length - 1 : filteredRows.length);
330
+ const knownTotal = hasInternalPagination
331
+ ? sortedRows.length
332
+ : (totalRows ?? (showPagination ? totalPages * filteredRows.length : filteredRows.length));
333
+ const visibleRowEntries = paginatedRows.map((row, index) => ({
334
+ row,
335
+ index: hasInternalPagination ? (clampedPage - 1) * pageSize + index : index,
336
+ id: String(getRowId(row, hasInternalPagination ? (clampedPage - 1) * pageSize + index : index)),
337
+ supportsRowClickSelection: selectable && !columns.some((col) =>
338
+ isInteractiveColumn(col) && hasInteractiveValue(row[col.key])
339
+ ),
340
+ }));
341
+ const selectedRows = visibleRowEntries
342
+ .filter((entry) => selectedRowIdSet.has(entry.id))
343
+ .map((entry) => entry.row);
344
+ const selectedCount = activeSelectedRowIds.length;
345
+ const allVisibleSelected = visibleRowEntries.length > 0
346
+ && visibleRowEntries.every((entry) => selectedRowIdSet.has(entry.id));
347
+ const someVisibleSelected = visibleRowEntries.some((entry) => selectedRowIdSet.has(entry.id));
348
+
349
+ function updatePage(nextPage) {
350
+ const normalized = resolvedTotalPages != null
351
+ ? Math.min(Math.max(nextPage, 1), resolvedTotalPages)
352
+ : Math.max(nextPage, 1);
353
+ if (!isPageControlled) setInternalPage(normalized);
354
+ onPageChange?.(normalized);
355
+ }
356
+
357
+ function resetPage() {
358
+ updatePage(1);
359
+ }
360
+
361
+ function updateSort(nextSort) {
362
+ if (!isSortControlled) setInternalSort(nextSort);
363
+ onSortChange?.(nextSort);
364
+ resetPage();
365
+ }
366
+
367
+ function updateFilterValue(nextValue) {
368
+ const normalized = normalizeFilterValue(filters, nextValue);
369
+ if (!isFilterControlled) setInternalFilterValue(normalized);
370
+ onFilterChange?.(normalized);
371
+ resetPage();
372
+ }
373
+
374
+ function updateSearchValue(nextValue) {
375
+ if (!isSearchControlled) setInternalSearchValue(nextValue);
376
+ onSearchChange?.(nextValue);
377
+ resetPage();
378
+ }
379
+
380
+ function updateSearchColumn(nextValue) {
381
+ if (!isSearchColumnControlled) setInternalSearchColumn(nextValue);
382
+ onSearchColumnChange?.(nextValue);
383
+ resetPage();
384
+ }
385
+
386
+ function updateSelectedRowIds(nextIds) {
387
+ const normalized = normalizeRowIds(nextIds);
388
+ if (!isSelectionControlled) setInternalSelectedRowIds(normalized);
389
+ onSelectedRowIdsChange?.(normalized);
390
+ }
391
+
392
+ function toggleColumnSort(col) {
393
+ const nextDirection = activeSort?.key === col.key && activeSort.direction === "asc"
394
+ ? "desc"
395
+ : "asc";
396
+ updateSort({ key: col.key, direction: nextDirection });
397
+ }
398
+
399
+ function handleMobileSortChange(event) {
400
+ const value = event.target.value;
401
+ if (!value) {
402
+ updateSort(null);
403
+ return;
404
+ }
405
+
406
+ const [key, direction] = value.split(":");
407
+ updateSort({ key, direction });
408
+ }
409
+
410
+ function toggleRowSelected(rowId, checked) {
411
+ const next = checked
412
+ ? [...new Set([...activeSelectedRowIds, rowId])]
413
+ : activeSelectedRowIds.filter((id) => id !== rowId);
414
+ updateSelectedRowIds(next);
415
+ }
416
+
417
+ function toggleAllVisible(checked) {
418
+ const visibleIds = visibleRowEntries.map((entry) => entry.id);
419
+ const next = checked
420
+ ? [...new Set([...activeSelectedRowIds, ...visibleIds])]
421
+ : activeSelectedRowIds.filter((id) => !visibleIds.includes(id));
422
+ updateSelectedRowIds(next);
423
+ }
424
+
425
+ function handleDeleteSelected() {
426
+ onDeleteSelected?.(selectedRows, activeSelectedRowIds);
427
+ updateSelectedRowIds([]);
428
+ }
429
+
430
+ function handleRowClick(rowId, supportsRowClickSelection, event) {
431
+ if (!supportsRowClickSelection) return;
432
+ if (event.target.closest("a, button, input, select, textarea, label")) return;
433
+ toggleRowSelected(rowId, !selectedRowIdSet.has(rowId));
434
+ }
435
+
436
+ // ── Cell rendering ──────────────────────────────────────────────────────
437
+
438
+ function renderCell(col, value) {
439
+ if (value == null || value === "") return "—";
440
+
441
+ switch (col.type) {
442
+ case "avatar": {
443
+ const initials = String(value)
444
+ .split(" ")
445
+ .slice(0, 2)
446
+ .map((w) => w[0])
447
+ .join("")
448
+ .toUpperCase();
449
+ return (
450
+ <span className="a1-data-table__avatar-cell">
451
+ <span className="a1-data-table__avatar" aria-hidden="true">{initials}</span>
452
+ <span>{value}</span>
453
+ </span>
454
+ );
455
+ }
456
+
457
+ case "badge": {
458
+ const status = col.statusMap?.[value] ?? "neutral";
459
+ const compact = activeDensity === "compact";
460
+ return (
461
+ <MessageBadge
462
+ status={status}
463
+ subtle
464
+ size={compact ? "sm" : undefined}
465
+ icon={compact ? null : undefined}
466
+ >
467
+ {value}
468
+ </MessageBadge>
469
+ );
470
+ }
471
+
472
+ case "link": {
473
+ const config = typeof value === "object"
474
+ ? value
475
+ : { href: value, label: value };
476
+ const href = config.href ?? "#";
477
+ return (
478
+ <Link
479
+ href={href}
480
+ icon={config.icon}
481
+ iconPosition={config.iconPosition ?? "end"}
482
+ target={config.target}
483
+ rel={config.rel ?? (config.target === "_blank" ? "noreferrer" : undefined)}
484
+ >
485
+ {config.label ?? href}
486
+ </Link>
487
+ );
488
+ }
489
+
490
+ case "actions": {
491
+ const actions = Array.isArray(value) ? value : [value];
492
+ return (
493
+ <span className="a1-data-table__actions">
494
+ {actions.filter(Boolean).map((action, index) => (
495
+ <Button
496
+ key={`${action.label ?? action.icon ?? "action"}-${index}`}
497
+ variant="tertiary"
498
+ size="sm"
499
+ icon={action.icon}
500
+ iconPosition={action.iconPosition ?? "start"}
501
+ disabled={action.disabled}
502
+ onClick={action.onClick}
503
+ >
504
+ {action.label}
505
+ </Button>
506
+ ))}
507
+ </span>
508
+ );
509
+ }
510
+
511
+ case "currency": {
512
+ const symbol = col.currencySymbol ?? "$";
513
+ const num = typeof value === "number"
514
+ ? value
515
+ : parseFloat(String(value).replace(/[^0-9.-]/g, ""));
516
+ return isNaN(num)
517
+ ? value
518
+ : `${symbol}${num.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
519
+ }
520
+
521
+ case "number": {
522
+ const num = typeof value === "number" ? value : parseFloat(value);
523
+ return isNaN(num) ? value : num.toLocaleString("en-US");
524
+ }
525
+
526
+ default:
527
+ return value;
528
+ }
529
+ }
530
+
531
+ function getAlign(col) {
532
+ if (col.align) return col.align;
533
+ if (col.type === "number" || col.type === "currency" || col.numeric) return "end";
534
+ return "start";
535
+ }
536
+
537
+ function getSortIcon(col) {
538
+ if (activeSort?.key !== col.key) return "unfold_more";
539
+ return activeSort.direction === "desc" ? "arrow_downward" : "arrow_upward";
540
+ }
541
+
542
+ function getSortAria(col) {
543
+ if (!col.sortable) return undefined;
544
+ if (activeSort?.key !== col.key) return "none";
545
+ return activeSort.direction === "desc" ? "descending" : "ascending";
546
+ }
547
+
548
+ // ── Render ──────────────────────────────────────────────────────────────
549
+
550
+ return (
551
+ <div ref={wrapperRef} className="a1-data-table-wrapper" {...props}>
552
+ {(filters.length > 0 || searchableColumns?.length > 0 || onSearchChange || searchValue !== undefined) && (
553
+ <DataTableFilters
554
+ filters={filters}
555
+ value={activeFilterValue}
556
+ onChange={updateFilterValue}
557
+ searchValue={activeSearchValue}
558
+ onSearchChange={updateSearchValue}
559
+ searchColumn={activeSearchColumn}
560
+ onSearchColumnChange={updateSearchColumn}
561
+ searchableColumns={searchableColumns}
562
+ />
563
+ )}
564
+ {selectable && selectedCount > 0 && (
565
+ <div className="a1-data-table-bulk-actions" role="region" aria-label="Bulk actions">
566
+ <span className="a1-data-table-bulk-actions__count">
567
+ {selectedCount} selected
568
+ </span>
569
+ <div className="a1-data-table-bulk-actions__controls">
570
+ <Button
571
+ variant="tertiary"
572
+ size="sm"
573
+ onClick={() => updateSelectedRowIds([])}
574
+ >
575
+ Clear
576
+ </Button>
577
+ {onDeleteSelected && (
578
+ <Button
579
+ variant="destructive"
580
+ size="sm"
581
+ icon="delete"
582
+ onClick={handleDeleteSelected}
583
+ >
584
+ Delete
585
+ </Button>
586
+ )}
587
+ </div>
588
+ </div>
589
+ )}
590
+ {sortableColumns.length > 0 && (
591
+ <div className="a1-data-table-sort">
592
+ <SelectField
593
+ label="Sort"
594
+ size="compact"
595
+ value={activeSort ? `${activeSort.key}:${activeSort.direction}` : ""}
596
+ onChange={handleMobileSortChange}
597
+ >
598
+ <option value="">No sorting</option>
599
+ {sortableColumns.flatMap((col) => [
600
+ <option key={`${col.key}:asc`} value={`${col.key}:asc`}>
601
+ {col.label} ascending
602
+ </option>,
603
+ <option key={`${col.key}:desc`} value={`${col.key}:desc`}>
604
+ {col.label} descending
605
+ </option>,
606
+ ])}
607
+ </SelectField>
608
+ </div>
609
+ )}
610
+ <div className={["a1-data-table-scroll", scrollable && "a1-data-table-scroll--scrollable"].filter(Boolean).join(" ")} tabIndex={scrollable ? 0 : undefined}>
611
+ {filteredRows.length === 0 ? (
612
+ <div className="a1-data-table__empty">
613
+ <MessageEmptyState
614
+ scale="card"
615
+ icon={emptyIcon}
616
+ title={emptyTitle}
617
+ description={emptyDescription}
618
+ />
619
+ </div>
620
+ ) : (
621
+ <table className={tableClass}>
622
+ {caption && <caption>{caption}</caption>}
623
+ <thead>
624
+ <tr>
625
+ {selectable && (
626
+ <th
627
+ scope="col"
628
+ className="a1-data-table__select-header"
629
+ >
630
+ <SelectionCheckbox
631
+ checked={allVisibleSelected}
632
+ indeterminate={someVisibleSelected && !allVisibleSelected}
633
+ label={allVisibleSelected ? "Deselect all rows" : "Select all rows"}
634
+ onChange={toggleAllVisible}
635
+ />
636
+ </th>
637
+ )}
638
+ {columns.map((col) => (
639
+ <th
640
+ key={col.key}
641
+ scope="col"
642
+ aria-sort={getSortAria(col)}
643
+ data-align={getAlign(col)}
644
+ style={col.width ? { width: col.width } : undefined}
645
+ >
646
+ {col.sortable ? (
647
+ <button
648
+ type="button"
649
+ className="a1-data-table__sort-button"
650
+ onClick={() => toggleColumnSort(col)}
651
+ >
652
+ <span>{col.label}</span>
653
+ <Icon
654
+ name={getSortIcon(col)}
655
+ className="a1-data-table__sort-icon"
656
+ />
657
+ </button>
658
+ ) : col.label}
659
+ </th>
660
+ ))}
661
+ </tr>
662
+ </thead>
663
+ <tbody>
664
+ {visibleRowEntries.map(({ row, index: rowIndex, id: rowId, supportsRowClickSelection }) => {
665
+ const isSelected = selectedRowIdSet.has(rowId);
666
+ return (
667
+ <tr
668
+ key={rowId}
669
+ data-selected={isSelected ? "true" : undefined}
670
+ data-selectable-row={supportsRowClickSelection ? "true" : undefined}
671
+ onClick={(event) => handleRowClick(rowId, supportsRowClickSelection, event)}
672
+ >
673
+ {selectable && (
674
+ <td
675
+ className="a1-data-table__select-cell"
676
+ data-label="Select"
677
+ >
678
+ <SelectionCheckbox
679
+ checked={isSelected}
680
+ label={`Select row ${rowIndex + 1}`}
681
+ onChange={(checked) => toggleRowSelected(rowId, checked)}
682
+ />
683
+ </td>
684
+ )}
685
+ {columns.map((col) => (
686
+ <td
687
+ key={col.key}
688
+ data-label={col.label}
689
+ data-align={getAlign(col)}
690
+ >
691
+ {renderCell(col, row[col.key])}
692
+ </td>
693
+ ))}
694
+ </tr>
695
+ );
696
+ })}
697
+ </tbody>
698
+ </table>
699
+ )}
700
+ </div>
701
+
702
+ {(showPagination || filteredRows.length > 0) && (
703
+ <div className="a1-data-table-footer">
704
+ <span className="a1-data-table-footer__count">
705
+ {showPagination
706
+ ? `Showing ${rowStart}–${rowEnd} of ${knownTotal} results`
707
+ : `${filteredRows.length} ${filteredRows.length === 1 ? "result" : "results"}`}
708
+ </span>
709
+ {showPagination && (
710
+ <Pagination
711
+ page={clampedPage}
712
+ totalPages={resolvedTotalPages}
713
+ onChange={updatePage}
714
+ size="sm"
715
+ />
716
+ )}
717
+ </div>
718
+ )}
719
+ </div>
720
+ );
721
+ }