@mshuja288/aksuicore 0.3.0 → 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 +13 -0
- package/dist/aksui.css +327 -6
- package/dist/index.cjs +430 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -9
- package/dist/index.d.ts +49 -9
- package/dist/index.js +430 -67
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/patterns/AppShell.tsx +140 -17
- package/src/patterns/RecordWorkspace.tsx +347 -46
- package/src/patterns/index.ts +2 -1
- package/src/primitives/SideNav.tsx +3 -1
- package/src/primitives/Topbar.tsx +38 -8
- package/src/theme/patterns.css +164 -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,11 +31,21 @@ 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
|
|
|
28
40
|
/** Formats supported by the shared table download control (matches akscore TableExport). */
|
|
29
41
|
export type ExportDownloadFormat = "csv" | "xlsx" | "json";
|
|
30
42
|
|
|
43
|
+
/** Column descriptors passed to `onDownload` (visible columns only). */
|
|
44
|
+
export type ExportColumnRef = {
|
|
45
|
+
key: string;
|
|
46
|
+
header: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
31
49
|
const EXPORT_FORMAT_OPTIONS: { format: ExportDownloadFormat; label: string }[] = [
|
|
32
50
|
{ format: "csv", label: "CSV" },
|
|
33
51
|
{ format: "xlsx", label: "Excel" },
|
|
@@ -39,6 +57,41 @@ type SortState = { key: string; direction: SortDirection };
|
|
|
39
57
|
type DetailLine = { label: string; value: string };
|
|
40
58
|
type TooltipState = { x: number; y: number; lines: DetailLine[] };
|
|
41
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
|
+
|
|
42
95
|
function compareColumnValues<T>(column: TableColumn<T>, left: T, right: T) {
|
|
43
96
|
const leftRaw = column.display ? column.display(left) : column.value(left);
|
|
44
97
|
const rightRaw = column.display ? column.display(right) : column.value(right);
|
|
@@ -97,6 +150,14 @@ function ResetIcon() {
|
|
|
97
150
|
);
|
|
98
151
|
}
|
|
99
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
|
+
|
|
100
161
|
function ChevronIcon({ open }: { open: boolean }) {
|
|
101
162
|
return (
|
|
102
163
|
<svg className={open ? "side-rail-chevron open" : "side-rail-chevron"} viewBox="0 0 20 20" aria-hidden="true">
|
|
@@ -124,6 +185,16 @@ function DownloadIcon() {
|
|
|
124
185
|
);
|
|
125
186
|
}
|
|
126
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
|
+
|
|
127
198
|
function DetailList({ lines }: { lines: DetailLine[] }) {
|
|
128
199
|
return (
|
|
129
200
|
<dl className="record-detail-list">
|
|
@@ -154,6 +225,11 @@ export function RecordWorkspace<T>({
|
|
|
154
225
|
onDownload,
|
|
155
226
|
canDownload = true,
|
|
156
227
|
downloadBusy = false,
|
|
228
|
+
showColumnPicker = true,
|
|
229
|
+
columnPrefsKey,
|
|
230
|
+
visibleColumnKeys,
|
|
231
|
+
defaultVisibleColumnKeys,
|
|
232
|
+
onVisibleColumnKeysChange,
|
|
157
233
|
toolbar,
|
|
158
234
|
form,
|
|
159
235
|
}: {
|
|
@@ -170,30 +246,104 @@ export function RecordWorkspace<T>({
|
|
|
170
246
|
onDelete?: (item: T) => void;
|
|
171
247
|
canUpdate?: (item: T) => boolean;
|
|
172
248
|
canDelete?: (item: T) => boolean;
|
|
173
|
-
/**
|
|
174
|
-
|
|
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;
|
|
175
254
|
canDownload?: boolean;
|
|
176
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;
|
|
177
265
|
toolbar?: ReactNode;
|
|
178
266
|
/** Slot for Add/Update/View dialogs owned by the host app. */
|
|
179
267
|
form?: ReactNode;
|
|
180
268
|
}) {
|
|
181
|
-
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
|
+
);
|
|
182
278
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
183
279
|
const [sort, setSort] = useState<SortState | null>(null);
|
|
184
280
|
const [sideOpen, setSideOpen] = useState(false);
|
|
185
281
|
const [downloadOpen, setDownloadOpen] = useState(false);
|
|
282
|
+
const [columnsOpen, setColumnsOpen] = useState(false);
|
|
186
283
|
const [previewEnabled, setPreviewEnabled] = useState(false);
|
|
187
284
|
const [previewKey, setPreviewKey] = useState<string | number | null>(null);
|
|
188
285
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
|
189
286
|
const [pageSize, setPageSize] = useState("15");
|
|
190
287
|
const [page, setPage] = useState(1);
|
|
288
|
+
const [actionsMenuKey, setActionsMenuKey] = useState<string | number | null>(null);
|
|
289
|
+
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
|
|
191
290
|
const isClient = useIsClient();
|
|
192
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
|
+
|
|
193
344
|
const filtered = useMemo(() => {
|
|
194
|
-
const activeFilters = columns.filter((column) => column.filterable !== false);
|
|
195
345
|
return items.filter((item) =>
|
|
196
|
-
|
|
346
|
+
filterableColumns.every((column) => {
|
|
197
347
|
const query = (filters[column.key] ?? "").trim();
|
|
198
348
|
if (!query) {
|
|
199
349
|
return true;
|
|
@@ -206,25 +356,26 @@ export function RecordWorkspace<T>({
|
|
|
206
356
|
return text.toLowerCase().includes(query.toLowerCase());
|
|
207
357
|
}),
|
|
208
358
|
);
|
|
209
|
-
}, [items, filters,
|
|
359
|
+
}, [items, filters, filterableColumns]);
|
|
210
360
|
|
|
211
361
|
const sorted = useMemo(() => {
|
|
212
362
|
if (!sort) {
|
|
213
363
|
return filtered;
|
|
214
364
|
}
|
|
215
|
-
const column =
|
|
365
|
+
const column = visibleColumns.find((entry) => entry.key === sort.key);
|
|
216
366
|
if (!column || column.sortable === false) {
|
|
217
367
|
return filtered;
|
|
218
368
|
}
|
|
219
369
|
const direction = sort.direction === "asc" ? 1 : -1;
|
|
220
370
|
return [...filtered].sort((left, right) => compareColumnValues(column, left, right) * direction);
|
|
221
|
-
}, [filtered, sort,
|
|
371
|
+
}, [filtered, sort, visibleColumns]);
|
|
222
372
|
|
|
223
373
|
const activeFilterCount = useMemo(
|
|
224
374
|
() => filterableColumns.reduce((count, column) => count + ((filters[column.key] ?? "").trim() ? 1 : 0), 0),
|
|
225
375
|
[filterableColumns, filters],
|
|
226
376
|
);
|
|
227
377
|
|
|
378
|
+
const hiddenColumnCount = columns.length - visibleColumns.length;
|
|
228
379
|
const showAll = pageSize === "all";
|
|
229
380
|
const numericPageSize = showAll ? Math.max(sorted.length, 1) : Number(pageSize);
|
|
230
381
|
const pageCount = showAll ? 1 : Math.max(1, Math.ceil(sorted.length / numericPageSize));
|
|
@@ -234,13 +385,33 @@ export function RecordWorkspace<T>({
|
|
|
234
385
|
const showAdd = canAdd && Boolean(onAdd);
|
|
235
386
|
const showDownload = Boolean(onDownload) && canDownload;
|
|
236
387
|
const showFilters = filterableColumns.length > 0;
|
|
388
|
+
const showColumns = showColumnPicker && columns.length > 0;
|
|
237
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]);
|
|
238
409
|
|
|
239
410
|
function requestDownload(format: ExportDownloadFormat) {
|
|
240
411
|
if (!onDownload || downloadBusy) {
|
|
241
412
|
return;
|
|
242
413
|
}
|
|
243
|
-
onDownload(format);
|
|
414
|
+
onDownload(format, exportColumns);
|
|
244
415
|
setDownloadOpen(false);
|
|
245
416
|
}
|
|
246
417
|
|
|
@@ -250,13 +421,13 @@ export function RecordWorkspace<T>({
|
|
|
250
421
|
}
|
|
251
422
|
|
|
252
423
|
function detailLines(item: T): DetailLine[] {
|
|
253
|
-
return
|
|
424
|
+
return visibleColumns.map((column) => ({
|
|
254
425
|
label: column.header,
|
|
255
426
|
value: cellText(column, item),
|
|
256
427
|
}));
|
|
257
428
|
}
|
|
258
429
|
|
|
259
|
-
const previewTitle = previewItem &&
|
|
430
|
+
const previewTitle = previewItem && visibleColumns[0] ? cellText(visibleColumns[0], previewItem) : null;
|
|
260
431
|
|
|
261
432
|
function changeFilter(key: string, value: string) {
|
|
262
433
|
setFilters((current) => ({ ...current, [key]: value }));
|
|
@@ -313,6 +484,108 @@ export function RecordWorkspace<T>({
|
|
|
313
484
|
? `1–${sorted.length} of ${sorted.length}`
|
|
314
485
|
: `${start + 1}–${Math.min(start + numericPageSize, sorted.length)} of ${sorted.length}`;
|
|
315
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
|
+
|
|
316
589
|
return (
|
|
317
590
|
<div className="list-workspace" aria-label={title}>
|
|
318
591
|
<div
|
|
@@ -332,7 +605,7 @@ export function RecordWorkspace<T>({
|
|
|
332
605
|
<table className="data-table">
|
|
333
606
|
<thead>
|
|
334
607
|
<tr>
|
|
335
|
-
{
|
|
608
|
+
{visibleColumns.map((column) => {
|
|
336
609
|
const sortable = column.sortable !== false;
|
|
337
610
|
const active = sort?.key === column.key ? sort.direction : null;
|
|
338
611
|
return (
|
|
@@ -363,7 +636,7 @@ export function RecordWorkspace<T>({
|
|
|
363
636
|
<tbody>
|
|
364
637
|
{pageItems.length === 0 ? (
|
|
365
638
|
<tr>
|
|
366
|
-
<td colSpan={
|
|
639
|
+
<td colSpan={visibleColumns.length + 1}>
|
|
367
640
|
<p className="hint table-empty">
|
|
368
641
|
{items.length === 0 ? empty : "No records match the current filters."}
|
|
369
642
|
</p>
|
|
@@ -372,8 +645,6 @@ export function RecordWorkspace<T>({
|
|
|
372
645
|
) : (
|
|
373
646
|
pageItems.map((item) => {
|
|
374
647
|
const key = rowKey(item);
|
|
375
|
-
const updateAllowed = canUpdate ? canUpdate(item) : Boolean(onUpdate);
|
|
376
|
-
const deleteAllowed = canDelete ? canDelete(item) : Boolean(onDelete);
|
|
377
648
|
const selected = previewEnabled && previewKey === key;
|
|
378
649
|
return (
|
|
379
650
|
<tr
|
|
@@ -393,39 +664,11 @@ export function RecordWorkspace<T>({
|
|
|
393
664
|
onMouseLeave={() => setTooltip(null)}
|
|
394
665
|
onClick={() => focusRecord(item)}
|
|
395
666
|
>
|
|
396
|
-
{
|
|
667
|
+
{visibleColumns.map((column) => (
|
|
397
668
|
<td key={column.key}>{cellText(column, item)}</td>
|
|
398
669
|
))}
|
|
399
670
|
<td className="actions-col" onClick={(event) => event.stopPropagation()}>
|
|
400
|
-
|
|
401
|
-
{onView ? (
|
|
402
|
-
<button type="button" className="ghost row-action" onClick={() => onView(item)}>
|
|
403
|
-
View
|
|
404
|
-
</button>
|
|
405
|
-
) : null}
|
|
406
|
-
{onUpdate ? (
|
|
407
|
-
<button
|
|
408
|
-
type="button"
|
|
409
|
-
className="ghost row-action"
|
|
410
|
-
disabled={!updateAllowed}
|
|
411
|
-
title={updateAllowed ? "Update" : "Update is not available"}
|
|
412
|
-
onClick={() => onUpdate(item)}
|
|
413
|
-
>
|
|
414
|
-
Update
|
|
415
|
-
</button>
|
|
416
|
-
) : null}
|
|
417
|
-
{onDelete ? (
|
|
418
|
-
<button
|
|
419
|
-
type="button"
|
|
420
|
-
className="ghost row-action danger"
|
|
421
|
-
disabled={!deleteAllowed}
|
|
422
|
-
title={deleteAllowed ? "Delete" : "Delete is not available"}
|
|
423
|
-
onClick={() => onDelete(item)}
|
|
424
|
-
>
|
|
425
|
-
Delete
|
|
426
|
-
</button>
|
|
427
|
-
) : null}
|
|
428
|
-
</div>
|
|
671
|
+
{renderRowActions(item)}
|
|
429
672
|
</td>
|
|
430
673
|
</tr>
|
|
431
674
|
);
|
|
@@ -499,6 +742,49 @@ export function RecordWorkspace<T>({
|
|
|
499
742
|
</button>
|
|
500
743
|
) : null}
|
|
501
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
|
+
|
|
502
788
|
{showDownload ? (
|
|
503
789
|
<div className="side-rail-download">
|
|
504
790
|
<button
|
|
@@ -585,6 +871,21 @@ export function RecordWorkspace<T>({
|
|
|
585
871
|
<PlusIcon />
|
|
586
872
|
</button>
|
|
587
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}
|
|
588
889
|
{showDownload ? (
|
|
589
890
|
<button
|
|
590
891
|
type="button"
|
package/src/patterns/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export { AppShell, type AppShellProps } from "./AppShell";
|
|
1
|
+
export { AppShell, type AppShellProps, type MobileNavMode } from "./AppShell";
|
|
2
2
|
export {
|
|
3
3
|
RecordWorkspace,
|
|
4
4
|
RecordTable,
|
|
5
5
|
type TableColumn,
|
|
6
6
|
type ExportDownloadFormat,
|
|
7
|
+
type ExportColumnRef,
|
|
7
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}
|
|
@@ -3,12 +3,27 @@
|
|
|
3
3
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import { Button } from "./Button";
|
|
5
5
|
|
|
6
|
+
function MenuIcon({ open }: { open: boolean }) {
|
|
7
|
+
return (
|
|
8
|
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
9
|
+
{open ? (
|
|
10
|
+
<path d="M5 5l10 10M15 5 5 15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
11
|
+
) : (
|
|
12
|
+
<path d="M3.5 5.5h13M3.5 10h13M3.5 14.5h13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
13
|
+
)}
|
|
14
|
+
</svg>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export function Topbar({
|
|
7
19
|
title,
|
|
8
20
|
logoSrc,
|
|
9
21
|
logoAlt = "Logo",
|
|
10
22
|
username = "",
|
|
11
23
|
onLogout,
|
|
24
|
+
showMenuButton = false,
|
|
25
|
+
menuOpen = false,
|
|
26
|
+
onMenuClick,
|
|
12
27
|
children,
|
|
13
28
|
}: {
|
|
14
29
|
title: string;
|
|
@@ -16,30 +31,45 @@ export function Topbar({
|
|
|
16
31
|
logoAlt?: string;
|
|
17
32
|
username?: string;
|
|
18
33
|
onLogout?: () => void;
|
|
34
|
+
/** Shows a hamburger control (used by AppShell drawer on small screens). */
|
|
35
|
+
showMenuButton?: boolean;
|
|
36
|
+
menuOpen?: boolean;
|
|
37
|
+
onMenuClick?: () => void;
|
|
19
38
|
children?: ReactNode;
|
|
20
39
|
}) {
|
|
21
|
-
const [
|
|
40
|
+
const [accountOpen, setAccountOpen] = useState(false);
|
|
22
41
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
23
42
|
const initial = (username || "U").slice(0, 1).toUpperCase();
|
|
24
43
|
|
|
25
44
|
useEffect(() => {
|
|
26
|
-
if (!
|
|
45
|
+
if (!accountOpen) {
|
|
27
46
|
return;
|
|
28
47
|
}
|
|
29
48
|
|
|
30
49
|
function onPointerDown(event: MouseEvent) {
|
|
31
50
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
32
|
-
|
|
51
|
+
setAccountOpen(false);
|
|
33
52
|
}
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
document.addEventListener("mousedown", onPointerDown);
|
|
37
56
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
38
|
-
}, [
|
|
57
|
+
}, [accountOpen]);
|
|
39
58
|
|
|
40
59
|
return (
|
|
41
60
|
<header className="topbar">
|
|
42
61
|
<div className="topbar-brand">
|
|
62
|
+
{showMenuButton && onMenuClick ? (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
className="ghost topbar-menu-button"
|
|
66
|
+
aria-label={menuOpen ? "Close navigation menu" : "Open navigation menu"}
|
|
67
|
+
aria-expanded={menuOpen}
|
|
68
|
+
onClick={onMenuClick}
|
|
69
|
+
>
|
|
70
|
+
<MenuIcon open={menuOpen} />
|
|
71
|
+
</button>
|
|
72
|
+
) : null}
|
|
43
73
|
{logoSrc ? (
|
|
44
74
|
<img
|
|
45
75
|
className="topbar-logo"
|
|
@@ -60,20 +90,20 @@ export function Topbar({
|
|
|
60
90
|
type="button"
|
|
61
91
|
className="avatar-button"
|
|
62
92
|
aria-haspopup="menu"
|
|
63
|
-
aria-expanded={
|
|
64
|
-
onClick={() =>
|
|
93
|
+
aria-expanded={accountOpen}
|
|
94
|
+
onClick={() => setAccountOpen((open) => !open)}
|
|
65
95
|
title={username || "Account"}
|
|
66
96
|
>
|
|
67
97
|
<span className="avatar">{initial}</span>
|
|
68
98
|
</button>
|
|
69
|
-
{
|
|
99
|
+
{accountOpen ? (
|
|
70
100
|
<div className="avatar-menu" role="menu">
|
|
71
101
|
{username ? <p className="avatar-name">{username}</p> : null}
|
|
72
102
|
<Button
|
|
73
103
|
variant="ghost"
|
|
74
104
|
role="menuitem"
|
|
75
105
|
onClick={() => {
|
|
76
|
-
|
|
106
|
+
setAccountOpen(false);
|
|
77
107
|
onLogout();
|
|
78
108
|
}}
|
|
79
109
|
>
|