@mshuja288/aksuicore 0.4.0 → 0.4.2

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.
@@ -21,6 +21,25 @@ function useIsClient() {
21
21
  );
22
22
  }
23
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
+
24
43
  export type TableColumn<T> = {
25
44
  key: string;
26
45
  header: string;
@@ -46,12 +65,34 @@ export type ExportColumnRef = {
46
65
  header: string;
47
66
  };
48
67
 
68
+ export type BulkImportMode = "insert" | "update" | "upsert";
69
+
70
+ export type BulkImportError = {
71
+ row: number;
72
+ field?: string | null;
73
+ code: string;
74
+ message: string;
75
+ };
76
+
77
+ export type BulkImportResult = {
78
+ totalRows: number;
79
+ failed: number;
80
+ applied: boolean;
81
+ errors: BulkImportError[];
82
+ };
83
+
49
84
  const EXPORT_FORMAT_OPTIONS: { format: ExportDownloadFormat; label: string }[] = [
50
85
  { format: "csv", label: "CSV" },
51
86
  { format: "xlsx", label: "Excel" },
52
87
  { format: "json", label: "JSON" },
53
88
  ];
54
89
 
90
+ const BULK_IMPORT_MODE_OPTIONS: { mode: BulkImportMode; label: string }[] = [
91
+ { mode: "insert", label: "Insert" },
92
+ { mode: "update", label: "Update" },
93
+ { mode: "upsert", label: "Upsert" },
94
+ ];
95
+
55
96
  type SortDirection = "asc" | "desc";
56
97
  type SortState = { key: string; direction: SortDirection };
57
98
  type DetailLine = { label: string; value: string };
@@ -175,6 +216,16 @@ function PreviewIcon() {
175
216
  );
176
217
  }
177
218
 
219
+ function UploadIcon() {
220
+ return (
221
+ <svg viewBox="0 0 20 20" aria-hidden="true">
222
+ <path d="M10 16.5V7.3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
223
+ <path d="M6.5 10.8 10 7.2l3.5 3.6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
224
+ <path d="M4 4.5h12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
225
+ </svg>
226
+ );
227
+ }
228
+
178
229
  function DownloadIcon() {
179
230
  return (
180
231
  <svg viewBox="0 0 20 20" aria-hidden="true">
@@ -195,6 +246,21 @@ function MoreIcon() {
195
246
  );
196
247
  }
197
248
 
249
+ function ToolsIcon() {
250
+ return (
251
+ <svg viewBox="0 0 20 20" aria-hidden="true">
252
+ <path
253
+ d="M11.5 3.5h5v5M16.2 3.8 10 10M8.2 5.2H3.8v11h11V12"
254
+ fill="none"
255
+ stroke="currentColor"
256
+ strokeWidth="1.6"
257
+ strokeLinecap="round"
258
+ strokeLinejoin="round"
259
+ />
260
+ </svg>
261
+ );
262
+ }
263
+
198
264
  function DetailList({ lines }: { lines: DetailLine[] }) {
199
265
  return (
200
266
  <dl className="record-detail-list">
@@ -224,7 +290,11 @@ export function RecordWorkspace<T>({
224
290
  canDelete,
225
291
  onDownload,
226
292
  canDownload = true,
293
+ canExport,
227
294
  downloadBusy = false,
295
+ onBulkImport,
296
+ canImport = true,
297
+ importBusy = false,
228
298
  showColumnPicker = true,
229
299
  columnPrefsKey,
230
300
  visibleColumnKeys,
@@ -251,8 +321,13 @@ export function RecordWorkspace<T>({
251
321
  * Second argument lists currently visible columns (exportKey ?? key).
252
322
  */
253
323
  onDownload?: (format: ExportDownloadFormat, columns: ExportColumnRef[]) => void;
324
+ /** Alias for `canDownload` (RBAC). When set, export requires this and `canDownload` to be true. */
325
+ canExport?: boolean;
254
326
  canDownload?: boolean;
255
327
  downloadBusy?: boolean;
328
+ onBulkImport?: (mode: BulkImportMode, file: File) => Promise<BulkImportResult>;
329
+ canImport?: boolean;
330
+ importBusy?: boolean;
256
331
  /** Column checklist in the side rail. Default true. */
257
332
  showColumnPicker?: boolean;
258
333
  /** Persist visible column keys in localStorage under this key. */
@@ -278,16 +353,28 @@ export function RecordWorkspace<T>({
278
353
  const [filters, setFilters] = useState<Record<string, string>>({});
279
354
  const [sort, setSort] = useState<SortState | null>(null);
280
355
  const [sideOpen, setSideOpen] = useState(false);
356
+ const [toolsDrawerOpen, setToolsDrawerOpen] = useState(false);
281
357
  const [downloadOpen, setDownloadOpen] = useState(false);
358
+ const [bulkOpen, setBulkOpen] = useState(false);
359
+ const [bulkMode, setBulkMode] = useState<BulkImportMode>("upsert");
360
+ const [bulkFile, setBulkFile] = useState<File | null>(null);
361
+ const [bulkReport, setBulkReport] = useState<BulkImportResult | null>(null);
362
+ const bulkFileInputRef = useRef<HTMLInputElement | null>(null);
282
363
  const [columnsOpen, setColumnsOpen] = useState(false);
283
364
  const [previewEnabled, setPreviewEnabled] = useState(false);
284
365
  const [previewKey, setPreviewKey] = useState<string | number | null>(null);
285
366
  const [tooltip, setTooltip] = useState<TooltipState | null>(null);
286
367
  const [pageSize, setPageSize] = useState("15");
287
368
  const [page, setPage] = useState(1);
288
- const [actionsMenuKey, setActionsMenuKey] = useState<string | number | null>(null);
369
+ const [actionsMenu, setActionsMenu] = useState<{
370
+ key: string | number;
371
+ item: T;
372
+ x: number;
373
+ y: number;
374
+ } | null>(null);
289
375
  const actionsMenuRef = useRef<HTMLDivElement | null>(null);
290
376
  const isClient = useIsClient();
377
+ const hoverTooltipsEnabled = useHoverTooltipsEnabled();
291
378
 
292
379
  useEffect(() => {
293
380
  if (isControlled) {
@@ -376,6 +463,7 @@ export function RecordWorkspace<T>({
376
463
  );
377
464
 
378
465
  const hiddenColumnCount = columns.length - visibleColumns.length;
466
+ const toolsBadgeCount = activeFilterCount + (hiddenColumnCount > 0 ? 1 : 0) + (previewEnabled ? 1 : 0);
379
467
  const showAll = pageSize === "all";
380
468
  const numericPageSize = showAll ? Math.max(sorted.length, 1) : Number(pageSize);
381
469
  const pageCount = showAll ? 1 : Math.max(1, Math.ceil(sorted.length / numericPageSize));
@@ -383,7 +471,9 @@ export function RecordWorkspace<T>({
383
471
  const start = showAll ? 0 : (currentPage - 1) * numericPageSize;
384
472
  const pageItems = showAll ? sorted : sorted.slice(start, start + numericPageSize);
385
473
  const showAdd = canAdd && Boolean(onAdd);
386
- const showDownload = Boolean(onDownload) && canDownload;
474
+ const exportAllowed = (canExport ?? true) && canDownload;
475
+ const showDownload = Boolean(onDownload) && exportAllowed;
476
+ const showBulkImport = Boolean(onBulkImport) && canImport;
387
477
  const showFilters = filterableColumns.length > 0;
388
478
  const showColumns = showColumnPicker && columns.length > 0;
389
479
  const previewItem = previewKey === null ? null : sorted.find((item) => rowKey(item) === previewKey) ?? null;
@@ -391,21 +481,42 @@ export function RecordWorkspace<T>({
391
481
  key: column.exportKey ?? column.key,
392
482
  header: column.header,
393
483
  }));
484
+ const canShowTooltip = hoverTooltipsEnabled && !previewEnabled && actionsMenu === null;
394
485
 
395
486
  useEffect(() => {
396
- if (actionsMenuKey === null) {
487
+ if (actionsMenu === null) {
397
488
  return;
398
489
  }
399
490
 
400
491
  function onPointerDown(event: globalThis.MouseEvent) {
401
492
  if (actionsMenuRef.current && !actionsMenuRef.current.contains(event.target as Node)) {
402
- setActionsMenuKey(null);
493
+ setActionsMenu(null);
403
494
  }
404
495
  }
405
496
 
406
497
  document.addEventListener("mousedown", onPointerDown);
407
498
  return () => document.removeEventListener("mousedown", onPointerDown);
408
- }, [actionsMenuKey]);
499
+ }, [actionsMenu]);
500
+
501
+ useEffect(() => {
502
+ if (!toolsDrawerOpen) {
503
+ return;
504
+ }
505
+
506
+ function onKeyDown(event: KeyboardEvent) {
507
+ if (event.key === "Escape") {
508
+ setToolsDrawerOpen(false);
509
+ }
510
+ }
511
+
512
+ document.addEventListener("keydown", onKeyDown);
513
+ const previousOverflow = document.body.style.overflow;
514
+ document.body.style.overflow = "hidden";
515
+ return () => {
516
+ document.removeEventListener("keydown", onKeyDown);
517
+ document.body.style.overflow = previousOverflow;
518
+ };
519
+ }, [toolsDrawerOpen]);
409
520
 
410
521
  function requestDownload(format: ExportDownloadFormat) {
411
522
  if (!onDownload || downloadBusy) {
@@ -415,6 +526,30 @@ export function RecordWorkspace<T>({
415
526
  setDownloadOpen(false);
416
527
  }
417
528
 
529
+ async function submitBulkImport() {
530
+ if (!onBulkImport || importBusy || !bulkFile) {
531
+ return;
532
+ }
533
+ setBulkReport(null);
534
+ try {
535
+ const report = await onBulkImport(bulkMode, bulkFile);
536
+ setBulkReport(report);
537
+ if (report.applied) {
538
+ setBulkFile(null);
539
+ if (bulkFileInputRef.current) {
540
+ bulkFileInputRef.current.value = "";
541
+ }
542
+ }
543
+ } catch {
544
+ setBulkReport({
545
+ totalRows: 0,
546
+ failed: 1,
547
+ applied: false,
548
+ errors: [{ row: 0, code: "ImportFailed", message: "Import request failed." }],
549
+ });
550
+ }
551
+ }
552
+
418
553
  function cellText(column: TableColumn<T>, item: T) {
419
554
  const raw = column.display ? column.display(item) : column.value(item);
420
555
  return raw === null || raw === undefined || raw === "" ? "—" : String(raw);
@@ -470,6 +605,9 @@ export function RecordWorkspace<T>({
470
605
  }
471
606
 
472
607
  function showTooltip(item: T, event: MouseEvent) {
608
+ if (!canShowTooltip) {
609
+ return;
610
+ }
473
611
  const padding = 16;
474
612
  const width = 280;
475
613
  const x = Math.min(event.clientX + 14, window.innerWidth - width - padding);
@@ -477,6 +615,22 @@ export function RecordWorkspace<T>({
477
615
  setTooltip({ x, y, lines: detailLines(item) });
478
616
  }
479
617
 
618
+ function openRowActions(item: T, event: MouseEvent<HTMLButtonElement>) {
619
+ event.stopPropagation();
620
+ setTooltip(null);
621
+ const key = rowKey(item);
622
+ if (actionsMenu?.key === key) {
623
+ setActionsMenu(null);
624
+ return;
625
+ }
626
+ const rect = event.currentTarget.getBoundingClientRect();
627
+ const menuWidth = 168;
628
+ const menuHeight = 148;
629
+ const x = Math.min(Math.max(8, rect.right - menuWidth), window.innerWidth - menuWidth - 8);
630
+ const y = Math.min(rect.bottom + 6, window.innerHeight - menuHeight - 8);
631
+ setActionsMenu({ key, item, x, y });
632
+ }
633
+
480
634
  const summary =
481
635
  sorted.length === 0
482
636
  ? "0 records"
@@ -484,6 +638,309 @@ export function RecordWorkspace<T>({
484
638
  ? `1–${sorted.length} of ${sorted.length}`
485
639
  : `${start + 1}–${Math.min(start + numericPageSize, sorted.length)} of ${sorted.length}`;
486
640
 
641
+ function renderToolsBody({ compactIcons }: { compactIcons: boolean }) {
642
+ if (compactIcons) {
643
+ return (
644
+ <div className="side-rail-icons">
645
+ <label className="side-rail-icon preview-check-icon" title="Preview">
646
+ <input
647
+ type="checkbox"
648
+ checked={previewEnabled}
649
+ onChange={(event) => setPreviewEnabledValue(event.target.checked)}
650
+ aria-label="Preview"
651
+ />
652
+ <PreviewIcon />
653
+ </label>
654
+ {showAdd ? (
655
+ <button type="button" className="ghost side-rail-icon" title={addLabel} aria-label={addLabel} onClick={onAdd}>
656
+ <PlusIcon />
657
+ </button>
658
+ ) : null}
659
+ {showColumns ? (
660
+ <button
661
+ type="button"
662
+ className="ghost side-rail-icon"
663
+ title="Columns"
664
+ aria-label="Columns"
665
+ onClick={() => {
666
+ setSideOpen(true);
667
+ setColumnsOpen(true);
668
+ }}
669
+ >
670
+ <ColumnsIcon />
671
+ {hiddenColumnCount > 0 ? <span className="filter-badge">{hiddenColumnCount}</span> : null}
672
+ </button>
673
+ ) : null}
674
+ {showDownload ? (
675
+ <button
676
+ type="button"
677
+ className="ghost side-rail-icon"
678
+ title="Download"
679
+ aria-label="Download"
680
+ disabled={downloadBusy}
681
+ onClick={() => {
682
+ setSideOpen(true);
683
+ setDownloadOpen(true);
684
+ }}
685
+ >
686
+ <DownloadIcon />
687
+ </button>
688
+ ) : null}
689
+ {showBulkImport ? (
690
+ <button
691
+ type="button"
692
+ className="ghost side-rail-icon"
693
+ title="Bulk entry"
694
+ aria-label="Bulk entry"
695
+ disabled={importBusy}
696
+ onClick={() => {
697
+ setSideOpen(true);
698
+ setBulkOpen(true);
699
+ }}
700
+ >
701
+ <UploadIcon />
702
+ </button>
703
+ ) : null}
704
+ {showFilters ? (
705
+ <button
706
+ type="button"
707
+ className="ghost side-rail-icon"
708
+ title="Show filters"
709
+ aria-label="Show filters"
710
+ onClick={() => setSideOpen(true)}
711
+ >
712
+ <FilterIcon />
713
+ {activeFilterCount > 0 ? <span className="filter-badge">{activeFilterCount}</span> : null}
714
+ </button>
715
+ ) : null}
716
+ {showFilters ? (
717
+ <button
718
+ type="button"
719
+ className="ghost side-rail-icon"
720
+ title="Reset filters"
721
+ aria-label="Reset filters"
722
+ disabled={activeFilterCount === 0}
723
+ onClick={clearFilters}
724
+ >
725
+ <ResetIcon />
726
+ </button>
727
+ ) : null}
728
+ </div>
729
+ );
730
+ }
731
+
732
+ return (
733
+ <div className="side-rail-body">
734
+ <div className="preview-check">
735
+ <input
736
+ type="checkbox"
737
+ checked={previewEnabled}
738
+ onChange={(event) => setPreviewEnabledValue(event.target.checked)}
739
+ aria-label="Preview"
740
+ />
741
+ <PreviewIcon />
742
+ <span aria-hidden="true">Preview</span>
743
+ </div>
744
+
745
+ {showAdd ? (
746
+ <button
747
+ type="button"
748
+ className="side-rail-add"
749
+ onClick={() => {
750
+ setToolsDrawerOpen(false);
751
+ onAdd?.();
752
+ }}
753
+ >
754
+ <PlusIcon />
755
+ <span>{addLabel}</span>
756
+ </button>
757
+ ) : null}
758
+
759
+ {showColumns ? (
760
+ <div className="side-rail-columns">
761
+ <button
762
+ type="button"
763
+ className="ghost side-rail-columns-toggle"
764
+ aria-expanded={columnsOpen}
765
+ onClick={() => setColumnsOpen((open) => !open)}
766
+ >
767
+ <ColumnsIcon />
768
+ <span>Columns{hiddenColumnCount > 0 ? ` (${visibleColumns.length}/${columns.length})` : ""}</span>
769
+ </button>
770
+ {columnsOpen ? (
771
+ <div className="side-rail-columns-panel" role="group" aria-label="Visible columns">
772
+ <div className="side-rail-columns-actions">
773
+ <button type="button" className="ghost" onClick={selectAllColumns}>
774
+ Select all
775
+ </button>
776
+ <button type="button" className="ghost" onClick={resetColumns}>
777
+ Reset
778
+ </button>
779
+ </div>
780
+ <div className="side-rail-columns-list">
781
+ {columns.map((column) => {
782
+ const checked = resolvedVisibleKeys.includes(column.key);
783
+ const disableUncheck = checked && resolvedVisibleKeys.length <= 1;
784
+ return (
785
+ <label key={column.key} className="side-rail-column-option">
786
+ <input
787
+ type="checkbox"
788
+ checked={checked}
789
+ disabled={disableUncheck}
790
+ onChange={(event) => toggleColumnVisible(column.key, event.target.checked)}
791
+ />
792
+ <span>{column.header}</span>
793
+ </label>
794
+ );
795
+ })}
796
+ </div>
797
+ </div>
798
+ ) : null}
799
+ </div>
800
+ ) : null}
801
+
802
+ {showDownload ? (
803
+ <div className="side-rail-download">
804
+ <button
805
+ type="button"
806
+ className="ghost side-rail-download-toggle"
807
+ aria-expanded={downloadOpen}
808
+ disabled={downloadBusy}
809
+ onClick={() => setDownloadOpen((open) => !open)}
810
+ >
811
+ <DownloadIcon />
812
+ <span>{downloadBusy ? "Downloading…" : "Download"}</span>
813
+ </button>
814
+ {downloadOpen ? (
815
+ <div className="side-rail-download-formats" role="group" aria-label="Download format">
816
+ {EXPORT_FORMAT_OPTIONS.map((option) => (
817
+ <button
818
+ key={option.format}
819
+ type="button"
820
+ className="ghost side-rail-download-format"
821
+ disabled={downloadBusy}
822
+ onClick={() => requestDownload(option.format)}
823
+ >
824
+ {option.label}
825
+ </button>
826
+ ))}
827
+ </div>
828
+ ) : null}
829
+ </div>
830
+ ) : null}
831
+
832
+ {showBulkImport ? (
833
+ <div className="side-rail-bulk">
834
+ <button
835
+ type="button"
836
+ className="ghost side-rail-bulk-toggle"
837
+ aria-expanded={bulkOpen}
838
+ disabled={importBusy}
839
+ onClick={() => setBulkOpen((open) => !open)}
840
+ >
841
+ <UploadIcon />
842
+ <span>{importBusy ? "Importing…" : "Bulk entry"}</span>
843
+ </button>
844
+ {bulkOpen ? (
845
+ <div className="side-rail-bulk-panel" role="group" aria-label="Bulk entry">
846
+ <label className="side-rail-bulk-mode">
847
+ Mode
848
+ <Select
849
+ value={bulkMode}
850
+ options={BULK_IMPORT_MODE_OPTIONS.map((option) => ({ value: option.mode, label: option.label }))}
851
+ onChange={(value) => setBulkMode(value as BulkImportMode)}
852
+ />
853
+ </label>
854
+ <label className="side-rail-bulk-file">
855
+ File
856
+ <input
857
+ ref={bulkFileInputRef}
858
+ type="file"
859
+ accept=".csv,.xlsx,.json"
860
+ disabled={importBusy}
861
+ onChange={(event) => {
862
+ setBulkFile(event.target.files?.[0] ?? null);
863
+ setBulkReport(null);
864
+ }}
865
+ />
866
+ </label>
867
+ <button
868
+ type="button"
869
+ className="side-rail-bulk-submit"
870
+ disabled={importBusy || !bulkFile}
871
+ onClick={() => void submitBulkImport()}
872
+ >
873
+ {importBusy ? "Importing…" : "Import"}
874
+ </button>
875
+ {bulkReport ? (
876
+ <div className="side-rail-bulk-report" role="status">
877
+ <p>
878
+ {bulkReport.applied
879
+ ? `Applied ${bulkReport.totalRows} row(s).`
880
+ : `${bulkReport.failed} error(s) in ${bulkReport.totalRows} row(s).`}
881
+ </p>
882
+ {bulkReport.errors.length > 0 ? (
883
+ <ul className="side-rail-bulk-errors">
884
+ {bulkReport.errors.slice(0, 8).map((error, index) => (
885
+ <li key={`${error.row}-${error.code}-${index}`}>
886
+ Row {error.row}: {error.message}
887
+ </li>
888
+ ))}
889
+ {bulkReport.errors.length > 8 ? (
890
+ <li>…and {bulkReport.errors.length - 8} more</li>
891
+ ) : null}
892
+ </ul>
893
+ ) : null}
894
+ </div>
895
+ ) : null}
896
+ </div>
897
+ ) : null}
898
+ </div>
899
+ ) : null}
900
+
901
+ {showFilters ? (
902
+ <div className="side-rail-filters">
903
+ <div className="filter-panel-head">
904
+ <h3>Filters</h3>
905
+ <button
906
+ type="button"
907
+ className="ghost filter-clear"
908
+ title="Reset filters"
909
+ aria-label="Reset filters"
910
+ disabled={activeFilterCount === 0}
911
+ onClick={clearFilters}
912
+ >
913
+ <ResetIcon />
914
+ <span>Reset</span>
915
+ </button>
916
+ </div>
917
+ <div className="filter-panel-stack">
918
+ {filterableColumns.map((column) => (
919
+ <label key={column.key}>
920
+ {column.header}
921
+ {column.filterOptions ? (
922
+ <Select
923
+ value={filters[column.key] ?? ""}
924
+ options={[{ value: "", label: column.filterPlaceholder ?? "All" }, ...column.filterOptions]}
925
+ onChange={(value) => changeFilter(column.key, value)}
926
+ placeholder={column.filterPlaceholder ?? "All"}
927
+ />
928
+ ) : (
929
+ <input
930
+ value={filters[column.key] ?? ""}
931
+ onChange={(event) => changeFilter(column.key, event.target.value)}
932
+ placeholder={`Filter ${column.header.toLowerCase()}`}
933
+ />
934
+ )}
935
+ </label>
936
+ ))}
937
+ </div>
938
+ </div>
939
+ ) : null}
940
+ </div>
941
+ );
942
+ }
943
+
487
944
  function renderRowActions(item: T) {
488
945
  const key = rowKey(item);
489
946
  const updateAllowed = canUpdate ? canUpdate(item) : Boolean(onUpdate);
@@ -525,69 +982,60 @@ export function RecordWorkspace<T>({
525
982
  ) : null}
526
983
  </div>
527
984
 
528
- <div className="row-actions row-actions-mobile" ref={actionsMenuKey === key ? actionsMenuRef : undefined}>
985
+ <div className="row-actions row-actions-mobile">
529
986
  <button
530
987
  type="button"
531
- className="ghost row-action-menu-trigger"
988
+ className={actionsMenu?.key === key ? "ghost row-action-menu-trigger open" : "ghost row-action-menu-trigger"}
532
989
  aria-haspopup="menu"
533
- aria-expanded={actionsMenuKey === key}
990
+ aria-expanded={actionsMenu?.key === key}
534
991
  aria-label="Row actions"
535
- onClick={() => setActionsMenuKey((current) => (current === key ? null : key))}
992
+ onClick={(event) => openRowActions(item, event)}
536
993
  >
537
994
  <MoreIcon />
538
995
  </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
996
  </div>
585
997
  </>
586
998
  );
587
999
  }
588
1000
 
1001
+ const actionsMenuItem = actionsMenu?.item;
1002
+ const actionsUpdateAllowed = actionsMenuItem
1003
+ ? canUpdate
1004
+ ? canUpdate(actionsMenuItem)
1005
+ : Boolean(onUpdate)
1006
+ : false;
1007
+ const actionsDeleteAllowed = actionsMenuItem
1008
+ ? canDelete
1009
+ ? canDelete(actionsMenuItem)
1010
+ : Boolean(onDelete)
1011
+ : false;
1012
+
589
1013
  return (
590
- <div className="list-workspace" aria-label={title}>
1014
+ <div className={toolsDrawerOpen ? "list-workspace tools-drawer-open" : "list-workspace"} aria-label={title}>
1015
+ <div className="tools-mobile-bar">
1016
+ <div className="tools-mobile-bar-start">
1017
+ {showAdd ? (
1018
+ <button type="button" className="tools-mobile-add" onClick={onAdd}>
1019
+ <PlusIcon />
1020
+ <span>{addLabel}</span>
1021
+ </button>
1022
+ ) : (
1023
+ <p className="tools-mobile-title">{title}</p>
1024
+ )}
1025
+ </div>
1026
+ <button
1027
+ type="button"
1028
+ className="ghost tools-mobile-toggle"
1029
+ aria-expanded={toolsDrawerOpen}
1030
+ aria-controls="aksui-tools-drawer"
1031
+ onClick={() => setToolsDrawerOpen(true)}
1032
+ >
1033
+ <ToolsIcon />
1034
+ <span>Tools</span>
1035
+ {toolsBadgeCount > 0 ? <span className="filter-badge">{toolsBadgeCount}</span> : null}
1036
+ </button>
1037
+ </div>
1038
+
591
1039
  <div
592
1040
  className={["list-body", sideOpen ? "side-open" : "", previewEnabled ? "preview-open" : ""]
593
1041
  .filter(Boolean)
@@ -651,16 +1099,10 @@ export function RecordWorkspace<T>({
651
1099
  key={key}
652
1100
  className={selected ? "data-row selected" : "data-row"}
653
1101
  onMouseEnter={(event) => {
654
- if (!previewEnabled) {
655
- showTooltip(item, event);
656
- }
1102
+ showTooltip(item, event);
657
1103
  focusRecord(item);
658
1104
  }}
659
- onMouseMove={(event) => {
660
- if (!previewEnabled) {
661
- showTooltip(item, event);
662
- }
663
- }}
1105
+ onMouseMove={(event) => showTooltip(item, event)}
664
1106
  onMouseLeave={() => setTooltip(null)}
665
1107
  onClick={() => focusRecord(item)}
666
1108
  >
@@ -704,7 +1146,7 @@ export function RecordWorkspace<T>({
704
1146
  {previewItem ? (
705
1147
  <DetailList lines={detailLines(previewItem)} />
706
1148
  ) : (
707
- <p className="preview-empty">Hover or click a row to preview its details here.</p>
1149
+ <p className="preview-empty">Select a row to preview its details here.</p>
708
1150
  )}
709
1151
  </div>
710
1152
  </aside>
@@ -721,219 +1163,99 @@ export function RecordWorkspace<T>({
721
1163
  <ChevronIcon open={sideOpen} />
722
1164
  {sideOpen ? <span>Collapse</span> : null}
723
1165
  </button>
1166
+ {sideOpen ? renderToolsBody({ compactIcons: false }) : renderToolsBody({ compactIcons: true })}
1167
+ </aside>
1168
+ </div>
724
1169
 
725
- {sideOpen ? (
726
- <div className="side-rail-body">
727
- <div className="preview-check">
728
- <input
729
- type="checkbox"
730
- checked={previewEnabled}
731
- onChange={(event) => setPreviewEnabledValue(event.target.checked)}
732
- aria-label="Preview"
733
- />
734
- <PreviewIcon />
735
- <span aria-hidden="true">Preview</span>
736
- </div>
737
-
738
- {showAdd ? (
739
- <button type="button" className="side-rail-add" onClick={onAdd}>
740
- <PlusIcon />
741
- <span>{addLabel}</span>
742
- </button>
743
- ) : null}
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>
1170
+ {isClient
1171
+ ? createPortal(
1172
+ <>
1173
+ <button
1174
+ type="button"
1175
+ className={toolsDrawerOpen ? "tools-drawer-backdrop open" : "tools-drawer-backdrop"}
1176
+ aria-label="Close tools"
1177
+ tabIndex={toolsDrawerOpen ? 0 : -1}
1178
+ onClick={() => setToolsDrawerOpen(false)}
1179
+ />
1180
+ <aside
1181
+ id="aksui-tools-drawer"
1182
+ className={toolsDrawerOpen ? "tools-drawer open" : "tools-drawer"}
1183
+ aria-label="Workspace tools"
1184
+ aria-hidden={!toolsDrawerOpen}
1185
+ >
1186
+ <div className="tools-drawer-head">
1187
+ <div>
1188
+ <p className="tools-drawer-kicker">Workspace</p>
1189
+ <h3>Tools</h3>
1190
+ </div>
1191
+ <button type="button" className="ghost tools-drawer-close" onClick={() => setToolsDrawerOpen(false)}>
1192
+ Close
755
1193
  </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
1194
  </div>
786
- ) : null}
1195
+ <div className="tools-drawer-body">{renderToolsBody({ compactIcons: false })}</div>
1196
+ </aside>
1197
+ </>,
1198
+ document.body,
1199
+ )
1200
+ : null}
787
1201
 
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}
1202
+ {form}
817
1203
 
818
- {showFilters ? (
819
- <div className="side-rail-filters">
820
- <div className="filter-panel-head">
821
- <h3>Filters</h3>
822
- <button
823
- type="button"
824
- className="ghost filter-clear"
825
- title="Reset filters"
826
- aria-label="Reset filters"
827
- disabled={activeFilterCount === 0}
828
- onClick={clearFilters}
829
- >
830
- <ResetIcon />
831
- <span>Reset</span>
832
- </button>
833
- </div>
834
- <div className="filter-panel-stack">
835
- {filterableColumns.map((column) => (
836
- <label key={column.key}>
837
- {column.header}
838
- {column.filterOptions ? (
839
- <Select
840
- value={filters[column.key] ?? ""}
841
- options={[{ value: "", label: column.filterPlaceholder ?? "All" }, ...column.filterOptions]}
842
- onChange={(value) => changeFilter(column.key, value)}
843
- placeholder={column.filterPlaceholder ?? "All"}
844
- />
845
- ) : (
846
- <input
847
- value={filters[column.key] ?? ""}
848
- onChange={(event) => changeFilter(column.key, event.target.value)}
849
- placeholder={`Filter ${column.header.toLowerCase()}`}
850
- />
851
- )}
852
- </label>
853
- ))}
854
- </div>
855
- </div>
856
- ) : null}
857
- </div>
858
- ) : (
859
- <div className="side-rail-icons">
860
- <label className="side-rail-icon preview-check-icon" title="Preview">
861
- <input
862
- type="checkbox"
863
- checked={previewEnabled}
864
- onChange={(event) => setPreviewEnabledValue(event.target.checked)}
865
- aria-label="Preview"
866
- />
867
- <PreviewIcon />
868
- </label>
869
- {showAdd ? (
870
- <button type="button" className="ghost side-rail-icon" title={addLabel} aria-label={addLabel} onClick={onAdd}>
871
- <PlusIcon />
872
- </button>
873
- ) : null}
874
- {showColumns ? (
1204
+ {isClient && actionsMenu && actionsMenuItem
1205
+ ? createPortal(
1206
+ <div
1207
+ ref={actionsMenuRef}
1208
+ className="row-action-menu row-action-menu-portal"
1209
+ role="menu"
1210
+ style={{ left: actionsMenu.x, top: actionsMenu.y }}
1211
+ >
1212
+ {onView ? (
875
1213
  <button
876
1214
  type="button"
877
- className="ghost side-rail-icon"
878
- title="Columns"
879
- aria-label="Columns"
1215
+ role="menuitem"
1216
+ className="ghost"
880
1217
  onClick={() => {
881
- setSideOpen(true);
882
- setColumnsOpen(true);
1218
+ setActionsMenu(null);
1219
+ onView(actionsMenuItem);
883
1220
  }}
884
1221
  >
885
- <ColumnsIcon />
886
- {hiddenColumnCount > 0 ? <span className="filter-badge">{hiddenColumnCount}</span> : null}
1222
+ View
887
1223
  </button>
888
1224
  ) : null}
889
- {showDownload ? (
1225
+ {onUpdate ? (
890
1226
  <button
891
1227
  type="button"
892
- className="ghost side-rail-icon"
893
- title="Download"
894
- aria-label="Download"
895
- aria-expanded={downloadOpen}
896
- disabled={downloadBusy}
1228
+ role="menuitem"
1229
+ className="ghost"
1230
+ disabled={!actionsUpdateAllowed}
897
1231
  onClick={() => {
898
- setSideOpen(true);
899
- setDownloadOpen(true);
1232
+ setActionsMenu(null);
1233
+ onUpdate(actionsMenuItem);
900
1234
  }}
901
1235
  >
902
- <DownloadIcon />
903
- </button>
904
- ) : null}
905
- {showFilters ? (
906
- <button
907
- type="button"
908
- className="ghost side-rail-icon"
909
- title="Show filters"
910
- aria-label="Show filters"
911
- onClick={() => setSideOpen(true)}
912
- >
913
- <FilterIcon />
914
- {activeFilterCount > 0 ? <span className="filter-badge">{activeFilterCount}</span> : null}
1236
+ Update
915
1237
  </button>
916
1238
  ) : null}
917
- {showFilters ? (
1239
+ {onDelete ? (
918
1240
  <button
919
1241
  type="button"
920
- className="ghost side-rail-icon"
921
- title="Reset filters"
922
- aria-label="Reset filters"
923
- disabled={activeFilterCount === 0}
924
- onClick={clearFilters}
1242
+ role="menuitem"
1243
+ className="ghost danger"
1244
+ disabled={!actionsDeleteAllowed}
1245
+ onClick={() => {
1246
+ setActionsMenu(null);
1247
+ onDelete(actionsMenuItem);
1248
+ }}
925
1249
  >
926
- <ResetIcon />
1250
+ Delete
927
1251
  </button>
928
1252
  ) : null}
929
- </div>
930
- )}
931
- </aside>
932
- </div>
933
-
934
- {form}
1253
+ </div>,
1254
+ document.body,
1255
+ )
1256
+ : null}
935
1257
 
936
- {isClient && tooltip && !previewEnabled
1258
+ {isClient && tooltip && canShowTooltip
937
1259
  ? createPortal(
938
1260
  <div className="record-tooltip" style={{ left: tooltip.x, top: tooltip.y }} role="tooltip">
939
1261
  <p className="record-tooltip-title">{title}</p>