@mshuja288/aksuicore 0.2.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.
@@ -0,0 +1,567 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState, useSyncExternalStore, type MouseEvent, type ReactNode } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { Pagination } from "../primitives/Pagination";
6
+ import { Select, type SelectOption } from "../primitives/Select";
7
+
8
+ function useIsClient() {
9
+ return useSyncExternalStore(
10
+ () => () => {},
11
+ () => true,
12
+ () => false,
13
+ );
14
+ }
15
+
16
+ export type TableColumn<T> = {
17
+ key: string;
18
+ header: string;
19
+ value: (item: T) => string | number | null | undefined;
20
+ display?: (item: T) => string | number | null | undefined;
21
+ filterable?: boolean;
22
+ sortable?: boolean;
23
+ /** When set, filter uses an exact-match Select instead of text contains. */
24
+ filterOptions?: SelectOption[];
25
+ filterPlaceholder?: string;
26
+ };
27
+
28
+ type SortDirection = "asc" | "desc";
29
+ type SortState = { key: string; direction: SortDirection };
30
+ type DetailLine = { label: string; value: string };
31
+ type TooltipState = { x: number; y: number; lines: DetailLine[] };
32
+
33
+ function compareColumnValues<T>(column: TableColumn<T>, left: T, right: T) {
34
+ const leftRaw = column.display ? column.display(left) : column.value(left);
35
+ const rightRaw = column.display ? column.display(right) : column.value(right);
36
+
37
+ if (leftRaw === null || leftRaw === undefined || leftRaw === "") {
38
+ return rightRaw === null || rightRaw === undefined || rightRaw === "" ? 0 : 1;
39
+ }
40
+ if (rightRaw === null || rightRaw === undefined || rightRaw === "") {
41
+ return -1;
42
+ }
43
+
44
+ if (typeof leftRaw === "number" && typeof rightRaw === "number") {
45
+ return leftRaw - rightRaw;
46
+ }
47
+
48
+ const leftText = String(leftRaw);
49
+ const rightText = String(rightRaw);
50
+ const leftNumber = Number(leftText);
51
+ const rightNumber = Number(rightText);
52
+ if (
53
+ leftText.trim() !== "" &&
54
+ rightText.trim() !== "" &&
55
+ Number.isFinite(leftNumber) &&
56
+ Number.isFinite(rightNumber) &&
57
+ /^-?\d+(\.\d+)?$/.test(leftText.trim()) &&
58
+ /^-?\d+(\.\d+)?$/.test(rightText.trim())
59
+ ) {
60
+ return leftNumber - rightNumber;
61
+ }
62
+
63
+ return leftText.localeCompare(rightText, undefined, { sensitivity: "base", numeric: true });
64
+ }
65
+
66
+ function PlusIcon() {
67
+ return (
68
+ <svg viewBox="0 0 20 20" aria-hidden="true">
69
+ <path d="M10 4v12M4 10h12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
70
+ </svg>
71
+ );
72
+ }
73
+
74
+ function FilterIcon() {
75
+ return (
76
+ <svg viewBox="0 0 20 20" aria-hidden="true">
77
+ <path d="M3 5h14M5.5 10h9M8 15h4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
78
+ </svg>
79
+ );
80
+ }
81
+
82
+ function ResetIcon() {
83
+ return (
84
+ <svg viewBox="0 0 20 20" aria-hidden="true">
85
+ <path d="M4.5 8.2A6 6 0 1 1 4.8 13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
86
+ <path d="M4.5 4.2v4h4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
87
+ </svg>
88
+ );
89
+ }
90
+
91
+ function ChevronIcon({ open }: { open: boolean }) {
92
+ return (
93
+ <svg className={open ? "side-rail-chevron open" : "side-rail-chevron"} viewBox="0 0 20 20" aria-hidden="true">
94
+ <path d="M8 4.5 13.5 10 8 15.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
95
+ </svg>
96
+ );
97
+ }
98
+
99
+ function PreviewIcon() {
100
+ return (
101
+ <svg viewBox="0 0 20 20" aria-hidden="true">
102
+ <path d="M2.8 10S5.5 4.8 10 4.8 17.2 10 17.2 10 14.5 15.2 10 15.2 2.8 10 2.8 10Z" fill="none" stroke="currentColor" strokeWidth="1.6" />
103
+ <circle cx="10" cy="10" r="2.4" fill="none" stroke="currentColor" strokeWidth="1.6" />
104
+ </svg>
105
+ );
106
+ }
107
+
108
+ function DetailList({ lines }: { lines: DetailLine[] }) {
109
+ return (
110
+ <dl className="record-detail-list">
111
+ {lines.map((line) => (
112
+ <div key={line.label} className="record-detail-row">
113
+ <dt>{line.label}</dt>
114
+ <dd>{line.value}</dd>
115
+ </div>
116
+ ))}
117
+ </dl>
118
+ );
119
+ }
120
+
121
+ export function RecordWorkspace<T>({
122
+ title,
123
+ columns,
124
+ items,
125
+ rowKey,
126
+ empty,
127
+ onAdd,
128
+ addLabel = "Add",
129
+ canAdd = true,
130
+ onView,
131
+ onUpdate,
132
+ onDelete,
133
+ canUpdate,
134
+ canDelete,
135
+ toolbar,
136
+ form,
137
+ }: {
138
+ title: string;
139
+ columns: TableColumn<T>[];
140
+ items: T[];
141
+ rowKey: (item: T) => string | number;
142
+ empty: string;
143
+ onAdd?: () => void;
144
+ addLabel?: string;
145
+ canAdd?: boolean;
146
+ onView?: (item: T) => void;
147
+ onUpdate?: (item: T) => void;
148
+ onDelete?: (item: T) => void;
149
+ canUpdate?: (item: T) => boolean;
150
+ canDelete?: (item: T) => boolean;
151
+ toolbar?: ReactNode;
152
+ /** Slot for Add/Update/View dialogs owned by the host app. */
153
+ form?: ReactNode;
154
+ }) {
155
+ const filterableColumns = columns.filter((column) => column.filterable !== false);
156
+ const [filters, setFilters] = useState<Record<string, string>>({});
157
+ const [sort, setSort] = useState<SortState | null>(null);
158
+ const [sideOpen, setSideOpen] = useState(false);
159
+ const [previewEnabled, setPreviewEnabled] = useState(false);
160
+ const [previewKey, setPreviewKey] = useState<string | number | null>(null);
161
+ const [tooltip, setTooltip] = useState<TooltipState | null>(null);
162
+ const [pageSize, setPageSize] = useState("15");
163
+ const [page, setPage] = useState(1);
164
+ const isClient = useIsClient();
165
+
166
+ const filtered = useMemo(() => {
167
+ const activeFilters = columns.filter((column) => column.filterable !== false);
168
+ return items.filter((item) =>
169
+ activeFilters.every((column) => {
170
+ const query = (filters[column.key] ?? "").trim();
171
+ if (!query) {
172
+ return true;
173
+ }
174
+ const raw = column.value(item);
175
+ const text = raw === null || raw === undefined ? "" : String(raw);
176
+ if (column.filterOptions) {
177
+ return text === query;
178
+ }
179
+ return text.toLowerCase().includes(query.toLowerCase());
180
+ }),
181
+ );
182
+ }, [items, filters, columns]);
183
+
184
+ const sorted = useMemo(() => {
185
+ if (!sort) {
186
+ return filtered;
187
+ }
188
+ const column = columns.find((entry) => entry.key === sort.key);
189
+ if (!column || column.sortable === false) {
190
+ return filtered;
191
+ }
192
+ const direction = sort.direction === "asc" ? 1 : -1;
193
+ return [...filtered].sort((left, right) => compareColumnValues(column, left, right) * direction);
194
+ }, [filtered, sort, columns]);
195
+
196
+ const activeFilterCount = useMemo(
197
+ () => filterableColumns.reduce((count, column) => count + ((filters[column.key] ?? "").trim() ? 1 : 0), 0),
198
+ [filterableColumns, filters],
199
+ );
200
+
201
+ const showAll = pageSize === "all";
202
+ const numericPageSize = showAll ? Math.max(sorted.length, 1) : Number(pageSize);
203
+ const pageCount = showAll ? 1 : Math.max(1, Math.ceil(sorted.length / numericPageSize));
204
+ const currentPage = Math.min(page, pageCount);
205
+ const start = showAll ? 0 : (currentPage - 1) * numericPageSize;
206
+ const pageItems = showAll ? sorted : sorted.slice(start, start + numericPageSize);
207
+ const showAdd = canAdd && Boolean(onAdd);
208
+ const showFilters = filterableColumns.length > 0;
209
+ const previewItem = previewKey === null ? null : sorted.find((item) => rowKey(item) === previewKey) ?? null;
210
+
211
+ function cellText(column: TableColumn<T>, item: T) {
212
+ const raw = column.display ? column.display(item) : column.value(item);
213
+ return raw === null || raw === undefined || raw === "" ? "—" : String(raw);
214
+ }
215
+
216
+ function detailLines(item: T): DetailLine[] {
217
+ return columns.map((column) => ({
218
+ label: column.header,
219
+ value: cellText(column, item),
220
+ }));
221
+ }
222
+
223
+ const previewTitle = previewItem && columns[0] ? cellText(columns[0], previewItem) : null;
224
+
225
+ function changeFilter(key: string, value: string) {
226
+ setFilters((current) => ({ ...current, [key]: value }));
227
+ setPage(1);
228
+ }
229
+
230
+ function clearFilters() {
231
+ setFilters({});
232
+ setPage(1);
233
+ }
234
+
235
+ function toggleSort(column: TableColumn<T>) {
236
+ if (column.sortable === false) {
237
+ return;
238
+ }
239
+ setSort((current) => {
240
+ if (!current || current.key !== column.key) {
241
+ return { key: column.key, direction: "asc" };
242
+ }
243
+ if (current.direction === "asc") {
244
+ return { key: column.key, direction: "desc" };
245
+ }
246
+ return null;
247
+ });
248
+ setPage(1);
249
+ }
250
+
251
+ function setPreviewEnabledValue(enabled: boolean) {
252
+ setPreviewEnabled(enabled);
253
+ setTooltip(null);
254
+ if (!enabled) {
255
+ setPreviewKey(null);
256
+ }
257
+ }
258
+
259
+ function focusRecord(item: T) {
260
+ if (previewEnabled) {
261
+ setPreviewKey(rowKey(item));
262
+ }
263
+ }
264
+
265
+ function showTooltip(item: T, event: MouseEvent) {
266
+ const padding = 16;
267
+ const width = 280;
268
+ const x = Math.min(event.clientX + 14, window.innerWidth - width - padding);
269
+ const y = Math.min(event.clientY + 14, window.innerHeight - padding - 24);
270
+ setTooltip({ x, y, lines: detailLines(item) });
271
+ }
272
+
273
+ const summary =
274
+ sorted.length === 0
275
+ ? "0 records"
276
+ : showAll
277
+ ? `1–${sorted.length} of ${sorted.length}`
278
+ : `${start + 1}–${Math.min(start + numericPageSize, sorted.length)} of ${sorted.length}`;
279
+
280
+ return (
281
+ <div className="list-workspace" aria-label={title}>
282
+ <div
283
+ className={["list-body", sideOpen ? "side-open" : "", previewEnabled ? "preview-open" : ""]
284
+ .filter(Boolean)
285
+ .join(" ")}
286
+ >
287
+ <div className="list-main">
288
+ <section className="table-card">
289
+ {toolbar ? (
290
+ <div className="table-card-bar">
291
+ <div className="table-card-context">{toolbar}</div>
292
+ </div>
293
+ ) : null}
294
+
295
+ <div className="table-wrap">
296
+ <table className="data-table">
297
+ <thead>
298
+ <tr>
299
+ {columns.map((column) => {
300
+ const sortable = column.sortable !== false;
301
+ const active = sort?.key === column.key ? sort.direction : null;
302
+ return (
303
+ <th
304
+ key={column.key}
305
+ aria-sort={active === "asc" ? "ascending" : active === "desc" ? "descending" : "none"}
306
+ >
307
+ {sortable ? (
308
+ <button
309
+ type="button"
310
+ className={active ? `sort-header active ${active}` : "sort-header"}
311
+ onClick={() => toggleSort(column)}
312
+ >
313
+ <span>{column.header}</span>
314
+ <span className="sort-indicator" aria-hidden="true">
315
+ {active === "asc" ? "▲" : active === "desc" ? "▼" : "↕"}
316
+ </span>
317
+ </button>
318
+ ) : (
319
+ column.header
320
+ )}
321
+ </th>
322
+ );
323
+ })}
324
+ <th className="actions-col">Actions</th>
325
+ </tr>
326
+ </thead>
327
+ <tbody>
328
+ {pageItems.length === 0 ? (
329
+ <tr>
330
+ <td colSpan={columns.length + 1}>
331
+ <p className="hint table-empty">
332
+ {items.length === 0 ? empty : "No records match the current filters."}
333
+ </p>
334
+ </td>
335
+ </tr>
336
+ ) : (
337
+ pageItems.map((item) => {
338
+ const key = rowKey(item);
339
+ const updateAllowed = canUpdate ? canUpdate(item) : Boolean(onUpdate);
340
+ const deleteAllowed = canDelete ? canDelete(item) : Boolean(onDelete);
341
+ const selected = previewEnabled && previewKey === key;
342
+ return (
343
+ <tr
344
+ key={key}
345
+ className={selected ? "data-row selected" : "data-row"}
346
+ onMouseEnter={(event) => {
347
+ if (!previewEnabled) {
348
+ showTooltip(item, event);
349
+ }
350
+ focusRecord(item);
351
+ }}
352
+ onMouseMove={(event) => {
353
+ if (!previewEnabled) {
354
+ showTooltip(item, event);
355
+ }
356
+ }}
357
+ onMouseLeave={() => setTooltip(null)}
358
+ onClick={() => focusRecord(item)}
359
+ >
360
+ {columns.map((column) => (
361
+ <td key={column.key}>{cellText(column, item)}</td>
362
+ ))}
363
+ <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>
393
+ </td>
394
+ </tr>
395
+ );
396
+ })
397
+ )}
398
+ </tbody>
399
+ </table>
400
+ </div>
401
+
402
+ <Pagination
403
+ page={currentPage}
404
+ pageCount={pageCount}
405
+ pageSize={pageSize}
406
+ onPageChange={setPage}
407
+ onPageSizeChange={(value) => {
408
+ setPageSize(value);
409
+ setPage(1);
410
+ }}
411
+ summary={summary}
412
+ />
413
+ </section>
414
+ </div>
415
+
416
+ {previewEnabled ? (
417
+ <aside className="preview-panel" aria-label="Record preview">
418
+ <div className="preview-panel-head">
419
+ <div>
420
+ <p className="preview-kicker">Preview</p>
421
+ <h3>{previewTitle ?? "No record selected"}</h3>
422
+ </div>
423
+ </div>
424
+ <div className="preview-panel-body">
425
+ {previewItem ? (
426
+ <DetailList lines={detailLines(previewItem)} />
427
+ ) : (
428
+ <p className="preview-empty">Hover or click a row to preview its details here.</p>
429
+ )}
430
+ </div>
431
+ </aside>
432
+ ) : null}
433
+
434
+ <aside className={sideOpen ? "side-rail open" : "side-rail"} aria-label="Actions and filters">
435
+ <button
436
+ type="button"
437
+ className="ghost side-rail-toggle"
438
+ aria-expanded={sideOpen}
439
+ title={sideOpen ? "Collapse" : "Expand"}
440
+ onClick={() => setSideOpen((open) => !open)}
441
+ >
442
+ <ChevronIcon open={sideOpen} />
443
+ {sideOpen ? <span>Collapse</span> : null}
444
+ </button>
445
+
446
+ {sideOpen ? (
447
+ <div className="side-rail-body">
448
+ <div className="preview-check">
449
+ <input
450
+ type="checkbox"
451
+ checked={previewEnabled}
452
+ onChange={(event) => setPreviewEnabledValue(event.target.checked)}
453
+ aria-label="Preview"
454
+ />
455
+ <PreviewIcon />
456
+ <span aria-hidden="true">Preview</span>
457
+ </div>
458
+
459
+ {showAdd ? (
460
+ <button type="button" className="side-rail-add" onClick={onAdd}>
461
+ <PlusIcon />
462
+ <span>{addLabel}</span>
463
+ </button>
464
+ ) : null}
465
+
466
+ {showFilters ? (
467
+ <div className="side-rail-filters">
468
+ <div className="filter-panel-head">
469
+ <h3>Filters</h3>
470
+ <button
471
+ type="button"
472
+ className="ghost filter-clear"
473
+ title="Reset filters"
474
+ aria-label="Reset filters"
475
+ disabled={activeFilterCount === 0}
476
+ onClick={clearFilters}
477
+ >
478
+ <ResetIcon />
479
+ <span>Reset</span>
480
+ </button>
481
+ </div>
482
+ <div className="filter-panel-stack">
483
+ {filterableColumns.map((column) => (
484
+ <label key={column.key}>
485
+ {column.header}
486
+ {column.filterOptions ? (
487
+ <Select
488
+ value={filters[column.key] ?? ""}
489
+ options={[{ value: "", label: column.filterPlaceholder ?? "All" }, ...column.filterOptions]}
490
+ onChange={(value) => changeFilter(column.key, value)}
491
+ placeholder={column.filterPlaceholder ?? "All"}
492
+ />
493
+ ) : (
494
+ <input
495
+ value={filters[column.key] ?? ""}
496
+ onChange={(event) => changeFilter(column.key, event.target.value)}
497
+ placeholder={`Filter ${column.header.toLowerCase()}`}
498
+ />
499
+ )}
500
+ </label>
501
+ ))}
502
+ </div>
503
+ </div>
504
+ ) : null}
505
+ </div>
506
+ ) : (
507
+ <div className="side-rail-icons">
508
+ <label className="side-rail-icon preview-check-icon" title="Preview">
509
+ <input
510
+ type="checkbox"
511
+ checked={previewEnabled}
512
+ onChange={(event) => setPreviewEnabledValue(event.target.checked)}
513
+ aria-label="Preview"
514
+ />
515
+ <PreviewIcon />
516
+ </label>
517
+ {showAdd ? (
518
+ <button type="button" className="ghost side-rail-icon" title={addLabel} aria-label={addLabel} onClick={onAdd}>
519
+ <PlusIcon />
520
+ </button>
521
+ ) : null}
522
+ {showFilters ? (
523
+ <button
524
+ type="button"
525
+ className="ghost side-rail-icon"
526
+ title="Show filters"
527
+ aria-label="Show filters"
528
+ onClick={() => setSideOpen(true)}
529
+ >
530
+ <FilterIcon />
531
+ {activeFilterCount > 0 ? <span className="filter-badge">{activeFilterCount}</span> : null}
532
+ </button>
533
+ ) : null}
534
+ {showFilters ? (
535
+ <button
536
+ type="button"
537
+ className="ghost side-rail-icon"
538
+ title="Reset filters"
539
+ aria-label="Reset filters"
540
+ disabled={activeFilterCount === 0}
541
+ onClick={clearFilters}
542
+ >
543
+ <ResetIcon />
544
+ </button>
545
+ ) : null}
546
+ </div>
547
+ )}
548
+ </aside>
549
+ </div>
550
+
551
+ {form}
552
+
553
+ {isClient && tooltip && !previewEnabled
554
+ ? createPortal(
555
+ <div className="record-tooltip" style={{ left: tooltip.x, top: tooltip.y }} role="tooltip">
556
+ <p className="record-tooltip-title">{title}</p>
557
+ <DetailList lines={tooltip.lines} />
558
+ </div>,
559
+ document.body,
560
+ )
561
+ : null}
562
+ </div>
563
+ );
564
+ }
565
+
566
+ /** @deprecated Prefer RecordWorkspace — alias kept for configurator migration */
567
+ export const RecordTable = RecordWorkspace;
@@ -0,0 +1,2 @@
1
+ export { AppShell, type AppShellProps } from "./AppShell";
2
+ export { RecordWorkspace, RecordTable, type TableColumn } from "./RecordWorkspace";
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
4
+
5
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
6
+
7
+ const variantClass: Record<ButtonVariant, string> = {
8
+ primary: "",
9
+ secondary: "secondary",
10
+ ghost: "ghost",
11
+ danger: "danger",
12
+ };
13
+
14
+ export function Button({
15
+ variant = "primary",
16
+ className = "",
17
+ children,
18
+ ...props
19
+ }: ButtonHTMLAttributes<HTMLButtonElement> & {
20
+ variant?: ButtonVariant;
21
+ children: ReactNode;
22
+ }) {
23
+ const classes = ["aks-button", variantClass[variant], className].filter(Boolean).join(" ");
24
+ return (
25
+ <button type="button" className={classes} {...props}>
26
+ {children}
27
+ </button>
28
+ );
29
+ }