@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.
- package/guidelines/Guidelines.md +228 -0
- package/package.json +4 -1
- package/src/breakpoints.css +29 -0
- package/src/color-scheme.css +586 -24
- package/src/components/accordion/Accordion.jsx +80 -0
- package/src/components/accordion/accordion.css +118 -0
- package/src/components/banner/Banner.jsx +66 -0
- package/src/components/banner/banner.css +205 -0
- package/src/components/bleed/Bleed.jsx +27 -0
- package/src/components/bleed/bleed.css +5 -0
- package/src/components/blockquote/Blockquote.jsx +40 -0
- package/src/components/blockquote/blockquote.css +166 -0
- package/src/components/breadcrumb/Breadcrumb.jsx +82 -0
- package/src/components/breadcrumb/breadcrumb.css +133 -0
- package/src/components/button/button.css +42 -12
- package/src/components/button-container/ButtonContainer.jsx +20 -1
- package/src/components/button-container/button-container.css +19 -1
- package/src/components/calendar/Calendar.jsx +383 -0
- package/src/components/calendar/calendar.css +225 -0
- package/src/components/card/Card.jsx +50 -12
- package/src/components/card/card.css +178 -14
- package/src/components/checkbox-group/CheckboxGroup.jsx +120 -0
- package/src/components/checkbox-group/checkbox-group.css +304 -0
- package/src/components/cluster/Cluster.jsx +52 -0
- package/src/components/cluster/cluster.css +9 -0
- package/src/components/code/Code.jsx +135 -0
- package/src/components/code/code.css +60 -0
- package/src/components/data-table/DataTable.jsx +721 -0
- package/src/components/data-table/DataTableFilters.jsx +339 -0
- package/src/components/data-table/data-table-filters.css +259 -0
- package/src/components/data-table/data-table.css +425 -0
- package/src/components/dialog/Dialog.jsx +45 -2
- package/src/components/dialog/dialog.css +13 -4
- package/src/components/divider/Divider.jsx +64 -0
- package/src/components/divider/divider.css +170 -0
- package/src/components/field/CreditCardField.jsx +131 -0
- package/src/components/field/DateField.jsx +11 -0
- package/src/components/field/NumberField.jsx +11 -0
- package/src/components/field/PhoneField.jsx +107 -0
- package/src/components/field/SelectField.jsx +86 -0
- package/src/components/field/TextField.jsx +83 -0
- package/src/components/field/TextareaField.jsx +147 -0
- package/src/components/field/TimeField.jsx +11 -0
- package/src/components/field/ZipField.jsx +114 -0
- package/src/components/field/credit-card.css +30 -0
- package/src/components/field/field.css +380 -0
- package/src/components/field/textarea-field.css +185 -0
- package/src/components/field-row/FieldRow.jsx +23 -0
- package/src/components/field-row/field-row.css +51 -0
- package/src/components/fieldset/Fieldset.jsx +49 -0
- package/src/components/fieldset/fieldset.css +75 -0
- package/src/components/figure/Figure.jsx +63 -0
- package/src/components/figure/figure.css +97 -0
- package/src/components/grid/Grid.jsx +36 -2
- package/src/components/grid/grid.css +129 -4
- package/src/components/heading/Heading.jsx +41 -1
- package/src/components/heading/heading.css +65 -4
- package/src/components/icon/icon.css +1 -0
- package/src/components/icon-button/icon-button.css +1 -0
- package/src/components/inline/inline.css +51 -0
- package/src/components/inline-editable/InlineEditable.jsx +77 -0
- package/src/components/inline-editable/inline-editable.css +47 -0
- package/src/components/inset/Inset.jsx +27 -0
- package/src/components/inset/inset.css +6 -0
- package/src/components/labels/Labels.jsx +5 -5
- package/src/components/link/Link.jsx +2 -3
- package/src/components/link/link.css +30 -1
- package/src/components/list/List.jsx +92 -0
- package/src/components/list/list.css +178 -0
- package/src/components/menu/Menu.jsx +243 -10
- package/src/components/menu/menu.css +157 -17
- package/src/components/message/Message.jsx +25 -50
- package/src/components/message/message.css +50 -33
- package/src/components/notification/Notification.jsx +1 -1
- package/src/components/page-layout/PageLayout.jsx +16 -1
- package/src/components/page-layout/page-layout.css +97 -4
- package/src/components/page-nav/PageNav.jsx +110 -0
- package/src/components/page-nav/page-nav.css +167 -0
- package/src/components/paragraph/Paragraph.jsx +35 -2
- package/src/components/paragraph/paragraph.css +38 -1
- package/src/components/radio-group/RadioGroup.jsx +121 -0
- package/src/components/radio-group/radio-group.css +268 -0
- package/src/components/section/Section.jsx +108 -0
- package/src/components/section/section.css +280 -0
- package/src/components/segmented-control/SegmentedControl.jsx +4 -0
- package/src/components/segmented-control/segmented.css +13 -0
- package/src/components/side-nav/SideNav.jsx +29 -9
- package/src/components/side-nav/scrim.css +1 -1
- package/src/components/side-nav/side-nav.css +70 -32
- package/src/components/snackbar/Snackbar.jsx +56 -0
- package/src/components/snackbar/snackbar.css +113 -0
- package/src/components/spacer/Spacer.jsx +36 -0
- package/src/components/spacer/spacer.css +44 -0
- package/src/components/stack/Stack.jsx +100 -0
- package/src/components/stack/stack.css +37 -0
- package/src/components/switch/Switch.jsx +114 -0
- package/src/components/switch/switch.css +276 -0
- package/src/components/system-banner/SystemBanner.jsx +57 -0
- package/src/components/system-banner/system-banner.css +118 -0
- package/src/components/tabs/Tabs.jsx +96 -28
- package/src/components/tabs/tabs.css +352 -15
- package/src/components/token-select/TokenSelect.jsx +159 -0
- package/src/components/token-select/token-select.css +110 -0
- package/src/components/top-header/TopHeader.jsx +641 -0
- package/src/components/top-header/top-header.css +337 -0
- package/src/illustrations/ComponentThumbnails.jsx +227 -0
- package/src/index.js +41 -5
- package/src/themes.css +256 -5
- package/src/tokens.css +919 -0
- package/src/utilities/spacing.css +8 -0
- 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
|
+
}
|