@mshuja288/aksuicore 0.3.0 → 0.4.1

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.
@@ -1,6 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState, useSyncExternalStore, type MouseEvent, type ReactNode } from "react";
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";
@@ -13,6 +21,25 @@ function useIsClient() {
13
21
  );
14
22
  }
15
23
 
24
+ /** Hover tooltips only on fine pointers (desktop). Touch / narrow layouts never get them. */
25
+ function useHoverTooltipsEnabled() {
26
+ return useSyncExternalStore(
27
+ (onStoreChange) => {
28
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
29
+ return () => {};
30
+ }
31
+ const media = window.matchMedia("(hover: hover) and (pointer: fine)");
32
+ media.addEventListener("change", onStoreChange);
33
+ return () => media.removeEventListener("change", onStoreChange);
34
+ },
35
+ () =>
36
+ typeof window !== "undefined" &&
37
+ typeof window.matchMedia === "function" &&
38
+ window.matchMedia("(hover: hover) and (pointer: fine)").matches,
39
+ () => false,
40
+ );
41
+ }
42
+
16
43
  export type TableColumn<T> = {
17
44
  key: string;
18
45
  header: string;
@@ -23,11 +50,21 @@ export type TableColumn<T> = {
23
50
  /** When set, filter uses an exact-match Select instead of text contains. */
24
51
  filterOptions?: SelectOption[];
25
52
  filterPlaceholder?: string;
53
+ /** When false, column starts hidden. Default true. */
54
+ defaultVisible?: boolean;
55
+ /** Key sent to export APIs when different from `key` (e.g. PascalCase property name). */
56
+ exportKey?: string;
26
57
  };
27
58
 
28
59
  /** Formats supported by the shared table download control (matches akscore TableExport). */
29
60
  export type ExportDownloadFormat = "csv" | "xlsx" | "json";
30
61
 
62
+ /** Column descriptors passed to `onDownload` (visible columns only). */
63
+ export type ExportColumnRef = {
64
+ key: string;
65
+ header: string;
66
+ };
67
+
31
68
  const EXPORT_FORMAT_OPTIONS: { format: ExportDownloadFormat; label: string }[] = [
32
69
  { format: "csv", label: "CSV" },
33
70
  { format: "xlsx", label: "Excel" },
@@ -39,6 +76,41 @@ type SortState = { key: string; direction: SortDirection };
39
76
  type DetailLine = { label: string; value: string };
40
77
  type TooltipState = { x: number; y: number; lines: DetailLine[] };
41
78
 
79
+ function defaultVisibleKeys<T>(columns: TableColumn<T>[]) {
80
+ return columns.filter((column) => column.defaultVisible !== false).map((column) => column.key);
81
+ }
82
+
83
+ function readStoredColumnKeys(prefsKey: string | undefined, fallback: string[], allowed: Set<string>) {
84
+ if (!prefsKey || typeof window === "undefined") {
85
+ return fallback;
86
+ }
87
+ try {
88
+ const raw = window.localStorage.getItem(prefsKey);
89
+ if (!raw) {
90
+ return fallback;
91
+ }
92
+ const parsed = JSON.parse(raw) as unknown;
93
+ if (!Array.isArray(parsed)) {
94
+ return fallback;
95
+ }
96
+ const keys = parsed.filter((entry): entry is string => typeof entry === "string" && allowed.has(entry));
97
+ return keys.length > 0 ? keys : fallback;
98
+ } catch {
99
+ return fallback;
100
+ }
101
+ }
102
+
103
+ function writeStoredColumnKeys(prefsKey: string | undefined, keys: string[]) {
104
+ if (!prefsKey || typeof window === "undefined") {
105
+ return;
106
+ }
107
+ try {
108
+ window.localStorage.setItem(prefsKey, JSON.stringify(keys));
109
+ } catch {
110
+ /* ignore quota / private mode */
111
+ }
112
+ }
113
+
42
114
  function compareColumnValues<T>(column: TableColumn<T>, left: T, right: T) {
43
115
  const leftRaw = column.display ? column.display(left) : column.value(left);
44
116
  const rightRaw = column.display ? column.display(right) : column.value(right);
@@ -97,6 +169,14 @@ function ResetIcon() {
97
169
  );
98
170
  }
99
171
 
172
+ function ColumnsIcon() {
173
+ return (
174
+ <svg viewBox="0 0 20 20" aria-hidden="true">
175
+ <path d="M4 4.5h3.5v11H4zM8.25 4.5h3.5v11h-3.5zM12.5 4.5H16v11h-3.5z" fill="none" stroke="currentColor" strokeWidth="1.5" />
176
+ </svg>
177
+ );
178
+ }
179
+
100
180
  function ChevronIcon({ open }: { open: boolean }) {
101
181
  return (
102
182
  <svg className={open ? "side-rail-chevron open" : "side-rail-chevron"} viewBox="0 0 20 20" aria-hidden="true">
@@ -124,6 +204,31 @@ function DownloadIcon() {
124
204
  );
125
205
  }
126
206
 
207
+ function MoreIcon() {
208
+ return (
209
+ <svg viewBox="0 0 20 20" aria-hidden="true">
210
+ <circle cx="4.5" cy="10" r="1.5" fill="currentColor" />
211
+ <circle cx="10" cy="10" r="1.5" fill="currentColor" />
212
+ <circle cx="15.5" cy="10" r="1.5" fill="currentColor" />
213
+ </svg>
214
+ );
215
+ }
216
+
217
+ function ToolsIcon() {
218
+ return (
219
+ <svg viewBox="0 0 20 20" aria-hidden="true">
220
+ <path
221
+ d="M11.5 3.5h5v5M16.2 3.8 10 10M8.2 5.2H3.8v11h11V12"
222
+ fill="none"
223
+ stroke="currentColor"
224
+ strokeWidth="1.6"
225
+ strokeLinecap="round"
226
+ strokeLinejoin="round"
227
+ />
228
+ </svg>
229
+ );
230
+ }
231
+
127
232
  function DetailList({ lines }: { lines: DetailLine[] }) {
128
233
  return (
129
234
  <dl className="record-detail-list">
@@ -154,6 +259,11 @@ export function RecordWorkspace<T>({
154
259
  onDownload,
155
260
  canDownload = true,
156
261
  downloadBusy = false,
262
+ showColumnPicker = true,
263
+ columnPrefsKey,
264
+ visibleColumnKeys,
265
+ defaultVisibleColumnKeys,
266
+ onVisibleColumnKeysChange,
157
267
  toolbar,
158
268
  form,
159
269
  }: {
@@ -170,30 +280,111 @@ export function RecordWorkspace<T>({
170
280
  onDelete?: (item: T) => void;
171
281
  canUpdate?: (item: T) => boolean;
172
282
  canDelete?: (item: T) => boolean;
173
- /** When set, shows CSV / Excel / JSON download controls in the side rail. */
174
- onDownload?: (format: ExportDownloadFormat) => void;
283
+ /**
284
+ * When set, shows CSV / Excel / JSON download controls.
285
+ * Second argument lists currently visible columns (exportKey ?? key).
286
+ */
287
+ onDownload?: (format: ExportDownloadFormat, columns: ExportColumnRef[]) => void;
175
288
  canDownload?: boolean;
176
289
  downloadBusy?: boolean;
290
+ /** Column checklist in the side rail. Default true. */
291
+ showColumnPicker?: boolean;
292
+ /** Persist visible column keys in localStorage under this key. */
293
+ columnPrefsKey?: string;
294
+ /** Controlled visible column keys. */
295
+ visibleColumnKeys?: string[];
296
+ /** Uncontrolled initial visible keys (defaults to columns with defaultVisible !== false). */
297
+ defaultVisibleColumnKeys?: string[];
298
+ onVisibleColumnKeysChange?: (keys: string[]) => void;
177
299
  toolbar?: ReactNode;
178
300
  /** Slot for Add/Update/View dialogs owned by the host app. */
179
301
  form?: ReactNode;
180
302
  }) {
181
- const filterableColumns = columns.filter((column) => column.filterable !== false);
303
+ const allowedKeys = useMemo(() => new Set(columns.map((column) => column.key)), [columns]);
304
+ const fallbackKeys = useMemo(
305
+ () => defaultVisibleColumnKeys ?? defaultVisibleKeys(columns),
306
+ [columns, defaultVisibleColumnKeys],
307
+ );
308
+ const isControlled = visibleColumnKeys !== undefined;
309
+ const [internalVisibleKeys, setInternalVisibleKeys] = useState<string[]>(() =>
310
+ readStoredColumnKeys(columnPrefsKey, fallbackKeys, allowedKeys),
311
+ );
182
312
  const [filters, setFilters] = useState<Record<string, string>>({});
183
313
  const [sort, setSort] = useState<SortState | null>(null);
184
314
  const [sideOpen, setSideOpen] = useState(false);
315
+ const [toolsDrawerOpen, setToolsDrawerOpen] = useState(false);
185
316
  const [downloadOpen, setDownloadOpen] = useState(false);
317
+ const [columnsOpen, setColumnsOpen] = useState(false);
186
318
  const [previewEnabled, setPreviewEnabled] = useState(false);
187
319
  const [previewKey, setPreviewKey] = useState<string | number | null>(null);
188
320
  const [tooltip, setTooltip] = useState<TooltipState | null>(null);
189
321
  const [pageSize, setPageSize] = useState("15");
190
322
  const [page, setPage] = useState(1);
323
+ const [actionsMenu, setActionsMenu] = useState<{
324
+ key: string | number;
325
+ item: T;
326
+ x: number;
327
+ y: number;
328
+ } | null>(null);
329
+ const actionsMenuRef = useRef<HTMLDivElement | null>(null);
191
330
  const isClient = useIsClient();
331
+ const hoverTooltipsEnabled = useHoverTooltipsEnabled();
332
+
333
+ useEffect(() => {
334
+ if (isControlled) {
335
+ return;
336
+ }
337
+ setInternalVisibleKeys(readStoredColumnKeys(columnPrefsKey, fallbackKeys, allowedKeys));
338
+ }, [allowedKeys, columnPrefsKey, fallbackKeys, isControlled]);
339
+
340
+ const resolvedVisibleKeys = useMemo(() => {
341
+ const source = isControlled ? visibleColumnKeys! : internalVisibleKeys;
342
+ const filtered = source.filter((key) => allowedKeys.has(key));
343
+ return filtered.length > 0 ? filtered : fallbackKeys.filter((key) => allowedKeys.has(key));
344
+ }, [allowedKeys, fallbackKeys, internalVisibleKeys, isControlled, visibleColumnKeys]);
345
+
346
+ const visibleColumns = useMemo(() => {
347
+ const byKey = new Map(columns.map((column) => [column.key, column]));
348
+ return resolvedVisibleKeys.map((key) => byKey.get(key)).filter((column): column is TableColumn<T> => Boolean(column));
349
+ }, [columns, resolvedVisibleKeys]);
350
+
351
+ function setVisibleKeys(next: string[]) {
352
+ const unique = Array.from(new Set(next.filter((key) => allowedKeys.has(key))));
353
+ const safe = unique.length > 0 ? unique : fallbackKeys.filter((key) => allowedKeys.has(key));
354
+ if (!isControlled) {
355
+ setInternalVisibleKeys(safe);
356
+ writeStoredColumnKeys(columnPrefsKey, safe);
357
+ }
358
+ onVisibleColumnKeysChange?.(safe);
359
+ }
360
+
361
+ function toggleColumnVisible(key: string, checked: boolean) {
362
+ if (checked) {
363
+ if (resolvedVisibleKeys.includes(key)) {
364
+ return;
365
+ }
366
+ setVisibleKeys([...resolvedVisibleKeys, key]);
367
+ return;
368
+ }
369
+ if (resolvedVisibleKeys.length <= 1) {
370
+ return;
371
+ }
372
+ setVisibleKeys(resolvedVisibleKeys.filter((entry) => entry !== key));
373
+ }
374
+
375
+ function selectAllColumns() {
376
+ setVisibleKeys(columns.map((column) => column.key));
377
+ }
378
+
379
+ function resetColumns() {
380
+ setVisibleKeys(fallbackKeys);
381
+ }
382
+
383
+ const filterableColumns = visibleColumns.filter((column) => column.filterable !== false);
192
384
 
193
385
  const filtered = useMemo(() => {
194
- const activeFilters = columns.filter((column) => column.filterable !== false);
195
386
  return items.filter((item) =>
196
- activeFilters.every((column) => {
387
+ filterableColumns.every((column) => {
197
388
  const query = (filters[column.key] ?? "").trim();
198
389
  if (!query) {
199
390
  return true;
@@ -206,25 +397,27 @@ export function RecordWorkspace<T>({
206
397
  return text.toLowerCase().includes(query.toLowerCase());
207
398
  }),
208
399
  );
209
- }, [items, filters, columns]);
400
+ }, [items, filters, filterableColumns]);
210
401
 
211
402
  const sorted = useMemo(() => {
212
403
  if (!sort) {
213
404
  return filtered;
214
405
  }
215
- const column = columns.find((entry) => entry.key === sort.key);
406
+ const column = visibleColumns.find((entry) => entry.key === sort.key);
216
407
  if (!column || column.sortable === false) {
217
408
  return filtered;
218
409
  }
219
410
  const direction = sort.direction === "asc" ? 1 : -1;
220
411
  return [...filtered].sort((left, right) => compareColumnValues(column, left, right) * direction);
221
- }, [filtered, sort, columns]);
412
+ }, [filtered, sort, visibleColumns]);
222
413
 
223
414
  const activeFilterCount = useMemo(
224
415
  () => filterableColumns.reduce((count, column) => count + ((filters[column.key] ?? "").trim() ? 1 : 0), 0),
225
416
  [filterableColumns, filters],
226
417
  );
227
418
 
419
+ const hiddenColumnCount = columns.length - visibleColumns.length;
420
+ const toolsBadgeCount = activeFilterCount + (hiddenColumnCount > 0 ? 1 : 0) + (previewEnabled ? 1 : 0);
228
421
  const showAll = pageSize === "all";
229
422
  const numericPageSize = showAll ? Math.max(sorted.length, 1) : Number(pageSize);
230
423
  const pageCount = showAll ? 1 : Math.max(1, Math.ceil(sorted.length / numericPageSize));
@@ -234,13 +427,54 @@ export function RecordWorkspace<T>({
234
427
  const showAdd = canAdd && Boolean(onAdd);
235
428
  const showDownload = Boolean(onDownload) && canDownload;
236
429
  const showFilters = filterableColumns.length > 0;
430
+ const showColumns = showColumnPicker && columns.length > 0;
237
431
  const previewItem = previewKey === null ? null : sorted.find((item) => rowKey(item) === previewKey) ?? null;
432
+ const exportColumns: ExportColumnRef[] = visibleColumns.map((column) => ({
433
+ key: column.exportKey ?? column.key,
434
+ header: column.header,
435
+ }));
436
+ const canShowTooltip = hoverTooltipsEnabled && !previewEnabled && actionsMenu === null;
437
+
438
+ useEffect(() => {
439
+ if (actionsMenu === null) {
440
+ return;
441
+ }
442
+
443
+ function onPointerDown(event: globalThis.MouseEvent) {
444
+ if (actionsMenuRef.current && !actionsMenuRef.current.contains(event.target as Node)) {
445
+ setActionsMenu(null);
446
+ }
447
+ }
448
+
449
+ document.addEventListener("mousedown", onPointerDown);
450
+ return () => document.removeEventListener("mousedown", onPointerDown);
451
+ }, [actionsMenu]);
452
+
453
+ useEffect(() => {
454
+ if (!toolsDrawerOpen) {
455
+ return;
456
+ }
457
+
458
+ function onKeyDown(event: KeyboardEvent) {
459
+ if (event.key === "Escape") {
460
+ setToolsDrawerOpen(false);
461
+ }
462
+ }
463
+
464
+ document.addEventListener("keydown", onKeyDown);
465
+ const previousOverflow = document.body.style.overflow;
466
+ document.body.style.overflow = "hidden";
467
+ return () => {
468
+ document.removeEventListener("keydown", onKeyDown);
469
+ document.body.style.overflow = previousOverflow;
470
+ };
471
+ }, [toolsDrawerOpen]);
238
472
 
239
473
  function requestDownload(format: ExportDownloadFormat) {
240
474
  if (!onDownload || downloadBusy) {
241
475
  return;
242
476
  }
243
- onDownload(format);
477
+ onDownload(format, exportColumns);
244
478
  setDownloadOpen(false);
245
479
  }
246
480
 
@@ -250,13 +484,13 @@ export function RecordWorkspace<T>({
250
484
  }
251
485
 
252
486
  function detailLines(item: T): DetailLine[] {
253
- return columns.map((column) => ({
487
+ return visibleColumns.map((column) => ({
254
488
  label: column.header,
255
489
  value: cellText(column, item),
256
490
  }));
257
491
  }
258
492
 
259
- const previewTitle = previewItem && columns[0] ? cellText(columns[0], previewItem) : null;
493
+ const previewTitle = previewItem && visibleColumns[0] ? cellText(visibleColumns[0], previewItem) : null;
260
494
 
261
495
  function changeFilter(key: string, value: string) {
262
496
  setFilters((current) => ({ ...current, [key]: value }));
@@ -299,6 +533,9 @@ export function RecordWorkspace<T>({
299
533
  }
300
534
 
301
535
  function showTooltip(item: T, event: MouseEvent) {
536
+ if (!canShowTooltip) {
537
+ return;
538
+ }
302
539
  const padding = 16;
303
540
  const width = 280;
304
541
  const x = Math.min(event.clientX + 14, window.innerWidth - width - padding);
@@ -306,6 +543,22 @@ export function RecordWorkspace<T>({
306
543
  setTooltip({ x, y, lines: detailLines(item) });
307
544
  }
308
545
 
546
+ function openRowActions(item: T, event: MouseEvent<HTMLButtonElement>) {
547
+ event.stopPropagation();
548
+ setTooltip(null);
549
+ const key = rowKey(item);
550
+ if (actionsMenu?.key === key) {
551
+ setActionsMenu(null);
552
+ return;
553
+ }
554
+ const rect = event.currentTarget.getBoundingClientRect();
555
+ const menuWidth = 168;
556
+ const menuHeight = 148;
557
+ const x = Math.min(Math.max(8, rect.right - menuWidth), window.innerWidth - menuWidth - 8);
558
+ const y = Math.min(rect.bottom + 6, window.innerHeight - menuHeight - 8);
559
+ setActionsMenu({ key, item, x, y });
560
+ }
561
+
309
562
  const summary =
310
563
  sorted.length === 0
311
564
  ? "0 records"
@@ -313,8 +566,320 @@ export function RecordWorkspace<T>({
313
566
  ? `1–${sorted.length} of ${sorted.length}`
314
567
  : `${start + 1}–${Math.min(start + numericPageSize, sorted.length)} of ${sorted.length}`;
315
568
 
569
+ function renderToolsBody({ compactIcons }: { compactIcons: boolean }) {
570
+ if (compactIcons) {
571
+ return (
572
+ <div className="side-rail-icons">
573
+ <label className="side-rail-icon preview-check-icon" title="Preview">
574
+ <input
575
+ type="checkbox"
576
+ checked={previewEnabled}
577
+ onChange={(event) => setPreviewEnabledValue(event.target.checked)}
578
+ aria-label="Preview"
579
+ />
580
+ <PreviewIcon />
581
+ </label>
582
+ {showAdd ? (
583
+ <button type="button" className="ghost side-rail-icon" title={addLabel} aria-label={addLabel} onClick={onAdd}>
584
+ <PlusIcon />
585
+ </button>
586
+ ) : null}
587
+ {showColumns ? (
588
+ <button
589
+ type="button"
590
+ className="ghost side-rail-icon"
591
+ title="Columns"
592
+ aria-label="Columns"
593
+ onClick={() => {
594
+ setSideOpen(true);
595
+ setColumnsOpen(true);
596
+ }}
597
+ >
598
+ <ColumnsIcon />
599
+ {hiddenColumnCount > 0 ? <span className="filter-badge">{hiddenColumnCount}</span> : null}
600
+ </button>
601
+ ) : null}
602
+ {showDownload ? (
603
+ <button
604
+ type="button"
605
+ className="ghost side-rail-icon"
606
+ title="Download"
607
+ aria-label="Download"
608
+ disabled={downloadBusy}
609
+ onClick={() => {
610
+ setSideOpen(true);
611
+ setDownloadOpen(true);
612
+ }}
613
+ >
614
+ <DownloadIcon />
615
+ </button>
616
+ ) : null}
617
+ {showFilters ? (
618
+ <button
619
+ type="button"
620
+ className="ghost side-rail-icon"
621
+ title="Show filters"
622
+ aria-label="Show filters"
623
+ onClick={() => setSideOpen(true)}
624
+ >
625
+ <FilterIcon />
626
+ {activeFilterCount > 0 ? <span className="filter-badge">{activeFilterCount}</span> : null}
627
+ </button>
628
+ ) : null}
629
+ {showFilters ? (
630
+ <button
631
+ type="button"
632
+ className="ghost side-rail-icon"
633
+ title="Reset filters"
634
+ aria-label="Reset filters"
635
+ disabled={activeFilterCount === 0}
636
+ onClick={clearFilters}
637
+ >
638
+ <ResetIcon />
639
+ </button>
640
+ ) : null}
641
+ </div>
642
+ );
643
+ }
644
+
645
+ return (
646
+ <div className="side-rail-body">
647
+ <div className="preview-check">
648
+ <input
649
+ type="checkbox"
650
+ checked={previewEnabled}
651
+ onChange={(event) => setPreviewEnabledValue(event.target.checked)}
652
+ aria-label="Preview"
653
+ />
654
+ <PreviewIcon />
655
+ <span aria-hidden="true">Preview</span>
656
+ </div>
657
+
658
+ {showAdd ? (
659
+ <button
660
+ type="button"
661
+ className="side-rail-add"
662
+ onClick={() => {
663
+ setToolsDrawerOpen(false);
664
+ onAdd?.();
665
+ }}
666
+ >
667
+ <PlusIcon />
668
+ <span>{addLabel}</span>
669
+ </button>
670
+ ) : null}
671
+
672
+ {showColumns ? (
673
+ <div className="side-rail-columns">
674
+ <button
675
+ type="button"
676
+ className="ghost side-rail-columns-toggle"
677
+ aria-expanded={columnsOpen}
678
+ onClick={() => setColumnsOpen((open) => !open)}
679
+ >
680
+ <ColumnsIcon />
681
+ <span>Columns{hiddenColumnCount > 0 ? ` (${visibleColumns.length}/${columns.length})` : ""}</span>
682
+ </button>
683
+ {columnsOpen ? (
684
+ <div className="side-rail-columns-panel" role="group" aria-label="Visible columns">
685
+ <div className="side-rail-columns-actions">
686
+ <button type="button" className="ghost" onClick={selectAllColumns}>
687
+ Select all
688
+ </button>
689
+ <button type="button" className="ghost" onClick={resetColumns}>
690
+ Reset
691
+ </button>
692
+ </div>
693
+ <div className="side-rail-columns-list">
694
+ {columns.map((column) => {
695
+ const checked = resolvedVisibleKeys.includes(column.key);
696
+ const disableUncheck = checked && resolvedVisibleKeys.length <= 1;
697
+ return (
698
+ <label key={column.key} className="side-rail-column-option">
699
+ <input
700
+ type="checkbox"
701
+ checked={checked}
702
+ disabled={disableUncheck}
703
+ onChange={(event) => toggleColumnVisible(column.key, event.target.checked)}
704
+ />
705
+ <span>{column.header}</span>
706
+ </label>
707
+ );
708
+ })}
709
+ </div>
710
+ </div>
711
+ ) : null}
712
+ </div>
713
+ ) : null}
714
+
715
+ {showDownload ? (
716
+ <div className="side-rail-download">
717
+ <button
718
+ type="button"
719
+ className="ghost side-rail-download-toggle"
720
+ aria-expanded={downloadOpen}
721
+ disabled={downloadBusy}
722
+ onClick={() => setDownloadOpen((open) => !open)}
723
+ >
724
+ <DownloadIcon />
725
+ <span>{downloadBusy ? "Downloading…" : "Download"}</span>
726
+ </button>
727
+ {downloadOpen ? (
728
+ <div className="side-rail-download-formats" role="group" aria-label="Download format">
729
+ {EXPORT_FORMAT_OPTIONS.map((option) => (
730
+ <button
731
+ key={option.format}
732
+ type="button"
733
+ className="ghost side-rail-download-format"
734
+ disabled={downloadBusy}
735
+ onClick={() => requestDownload(option.format)}
736
+ >
737
+ {option.label}
738
+ </button>
739
+ ))}
740
+ </div>
741
+ ) : null}
742
+ </div>
743
+ ) : null}
744
+
745
+ {showFilters ? (
746
+ <div className="side-rail-filters">
747
+ <div className="filter-panel-head">
748
+ <h3>Filters</h3>
749
+ <button
750
+ type="button"
751
+ className="ghost filter-clear"
752
+ title="Reset filters"
753
+ aria-label="Reset filters"
754
+ disabled={activeFilterCount === 0}
755
+ onClick={clearFilters}
756
+ >
757
+ <ResetIcon />
758
+ <span>Reset</span>
759
+ </button>
760
+ </div>
761
+ <div className="filter-panel-stack">
762
+ {filterableColumns.map((column) => (
763
+ <label key={column.key}>
764
+ {column.header}
765
+ {column.filterOptions ? (
766
+ <Select
767
+ value={filters[column.key] ?? ""}
768
+ options={[{ value: "", label: column.filterPlaceholder ?? "All" }, ...column.filterOptions]}
769
+ onChange={(value) => changeFilter(column.key, value)}
770
+ placeholder={column.filterPlaceholder ?? "All"}
771
+ />
772
+ ) : (
773
+ <input
774
+ value={filters[column.key] ?? ""}
775
+ onChange={(event) => changeFilter(column.key, event.target.value)}
776
+ placeholder={`Filter ${column.header.toLowerCase()}`}
777
+ />
778
+ )}
779
+ </label>
780
+ ))}
781
+ </div>
782
+ </div>
783
+ ) : null}
784
+ </div>
785
+ );
786
+ }
787
+
788
+ function renderRowActions(item: T) {
789
+ const key = rowKey(item);
790
+ const updateAllowed = canUpdate ? canUpdate(item) : Boolean(onUpdate);
791
+ const deleteAllowed = canDelete ? canDelete(item) : Boolean(onDelete);
792
+ const hasActions = Boolean(onView || onUpdate || onDelete);
793
+ if (!hasActions) {
794
+ return null;
795
+ }
796
+
797
+ return (
798
+ <>
799
+ <div className="row-actions row-actions-desktop">
800
+ {onView ? (
801
+ <button type="button" className="ghost row-action" onClick={() => onView(item)}>
802
+ View
803
+ </button>
804
+ ) : null}
805
+ {onUpdate ? (
806
+ <button
807
+ type="button"
808
+ className="ghost row-action"
809
+ disabled={!updateAllowed}
810
+ title={updateAllowed ? "Update" : "Update is not available"}
811
+ onClick={() => onUpdate(item)}
812
+ >
813
+ Update
814
+ </button>
815
+ ) : null}
816
+ {onDelete ? (
817
+ <button
818
+ type="button"
819
+ className="ghost row-action danger"
820
+ disabled={!deleteAllowed}
821
+ title={deleteAllowed ? "Delete" : "Delete is not available"}
822
+ onClick={() => onDelete(item)}
823
+ >
824
+ Delete
825
+ </button>
826
+ ) : null}
827
+ </div>
828
+
829
+ <div className="row-actions row-actions-mobile">
830
+ <button
831
+ type="button"
832
+ className={actionsMenu?.key === key ? "ghost row-action-menu-trigger open" : "ghost row-action-menu-trigger"}
833
+ aria-haspopup="menu"
834
+ aria-expanded={actionsMenu?.key === key}
835
+ aria-label="Row actions"
836
+ onClick={(event) => openRowActions(item, event)}
837
+ >
838
+ <MoreIcon />
839
+ </button>
840
+ </div>
841
+ </>
842
+ );
843
+ }
844
+
845
+ const actionsMenuItem = actionsMenu?.item;
846
+ const actionsUpdateAllowed = actionsMenuItem
847
+ ? canUpdate
848
+ ? canUpdate(actionsMenuItem)
849
+ : Boolean(onUpdate)
850
+ : false;
851
+ const actionsDeleteAllowed = actionsMenuItem
852
+ ? canDelete
853
+ ? canDelete(actionsMenuItem)
854
+ : Boolean(onDelete)
855
+ : false;
856
+
316
857
  return (
317
- <div className="list-workspace" aria-label={title}>
858
+ <div className={toolsDrawerOpen ? "list-workspace tools-drawer-open" : "list-workspace"} aria-label={title}>
859
+ <div className="tools-mobile-bar">
860
+ <div className="tools-mobile-bar-start">
861
+ {showAdd ? (
862
+ <button type="button" className="tools-mobile-add" onClick={onAdd}>
863
+ <PlusIcon />
864
+ <span>{addLabel}</span>
865
+ </button>
866
+ ) : (
867
+ <p className="tools-mobile-title">{title}</p>
868
+ )}
869
+ </div>
870
+ <button
871
+ type="button"
872
+ className="ghost tools-mobile-toggle"
873
+ aria-expanded={toolsDrawerOpen}
874
+ aria-controls="aksui-tools-drawer"
875
+ onClick={() => setToolsDrawerOpen(true)}
876
+ >
877
+ <ToolsIcon />
878
+ <span>Tools</span>
879
+ {toolsBadgeCount > 0 ? <span className="filter-badge">{toolsBadgeCount}</span> : null}
880
+ </button>
881
+ </div>
882
+
318
883
  <div
319
884
  className={["list-body", sideOpen ? "side-open" : "", previewEnabled ? "preview-open" : ""]
320
885
  .filter(Boolean)
@@ -332,7 +897,7 @@ export function RecordWorkspace<T>({
332
897
  <table className="data-table">
333
898
  <thead>
334
899
  <tr>
335
- {columns.map((column) => {
900
+ {visibleColumns.map((column) => {
336
901
  const sortable = column.sortable !== false;
337
902
  const active = sort?.key === column.key ? sort.direction : null;
338
903
  return (
@@ -363,7 +928,7 @@ export function RecordWorkspace<T>({
363
928
  <tbody>
364
929
  {pageItems.length === 0 ? (
365
930
  <tr>
366
- <td colSpan={columns.length + 1}>
931
+ <td colSpan={visibleColumns.length + 1}>
367
932
  <p className="hint table-empty">
368
933
  {items.length === 0 ? empty : "No records match the current filters."}
369
934
  </p>
@@ -372,60 +937,24 @@ export function RecordWorkspace<T>({
372
937
  ) : (
373
938
  pageItems.map((item) => {
374
939
  const key = rowKey(item);
375
- const updateAllowed = canUpdate ? canUpdate(item) : Boolean(onUpdate);
376
- const deleteAllowed = canDelete ? canDelete(item) : Boolean(onDelete);
377
940
  const selected = previewEnabled && previewKey === key;
378
941
  return (
379
942
  <tr
380
943
  key={key}
381
944
  className={selected ? "data-row selected" : "data-row"}
382
945
  onMouseEnter={(event) => {
383
- if (!previewEnabled) {
384
- showTooltip(item, event);
385
- }
946
+ showTooltip(item, event);
386
947
  focusRecord(item);
387
948
  }}
388
- onMouseMove={(event) => {
389
- if (!previewEnabled) {
390
- showTooltip(item, event);
391
- }
392
- }}
949
+ onMouseMove={(event) => showTooltip(item, event)}
393
950
  onMouseLeave={() => setTooltip(null)}
394
951
  onClick={() => focusRecord(item)}
395
952
  >
396
- {columns.map((column) => (
953
+ {visibleColumns.map((column) => (
397
954
  <td key={column.key}>{cellText(column, item)}</td>
398
955
  ))}
399
956
  <td className="actions-col" onClick={(event) => event.stopPropagation()}>
400
- <div className="row-actions">
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>
957
+ {renderRowActions(item)}
429
958
  </td>
430
959
  </tr>
431
960
  );
@@ -461,7 +990,7 @@ export function RecordWorkspace<T>({
461
990
  {previewItem ? (
462
991
  <DetailList lines={detailLines(previewItem)} />
463
992
  ) : (
464
- <p className="preview-empty">Hover or click a row to preview its details here.</p>
993
+ <p className="preview-empty">Select a row to preview its details here.</p>
465
994
  )}
466
995
  </div>
467
996
  </aside>
@@ -478,161 +1007,99 @@ export function RecordWorkspace<T>({
478
1007
  <ChevronIcon open={sideOpen} />
479
1008
  {sideOpen ? <span>Collapse</span> : null}
480
1009
  </button>
1010
+ {sideOpen ? renderToolsBody({ compactIcons: false }) : renderToolsBody({ compactIcons: true })}
1011
+ </aside>
1012
+ </div>
481
1013
 
482
- {sideOpen ? (
483
- <div className="side-rail-body">
484
- <div className="preview-check">
485
- <input
486
- type="checkbox"
487
- checked={previewEnabled}
488
- onChange={(event) => setPreviewEnabledValue(event.target.checked)}
489
- aria-label="Preview"
490
- />
491
- <PreviewIcon />
492
- <span aria-hidden="true">Preview</span>
493
- </div>
494
-
495
- {showAdd ? (
496
- <button type="button" className="side-rail-add" onClick={onAdd}>
497
- <PlusIcon />
498
- <span>{addLabel}</span>
499
- </button>
500
- ) : null}
501
-
502
- {showDownload ? (
503
- <div className="side-rail-download">
504
- <button
505
- type="button"
506
- className="ghost side-rail-download-toggle"
507
- aria-expanded={downloadOpen}
508
- disabled={downloadBusy}
509
- onClick={() => setDownloadOpen((open) => !open)}
510
- >
511
- <DownloadIcon />
512
- <span>{downloadBusy ? "Downloading…" : "Download"}</span>
1014
+ {isClient
1015
+ ? createPortal(
1016
+ <>
1017
+ <button
1018
+ type="button"
1019
+ className={toolsDrawerOpen ? "tools-drawer-backdrop open" : "tools-drawer-backdrop"}
1020
+ aria-label="Close tools"
1021
+ tabIndex={toolsDrawerOpen ? 0 : -1}
1022
+ onClick={() => setToolsDrawerOpen(false)}
1023
+ />
1024
+ <aside
1025
+ id="aksui-tools-drawer"
1026
+ className={toolsDrawerOpen ? "tools-drawer open" : "tools-drawer"}
1027
+ aria-label="Workspace tools"
1028
+ aria-hidden={!toolsDrawerOpen}
1029
+ >
1030
+ <div className="tools-drawer-head">
1031
+ <div>
1032
+ <p className="tools-drawer-kicker">Workspace</p>
1033
+ <h3>Tools</h3>
1034
+ </div>
1035
+ <button type="button" className="ghost tools-drawer-close" onClick={() => setToolsDrawerOpen(false)}>
1036
+ Close
513
1037
  </button>
514
- {downloadOpen ? (
515
- <div className="side-rail-download-formats" role="group" aria-label="Download format">
516
- {EXPORT_FORMAT_OPTIONS.map((option) => (
517
- <button
518
- key={option.format}
519
- type="button"
520
- className="ghost side-rail-download-format"
521
- disabled={downloadBusy}
522
- onClick={() => requestDownload(option.format)}
523
- >
524
- {option.label}
525
- </button>
526
- ))}
527
- </div>
528
- ) : null}
529
1038
  </div>
530
- ) : null}
1039
+ <div className="tools-drawer-body">{renderToolsBody({ compactIcons: false })}</div>
1040
+ </aside>
1041
+ </>,
1042
+ document.body,
1043
+ )
1044
+ : null}
531
1045
 
532
- {showFilters ? (
533
- <div className="side-rail-filters">
534
- <div className="filter-panel-head">
535
- <h3>Filters</h3>
536
- <button
537
- type="button"
538
- className="ghost filter-clear"
539
- title="Reset filters"
540
- aria-label="Reset filters"
541
- disabled={activeFilterCount === 0}
542
- onClick={clearFilters}
543
- >
544
- <ResetIcon />
545
- <span>Reset</span>
546
- </button>
547
- </div>
548
- <div className="filter-panel-stack">
549
- {filterableColumns.map((column) => (
550
- <label key={column.key}>
551
- {column.header}
552
- {column.filterOptions ? (
553
- <Select
554
- value={filters[column.key] ?? ""}
555
- options={[{ value: "", label: column.filterPlaceholder ?? "All" }, ...column.filterOptions]}
556
- onChange={(value) => changeFilter(column.key, value)}
557
- placeholder={column.filterPlaceholder ?? "All"}
558
- />
559
- ) : (
560
- <input
561
- value={filters[column.key] ?? ""}
562
- onChange={(event) => changeFilter(column.key, event.target.value)}
563
- placeholder={`Filter ${column.header.toLowerCase()}`}
564
- />
565
- )}
566
- </label>
567
- ))}
568
- </div>
569
- </div>
570
- ) : null}
571
- </div>
572
- ) : (
573
- <div className="side-rail-icons">
574
- <label className="side-rail-icon preview-check-icon" title="Preview">
575
- <input
576
- type="checkbox"
577
- checked={previewEnabled}
578
- onChange={(event) => setPreviewEnabledValue(event.target.checked)}
579
- aria-label="Preview"
580
- />
581
- <PreviewIcon />
582
- </label>
583
- {showAdd ? (
584
- <button type="button" className="ghost side-rail-icon" title={addLabel} aria-label={addLabel} onClick={onAdd}>
585
- <PlusIcon />
586
- </button>
587
- ) : null}
588
- {showDownload ? (
1046
+ {form}
1047
+
1048
+ {isClient && actionsMenu && actionsMenuItem
1049
+ ? createPortal(
1050
+ <div
1051
+ ref={actionsMenuRef}
1052
+ className="row-action-menu row-action-menu-portal"
1053
+ role="menu"
1054
+ style={{ left: actionsMenu.x, top: actionsMenu.y }}
1055
+ >
1056
+ {onView ? (
589
1057
  <button
590
1058
  type="button"
591
- className="ghost side-rail-icon"
592
- title="Download"
593
- aria-label="Download"
594
- aria-expanded={downloadOpen}
595
- disabled={downloadBusy}
1059
+ role="menuitem"
1060
+ className="ghost"
596
1061
  onClick={() => {
597
- setSideOpen(true);
598
- setDownloadOpen(true);
1062
+ setActionsMenu(null);
1063
+ onView(actionsMenuItem);
599
1064
  }}
600
1065
  >
601
- <DownloadIcon />
1066
+ View
602
1067
  </button>
603
1068
  ) : null}
604
- {showFilters ? (
1069
+ {onUpdate ? (
605
1070
  <button
606
1071
  type="button"
607
- className="ghost side-rail-icon"
608
- title="Show filters"
609
- aria-label="Show filters"
610
- onClick={() => setSideOpen(true)}
1072
+ role="menuitem"
1073
+ className="ghost"
1074
+ disabled={!actionsUpdateAllowed}
1075
+ onClick={() => {
1076
+ setActionsMenu(null);
1077
+ onUpdate(actionsMenuItem);
1078
+ }}
611
1079
  >
612
- <FilterIcon />
613
- {activeFilterCount > 0 ? <span className="filter-badge">{activeFilterCount}</span> : null}
1080
+ Update
614
1081
  </button>
615
1082
  ) : null}
616
- {showFilters ? (
1083
+ {onDelete ? (
617
1084
  <button
618
1085
  type="button"
619
- className="ghost side-rail-icon"
620
- title="Reset filters"
621
- aria-label="Reset filters"
622
- disabled={activeFilterCount === 0}
623
- onClick={clearFilters}
1086
+ role="menuitem"
1087
+ className="ghost danger"
1088
+ disabled={!actionsDeleteAllowed}
1089
+ onClick={() => {
1090
+ setActionsMenu(null);
1091
+ onDelete(actionsMenuItem);
1092
+ }}
624
1093
  >
625
- <ResetIcon />
1094
+ Delete
626
1095
  </button>
627
1096
  ) : null}
628
- </div>
629
- )}
630
- </aside>
631
- </div>
632
-
633
- {form}
1097
+ </div>,
1098
+ document.body,
1099
+ )
1100
+ : null}
634
1101
 
635
- {isClient && tooltip && !previewEnabled
1102
+ {isClient && tooltip && canShowTooltip
636
1103
  ? createPortal(
637
1104
  <div className="record-tooltip" style={{ left: tooltip.x, top: tooltip.y }} role="tooltip">
638
1105
  <p className="record-tooltip-title">{title}</p>