@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.
@@ -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,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
- /** When set, shows CSV / Excel / JSON download controls in the side rail. */
174
- onDownload?: (format: ExportDownloadFormat) => void;
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 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
+ );
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
- activeFilters.every((column) => {
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, columns]);
359
+ }, [items, filters, filterableColumns]);
210
360
 
211
361
  const sorted = useMemo(() => {
212
362
  if (!sort) {
213
363
  return filtered;
214
364
  }
215
- const column = columns.find((entry) => entry.key === sort.key);
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, columns]);
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 columns.map((column) => ({
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 && columns[0] ? cellText(columns[0], previewItem) : null;
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
- {columns.map((column) => {
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={columns.length + 1}>
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
- {columns.map((column) => (
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
- <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>
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"
@@ -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 [menuOpen, setMenuOpen] = useState(false);
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 (!menuOpen) {
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
- setMenuOpen(false);
51
+ setAccountOpen(false);
33
52
  }
34
53
  }
35
54
 
36
55
  document.addEventListener("mousedown", onPointerDown);
37
56
  return () => document.removeEventListener("mousedown", onPointerDown);
38
- }, [menuOpen]);
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={menuOpen}
64
- onClick={() => setMenuOpen((open) => !open)}
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
- {menuOpen ? (
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
- setMenuOpen(false);
106
+ setAccountOpen(false);
77
107
  onLogout();
78
108
  }}
79
109
  >