@mshuja288/aksuicore 0.2.1 → 0.4.0

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