@mshuja288/aksuicore 0.2.1 → 0.4.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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/aksui.css +386 -6
- package/dist/index.cjs +496 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +53 -7
- package/dist/index.d.ts +53 -7
- package/dist/index.js +496 -66
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/patterns/AppShell.tsx +140 -17
- package/src/patterns/RecordWorkspace.tsx +426 -43
- package/src/patterns/index.ts +8 -2
- package/src/primitives/SideNav.tsx +3 -1
- package/src/primitives/Topbar.tsx +38 -8
- package/src/theme/patterns.css +223 -0
- package/src/theme/primitives.css +9 -1
- package/src/theme/shell.css +154 -5
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type MouseEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
4
12
|
import { createPortal } from "react-dom";
|
|
5
13
|
import { Pagination } from "../primitives/Pagination";
|
|
6
14
|
import { Select, type SelectOption } from "../primitives/Select";
|
|
@@ -23,13 +31,67 @@ export type TableColumn<T> = {
|
|
|
23
31
|
/** When set, filter uses an exact-match Select instead of text contains. */
|
|
24
32
|
filterOptions?: SelectOption[];
|
|
25
33
|
filterPlaceholder?: string;
|
|
34
|
+
/** When false, column starts hidden. Default true. */
|
|
35
|
+
defaultVisible?: boolean;
|
|
36
|
+
/** Key sent to export APIs when different from `key` (e.g. PascalCase property name). */
|
|
37
|
+
exportKey?: string;
|
|
26
38
|
};
|
|
27
39
|
|
|
40
|
+
/** Formats supported by the shared table download control (matches akscore TableExport). */
|
|
41
|
+
export type ExportDownloadFormat = "csv" | "xlsx" | "json";
|
|
42
|
+
|
|
43
|
+
/** Column descriptors passed to `onDownload` (visible columns only). */
|
|
44
|
+
export type ExportColumnRef = {
|
|
45
|
+
key: string;
|
|
46
|
+
header: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const EXPORT_FORMAT_OPTIONS: { format: ExportDownloadFormat; label: string }[] = [
|
|
50
|
+
{ format: "csv", label: "CSV" },
|
|
51
|
+
{ format: "xlsx", label: "Excel" },
|
|
52
|
+
{ format: "json", label: "JSON" },
|
|
53
|
+
];
|
|
54
|
+
|
|
28
55
|
type SortDirection = "asc" | "desc";
|
|
29
56
|
type SortState = { key: string; direction: SortDirection };
|
|
30
57
|
type DetailLine = { label: string; value: string };
|
|
31
58
|
type TooltipState = { x: number; y: number; lines: DetailLine[] };
|
|
32
59
|
|
|
60
|
+
function defaultVisibleKeys<T>(columns: TableColumn<T>[]) {
|
|
61
|
+
return columns.filter((column) => column.defaultVisible !== false).map((column) => column.key);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readStoredColumnKeys(prefsKey: string | undefined, fallback: string[], allowed: Set<string>) {
|
|
65
|
+
if (!prefsKey || typeof window === "undefined") {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const raw = window.localStorage.getItem(prefsKey);
|
|
70
|
+
if (!raw) {
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
74
|
+
if (!Array.isArray(parsed)) {
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
const keys = parsed.filter((entry): entry is string => typeof entry === "string" && allowed.has(entry));
|
|
78
|
+
return keys.length > 0 ? keys : fallback;
|
|
79
|
+
} catch {
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeStoredColumnKeys(prefsKey: string | undefined, keys: string[]) {
|
|
85
|
+
if (!prefsKey || typeof window === "undefined") {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
window.localStorage.setItem(prefsKey, JSON.stringify(keys));
|
|
90
|
+
} catch {
|
|
91
|
+
/* ignore quota / private mode */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
33
95
|
function compareColumnValues<T>(column: TableColumn<T>, left: T, right: T) {
|
|
34
96
|
const leftRaw = column.display ? column.display(left) : column.value(left);
|
|
35
97
|
const rightRaw = column.display ? column.display(right) : column.value(right);
|
|
@@ -88,6 +150,14 @@ function ResetIcon() {
|
|
|
88
150
|
);
|
|
89
151
|
}
|
|
90
152
|
|
|
153
|
+
function ColumnsIcon() {
|
|
154
|
+
return (
|
|
155
|
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
156
|
+
<path d="M4 4.5h3.5v11H4zM8.25 4.5h3.5v11h-3.5zM12.5 4.5H16v11h-3.5z" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
157
|
+
</svg>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
91
161
|
function ChevronIcon({ open }: { open: boolean }) {
|
|
92
162
|
return (
|
|
93
163
|
<svg className={open ? "side-rail-chevron open" : "side-rail-chevron"} viewBox="0 0 20 20" aria-hidden="true">
|
|
@@ -105,6 +175,26 @@ function PreviewIcon() {
|
|
|
105
175
|
);
|
|
106
176
|
}
|
|
107
177
|
|
|
178
|
+
function DownloadIcon() {
|
|
179
|
+
return (
|
|
180
|
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
181
|
+
<path d="M10 3.5v8.2" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
182
|
+
<path d="M6.5 9.2 10 12.8l3.5-3.6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
|
183
|
+
<path d="M4 15.5h12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
184
|
+
</svg>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function MoreIcon() {
|
|
189
|
+
return (
|
|
190
|
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
191
|
+
<circle cx="4.5" cy="10" r="1.5" fill="currentColor" />
|
|
192
|
+
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
|
|
193
|
+
<circle cx="15.5" cy="10" r="1.5" fill="currentColor" />
|
|
194
|
+
</svg>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
108
198
|
function DetailList({ lines }: { lines: DetailLine[] }) {
|
|
109
199
|
return (
|
|
110
200
|
<dl className="record-detail-list">
|
|
@@ -132,6 +222,14 @@ export function RecordWorkspace<T>({
|
|
|
132
222
|
onDelete,
|
|
133
223
|
canUpdate,
|
|
134
224
|
canDelete,
|
|
225
|
+
onDownload,
|
|
226
|
+
canDownload = true,
|
|
227
|
+
downloadBusy = false,
|
|
228
|
+
showColumnPicker = true,
|
|
229
|
+
columnPrefsKey,
|
|
230
|
+
visibleColumnKeys,
|
|
231
|
+
defaultVisibleColumnKeys,
|
|
232
|
+
onVisibleColumnKeysChange,
|
|
135
233
|
toolbar,
|
|
136
234
|
form,
|
|
137
235
|
}: {
|
|
@@ -148,25 +246,104 @@ export function RecordWorkspace<T>({
|
|
|
148
246
|
onDelete?: (item: T) => void;
|
|
149
247
|
canUpdate?: (item: T) => boolean;
|
|
150
248
|
canDelete?: (item: T) => boolean;
|
|
249
|
+
/**
|
|
250
|
+
* When set, shows CSV / Excel / JSON download controls.
|
|
251
|
+
* Second argument lists currently visible columns (exportKey ?? key).
|
|
252
|
+
*/
|
|
253
|
+
onDownload?: (format: ExportDownloadFormat, columns: ExportColumnRef[]) => void;
|
|
254
|
+
canDownload?: boolean;
|
|
255
|
+
downloadBusy?: boolean;
|
|
256
|
+
/** Column checklist in the side rail. Default true. */
|
|
257
|
+
showColumnPicker?: boolean;
|
|
258
|
+
/** Persist visible column keys in localStorage under this key. */
|
|
259
|
+
columnPrefsKey?: string;
|
|
260
|
+
/** Controlled visible column keys. */
|
|
261
|
+
visibleColumnKeys?: string[];
|
|
262
|
+
/** Uncontrolled initial visible keys (defaults to columns with defaultVisible !== false). */
|
|
263
|
+
defaultVisibleColumnKeys?: string[];
|
|
264
|
+
onVisibleColumnKeysChange?: (keys: string[]) => void;
|
|
151
265
|
toolbar?: ReactNode;
|
|
152
266
|
/** Slot for Add/Update/View dialogs owned by the host app. */
|
|
153
267
|
form?: ReactNode;
|
|
154
268
|
}) {
|
|
155
|
-
const
|
|
269
|
+
const allowedKeys = useMemo(() => new Set(columns.map((column) => column.key)), [columns]);
|
|
270
|
+
const fallbackKeys = useMemo(
|
|
271
|
+
() => defaultVisibleColumnKeys ?? defaultVisibleKeys(columns),
|
|
272
|
+
[columns, defaultVisibleColumnKeys],
|
|
273
|
+
);
|
|
274
|
+
const isControlled = visibleColumnKeys !== undefined;
|
|
275
|
+
const [internalVisibleKeys, setInternalVisibleKeys] = useState<string[]>(() =>
|
|
276
|
+
readStoredColumnKeys(columnPrefsKey, fallbackKeys, allowedKeys),
|
|
277
|
+
);
|
|
156
278
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
157
279
|
const [sort, setSort] = useState<SortState | null>(null);
|
|
158
280
|
const [sideOpen, setSideOpen] = useState(false);
|
|
281
|
+
const [downloadOpen, setDownloadOpen] = useState(false);
|
|
282
|
+
const [columnsOpen, setColumnsOpen] = useState(false);
|
|
159
283
|
const [previewEnabled, setPreviewEnabled] = useState(false);
|
|
160
284
|
const [previewKey, setPreviewKey] = useState<string | number | null>(null);
|
|
161
285
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
|
162
286
|
const [pageSize, setPageSize] = useState("15");
|
|
163
287
|
const [page, setPage] = useState(1);
|
|
288
|
+
const [actionsMenuKey, setActionsMenuKey] = useState<string | number | null>(null);
|
|
289
|
+
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
|
|
164
290
|
const isClient = useIsClient();
|
|
165
291
|
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (isControlled) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
setInternalVisibleKeys(readStoredColumnKeys(columnPrefsKey, fallbackKeys, allowedKeys));
|
|
297
|
+
}, [allowedKeys, columnPrefsKey, fallbackKeys, isControlled]);
|
|
298
|
+
|
|
299
|
+
const resolvedVisibleKeys = useMemo(() => {
|
|
300
|
+
const source = isControlled ? visibleColumnKeys! : internalVisibleKeys;
|
|
301
|
+
const filtered = source.filter((key) => allowedKeys.has(key));
|
|
302
|
+
return filtered.length > 0 ? filtered : fallbackKeys.filter((key) => allowedKeys.has(key));
|
|
303
|
+
}, [allowedKeys, fallbackKeys, internalVisibleKeys, isControlled, visibleColumnKeys]);
|
|
304
|
+
|
|
305
|
+
const visibleColumns = useMemo(() => {
|
|
306
|
+
const byKey = new Map(columns.map((column) => [column.key, column]));
|
|
307
|
+
return resolvedVisibleKeys.map((key) => byKey.get(key)).filter((column): column is TableColumn<T> => Boolean(column));
|
|
308
|
+
}, [columns, resolvedVisibleKeys]);
|
|
309
|
+
|
|
310
|
+
function setVisibleKeys(next: string[]) {
|
|
311
|
+
const unique = Array.from(new Set(next.filter((key) => allowedKeys.has(key))));
|
|
312
|
+
const safe = unique.length > 0 ? unique : fallbackKeys.filter((key) => allowedKeys.has(key));
|
|
313
|
+
if (!isControlled) {
|
|
314
|
+
setInternalVisibleKeys(safe);
|
|
315
|
+
writeStoredColumnKeys(columnPrefsKey, safe);
|
|
316
|
+
}
|
|
317
|
+
onVisibleColumnKeysChange?.(safe);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function toggleColumnVisible(key: string, checked: boolean) {
|
|
321
|
+
if (checked) {
|
|
322
|
+
if (resolvedVisibleKeys.includes(key)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
setVisibleKeys([...resolvedVisibleKeys, key]);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (resolvedVisibleKeys.length <= 1) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
setVisibleKeys(resolvedVisibleKeys.filter((entry) => entry !== key));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function selectAllColumns() {
|
|
335
|
+
setVisibleKeys(columns.map((column) => column.key));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function resetColumns() {
|
|
339
|
+
setVisibleKeys(fallbackKeys);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const filterableColumns = visibleColumns.filter((column) => column.filterable !== false);
|
|
343
|
+
|
|
166
344
|
const filtered = useMemo(() => {
|
|
167
|
-
const activeFilters = columns.filter((column) => column.filterable !== false);
|
|
168
345
|
return items.filter((item) =>
|
|
169
|
-
|
|
346
|
+
filterableColumns.every((column) => {
|
|
170
347
|
const query = (filters[column.key] ?? "").trim();
|
|
171
348
|
if (!query) {
|
|
172
349
|
return true;
|
|
@@ -179,25 +356,26 @@ export function RecordWorkspace<T>({
|
|
|
179
356
|
return text.toLowerCase().includes(query.toLowerCase());
|
|
180
357
|
}),
|
|
181
358
|
);
|
|
182
|
-
}, [items, filters,
|
|
359
|
+
}, [items, filters, filterableColumns]);
|
|
183
360
|
|
|
184
361
|
const sorted = useMemo(() => {
|
|
185
362
|
if (!sort) {
|
|
186
363
|
return filtered;
|
|
187
364
|
}
|
|
188
|
-
const column =
|
|
365
|
+
const column = visibleColumns.find((entry) => entry.key === sort.key);
|
|
189
366
|
if (!column || column.sortable === false) {
|
|
190
367
|
return filtered;
|
|
191
368
|
}
|
|
192
369
|
const direction = sort.direction === "asc" ? 1 : -1;
|
|
193
370
|
return [...filtered].sort((left, right) => compareColumnValues(column, left, right) * direction);
|
|
194
|
-
}, [filtered, sort,
|
|
371
|
+
}, [filtered, sort, visibleColumns]);
|
|
195
372
|
|
|
196
373
|
const activeFilterCount = useMemo(
|
|
197
374
|
() => filterableColumns.reduce((count, column) => count + ((filters[column.key] ?? "").trim() ? 1 : 0), 0),
|
|
198
375
|
[filterableColumns, filters],
|
|
199
376
|
);
|
|
200
377
|
|
|
378
|
+
const hiddenColumnCount = columns.length - visibleColumns.length;
|
|
201
379
|
const showAll = pageSize === "all";
|
|
202
380
|
const numericPageSize = showAll ? Math.max(sorted.length, 1) : Number(pageSize);
|
|
203
381
|
const pageCount = showAll ? 1 : Math.max(1, Math.ceil(sorted.length / numericPageSize));
|
|
@@ -205,8 +383,37 @@ export function RecordWorkspace<T>({
|
|
|
205
383
|
const start = showAll ? 0 : (currentPage - 1) * numericPageSize;
|
|
206
384
|
const pageItems = showAll ? sorted : sorted.slice(start, start + numericPageSize);
|
|
207
385
|
const showAdd = canAdd && Boolean(onAdd);
|
|
386
|
+
const showDownload = Boolean(onDownload) && canDownload;
|
|
208
387
|
const showFilters = filterableColumns.length > 0;
|
|
388
|
+
const showColumns = showColumnPicker && columns.length > 0;
|
|
209
389
|
const previewItem = previewKey === null ? null : sorted.find((item) => rowKey(item) === previewKey) ?? null;
|
|
390
|
+
const exportColumns: ExportColumnRef[] = visibleColumns.map((column) => ({
|
|
391
|
+
key: column.exportKey ?? column.key,
|
|
392
|
+
header: column.header,
|
|
393
|
+
}));
|
|
394
|
+
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
if (actionsMenuKey === null) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function onPointerDown(event: globalThis.MouseEvent) {
|
|
401
|
+
if (actionsMenuRef.current && !actionsMenuRef.current.contains(event.target as Node)) {
|
|
402
|
+
setActionsMenuKey(null);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
407
|
+
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
408
|
+
}, [actionsMenuKey]);
|
|
409
|
+
|
|
410
|
+
function requestDownload(format: ExportDownloadFormat) {
|
|
411
|
+
if (!onDownload || downloadBusy) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
onDownload(format, exportColumns);
|
|
415
|
+
setDownloadOpen(false);
|
|
416
|
+
}
|
|
210
417
|
|
|
211
418
|
function cellText(column: TableColumn<T>, item: T) {
|
|
212
419
|
const raw = column.display ? column.display(item) : column.value(item);
|
|
@@ -214,13 +421,13 @@ export function RecordWorkspace<T>({
|
|
|
214
421
|
}
|
|
215
422
|
|
|
216
423
|
function detailLines(item: T): DetailLine[] {
|
|
217
|
-
return
|
|
424
|
+
return visibleColumns.map((column) => ({
|
|
218
425
|
label: column.header,
|
|
219
426
|
value: cellText(column, item),
|
|
220
427
|
}));
|
|
221
428
|
}
|
|
222
429
|
|
|
223
|
-
const previewTitle = previewItem &&
|
|
430
|
+
const previewTitle = previewItem && visibleColumns[0] ? cellText(visibleColumns[0], previewItem) : null;
|
|
224
431
|
|
|
225
432
|
function changeFilter(key: string, value: string) {
|
|
226
433
|
setFilters((current) => ({ ...current, [key]: value }));
|
|
@@ -277,6 +484,108 @@ export function RecordWorkspace<T>({
|
|
|
277
484
|
? `1–${sorted.length} of ${sorted.length}`
|
|
278
485
|
: `${start + 1}–${Math.min(start + numericPageSize, sorted.length)} of ${sorted.length}`;
|
|
279
486
|
|
|
487
|
+
function renderRowActions(item: T) {
|
|
488
|
+
const key = rowKey(item);
|
|
489
|
+
const updateAllowed = canUpdate ? canUpdate(item) : Boolean(onUpdate);
|
|
490
|
+
const deleteAllowed = canDelete ? canDelete(item) : Boolean(onDelete);
|
|
491
|
+
const hasActions = Boolean(onView || onUpdate || onDelete);
|
|
492
|
+
if (!hasActions) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<>
|
|
498
|
+
<div className="row-actions row-actions-desktop">
|
|
499
|
+
{onView ? (
|
|
500
|
+
<button type="button" className="ghost row-action" onClick={() => onView(item)}>
|
|
501
|
+
View
|
|
502
|
+
</button>
|
|
503
|
+
) : null}
|
|
504
|
+
{onUpdate ? (
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
className="ghost row-action"
|
|
508
|
+
disabled={!updateAllowed}
|
|
509
|
+
title={updateAllowed ? "Update" : "Update is not available"}
|
|
510
|
+
onClick={() => onUpdate(item)}
|
|
511
|
+
>
|
|
512
|
+
Update
|
|
513
|
+
</button>
|
|
514
|
+
) : null}
|
|
515
|
+
{onDelete ? (
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
className="ghost row-action danger"
|
|
519
|
+
disabled={!deleteAllowed}
|
|
520
|
+
title={deleteAllowed ? "Delete" : "Delete is not available"}
|
|
521
|
+
onClick={() => onDelete(item)}
|
|
522
|
+
>
|
|
523
|
+
Delete
|
|
524
|
+
</button>
|
|
525
|
+
) : null}
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
<div className="row-actions row-actions-mobile" ref={actionsMenuKey === key ? actionsMenuRef : undefined}>
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
className="ghost row-action-menu-trigger"
|
|
532
|
+
aria-haspopup="menu"
|
|
533
|
+
aria-expanded={actionsMenuKey === key}
|
|
534
|
+
aria-label="Row actions"
|
|
535
|
+
onClick={() => setActionsMenuKey((current) => (current === key ? null : key))}
|
|
536
|
+
>
|
|
537
|
+
<MoreIcon />
|
|
538
|
+
</button>
|
|
539
|
+
{actionsMenuKey === key ? (
|
|
540
|
+
<div className="row-action-menu" role="menu">
|
|
541
|
+
{onView ? (
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
role="menuitem"
|
|
545
|
+
className="ghost"
|
|
546
|
+
onClick={() => {
|
|
547
|
+
setActionsMenuKey(null);
|
|
548
|
+
onView(item);
|
|
549
|
+
}}
|
|
550
|
+
>
|
|
551
|
+
View
|
|
552
|
+
</button>
|
|
553
|
+
) : null}
|
|
554
|
+
{onUpdate ? (
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
role="menuitem"
|
|
558
|
+
className="ghost"
|
|
559
|
+
disabled={!updateAllowed}
|
|
560
|
+
onClick={() => {
|
|
561
|
+
setActionsMenuKey(null);
|
|
562
|
+
onUpdate(item);
|
|
563
|
+
}}
|
|
564
|
+
>
|
|
565
|
+
Update
|
|
566
|
+
</button>
|
|
567
|
+
) : null}
|
|
568
|
+
{onDelete ? (
|
|
569
|
+
<button
|
|
570
|
+
type="button"
|
|
571
|
+
role="menuitem"
|
|
572
|
+
className="ghost danger"
|
|
573
|
+
disabled={!deleteAllowed}
|
|
574
|
+
onClick={() => {
|
|
575
|
+
setActionsMenuKey(null);
|
|
576
|
+
onDelete(item);
|
|
577
|
+
}}
|
|
578
|
+
>
|
|
579
|
+
Delete
|
|
580
|
+
</button>
|
|
581
|
+
) : null}
|
|
582
|
+
</div>
|
|
583
|
+
) : null}
|
|
584
|
+
</div>
|
|
585
|
+
</>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
280
589
|
return (
|
|
281
590
|
<div className="list-workspace" aria-label={title}>
|
|
282
591
|
<div
|
|
@@ -296,7 +605,7 @@ export function RecordWorkspace<T>({
|
|
|
296
605
|
<table className="data-table">
|
|
297
606
|
<thead>
|
|
298
607
|
<tr>
|
|
299
|
-
{
|
|
608
|
+
{visibleColumns.map((column) => {
|
|
300
609
|
const sortable = column.sortable !== false;
|
|
301
610
|
const active = sort?.key === column.key ? sort.direction : null;
|
|
302
611
|
return (
|
|
@@ -327,7 +636,7 @@ export function RecordWorkspace<T>({
|
|
|
327
636
|
<tbody>
|
|
328
637
|
{pageItems.length === 0 ? (
|
|
329
638
|
<tr>
|
|
330
|
-
<td colSpan={
|
|
639
|
+
<td colSpan={visibleColumns.length + 1}>
|
|
331
640
|
<p className="hint table-empty">
|
|
332
641
|
{items.length === 0 ? empty : "No records match the current filters."}
|
|
333
642
|
</p>
|
|
@@ -336,8 +645,6 @@ export function RecordWorkspace<T>({
|
|
|
336
645
|
) : (
|
|
337
646
|
pageItems.map((item) => {
|
|
338
647
|
const key = rowKey(item);
|
|
339
|
-
const updateAllowed = canUpdate ? canUpdate(item) : Boolean(onUpdate);
|
|
340
|
-
const deleteAllowed = canDelete ? canDelete(item) : Boolean(onDelete);
|
|
341
648
|
const selected = previewEnabled && previewKey === key;
|
|
342
649
|
return (
|
|
343
650
|
<tr
|
|
@@ -357,39 +664,11 @@ export function RecordWorkspace<T>({
|
|
|
357
664
|
onMouseLeave={() => setTooltip(null)}
|
|
358
665
|
onClick={() => focusRecord(item)}
|
|
359
666
|
>
|
|
360
|
-
{
|
|
667
|
+
{visibleColumns.map((column) => (
|
|
361
668
|
<td key={column.key}>{cellText(column, item)}</td>
|
|
362
669
|
))}
|
|
363
670
|
<td className="actions-col" onClick={(event) => event.stopPropagation()}>
|
|
364
|
-
|
|
365
|
-
{onView ? (
|
|
366
|
-
<button type="button" className="ghost row-action" onClick={() => onView(item)}>
|
|
367
|
-
View
|
|
368
|
-
</button>
|
|
369
|
-
) : null}
|
|
370
|
-
{onUpdate ? (
|
|
371
|
-
<button
|
|
372
|
-
type="button"
|
|
373
|
-
className="ghost row-action"
|
|
374
|
-
disabled={!updateAllowed}
|
|
375
|
-
title={updateAllowed ? "Update" : "Update is not available"}
|
|
376
|
-
onClick={() => onUpdate(item)}
|
|
377
|
-
>
|
|
378
|
-
Update
|
|
379
|
-
</button>
|
|
380
|
-
) : null}
|
|
381
|
-
{onDelete ? (
|
|
382
|
-
<button
|
|
383
|
-
type="button"
|
|
384
|
-
className="ghost row-action danger"
|
|
385
|
-
disabled={!deleteAllowed}
|
|
386
|
-
title={deleteAllowed ? "Delete" : "Delete is not available"}
|
|
387
|
-
onClick={() => onDelete(item)}
|
|
388
|
-
>
|
|
389
|
-
Delete
|
|
390
|
-
</button>
|
|
391
|
-
) : null}
|
|
392
|
-
</div>
|
|
671
|
+
{renderRowActions(item)}
|
|
393
672
|
</td>
|
|
394
673
|
</tr>
|
|
395
674
|
);
|
|
@@ -463,6 +742,79 @@ export function RecordWorkspace<T>({
|
|
|
463
742
|
</button>
|
|
464
743
|
) : null}
|
|
465
744
|
|
|
745
|
+
{showColumns ? (
|
|
746
|
+
<div className="side-rail-columns">
|
|
747
|
+
<button
|
|
748
|
+
type="button"
|
|
749
|
+
className="ghost side-rail-columns-toggle"
|
|
750
|
+
aria-expanded={columnsOpen}
|
|
751
|
+
onClick={() => setColumnsOpen((open) => !open)}
|
|
752
|
+
>
|
|
753
|
+
<ColumnsIcon />
|
|
754
|
+
<span>Columns{hiddenColumnCount > 0 ? ` (${visibleColumns.length}/${columns.length})` : ""}</span>
|
|
755
|
+
</button>
|
|
756
|
+
{columnsOpen ? (
|
|
757
|
+
<div className="side-rail-columns-panel" role="group" aria-label="Visible columns">
|
|
758
|
+
<div className="side-rail-columns-actions">
|
|
759
|
+
<button type="button" className="ghost" onClick={selectAllColumns}>
|
|
760
|
+
Select all
|
|
761
|
+
</button>
|
|
762
|
+
<button type="button" className="ghost" onClick={resetColumns}>
|
|
763
|
+
Reset
|
|
764
|
+
</button>
|
|
765
|
+
</div>
|
|
766
|
+
<div className="side-rail-columns-list">
|
|
767
|
+
{columns.map((column) => {
|
|
768
|
+
const checked = resolvedVisibleKeys.includes(column.key);
|
|
769
|
+
const disableUncheck = checked && resolvedVisibleKeys.length <= 1;
|
|
770
|
+
return (
|
|
771
|
+
<label key={column.key} className="side-rail-column-option">
|
|
772
|
+
<input
|
|
773
|
+
type="checkbox"
|
|
774
|
+
checked={checked}
|
|
775
|
+
disabled={disableUncheck}
|
|
776
|
+
onChange={(event) => toggleColumnVisible(column.key, event.target.checked)}
|
|
777
|
+
/>
|
|
778
|
+
<span>{column.header}</span>
|
|
779
|
+
</label>
|
|
780
|
+
);
|
|
781
|
+
})}
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
) : null}
|
|
785
|
+
</div>
|
|
786
|
+
) : null}
|
|
787
|
+
|
|
788
|
+
{showDownload ? (
|
|
789
|
+
<div className="side-rail-download">
|
|
790
|
+
<button
|
|
791
|
+
type="button"
|
|
792
|
+
className="ghost side-rail-download-toggle"
|
|
793
|
+
aria-expanded={downloadOpen}
|
|
794
|
+
disabled={downloadBusy}
|
|
795
|
+
onClick={() => setDownloadOpen((open) => !open)}
|
|
796
|
+
>
|
|
797
|
+
<DownloadIcon />
|
|
798
|
+
<span>{downloadBusy ? "Downloading…" : "Download"}</span>
|
|
799
|
+
</button>
|
|
800
|
+
{downloadOpen ? (
|
|
801
|
+
<div className="side-rail-download-formats" role="group" aria-label="Download format">
|
|
802
|
+
{EXPORT_FORMAT_OPTIONS.map((option) => (
|
|
803
|
+
<button
|
|
804
|
+
key={option.format}
|
|
805
|
+
type="button"
|
|
806
|
+
className="ghost side-rail-download-format"
|
|
807
|
+
disabled={downloadBusy}
|
|
808
|
+
onClick={() => requestDownload(option.format)}
|
|
809
|
+
>
|
|
810
|
+
{option.label}
|
|
811
|
+
</button>
|
|
812
|
+
))}
|
|
813
|
+
</div>
|
|
814
|
+
) : null}
|
|
815
|
+
</div>
|
|
816
|
+
) : null}
|
|
817
|
+
|
|
466
818
|
{showFilters ? (
|
|
467
819
|
<div className="side-rail-filters">
|
|
468
820
|
<div className="filter-panel-head">
|
|
@@ -519,6 +871,37 @@ export function RecordWorkspace<T>({
|
|
|
519
871
|
<PlusIcon />
|
|
520
872
|
</button>
|
|
521
873
|
) : null}
|
|
874
|
+
{showColumns ? (
|
|
875
|
+
<button
|
|
876
|
+
type="button"
|
|
877
|
+
className="ghost side-rail-icon"
|
|
878
|
+
title="Columns"
|
|
879
|
+
aria-label="Columns"
|
|
880
|
+
onClick={() => {
|
|
881
|
+
setSideOpen(true);
|
|
882
|
+
setColumnsOpen(true);
|
|
883
|
+
}}
|
|
884
|
+
>
|
|
885
|
+
<ColumnsIcon />
|
|
886
|
+
{hiddenColumnCount > 0 ? <span className="filter-badge">{hiddenColumnCount}</span> : null}
|
|
887
|
+
</button>
|
|
888
|
+
) : null}
|
|
889
|
+
{showDownload ? (
|
|
890
|
+
<button
|
|
891
|
+
type="button"
|
|
892
|
+
className="ghost side-rail-icon"
|
|
893
|
+
title="Download"
|
|
894
|
+
aria-label="Download"
|
|
895
|
+
aria-expanded={downloadOpen}
|
|
896
|
+
disabled={downloadBusy}
|
|
897
|
+
onClick={() => {
|
|
898
|
+
setSideOpen(true);
|
|
899
|
+
setDownloadOpen(true);
|
|
900
|
+
}}
|
|
901
|
+
>
|
|
902
|
+
<DownloadIcon />
|
|
903
|
+
</button>
|
|
904
|
+
) : null}
|
|
522
905
|
{showFilters ? (
|
|
523
906
|
<button
|
|
524
907
|
type="button"
|
package/src/patterns/index.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
export { AppShell, type AppShellProps } from "./AppShell";
|
|
2
|
-
export {
|
|
1
|
+
export { AppShell, type AppShellProps, type MobileNavMode } from "./AppShell";
|
|
2
|
+
export {
|
|
3
|
+
RecordWorkspace,
|
|
4
|
+
RecordTable,
|
|
5
|
+
type TableColumn,
|
|
6
|
+
type ExportDownloadFormat,
|
|
7
|
+
type ExportColumnRef,
|
|
8
|
+
} from "./RecordWorkspace";
|
|
@@ -12,14 +12,16 @@ export function SideNav({
|
|
|
12
12
|
activeId,
|
|
13
13
|
onSelect,
|
|
14
14
|
label = "Sections",
|
|
15
|
+
className = "",
|
|
15
16
|
}: {
|
|
16
17
|
items: SideNavItem[];
|
|
17
18
|
activeId: string;
|
|
18
19
|
onSelect: (id: string) => void;
|
|
19
20
|
label?: string;
|
|
21
|
+
className?: string;
|
|
20
22
|
}) {
|
|
21
23
|
return (
|
|
22
|
-
<nav className="side-nav" aria-label={label}>
|
|
24
|
+
<nav className={["side-nav", className].filter(Boolean).join(" ")} aria-label={label}>
|
|
23
25
|
{items.map((item) => (
|
|
24
26
|
<button
|
|
25
27
|
key={item.id}
|