@a4ui/core 0.33.0 → 0.35.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.
Files changed (58) hide show
  1. package/dist/elements.css +299 -0
  2. package/dist/full.css +299 -0
  3. package/dist/index.d.ts +27 -1
  4. package/dist/index.js +9140 -5065
  5. package/dist/ui/ActivityFeed.d.ts +49 -0
  6. package/dist/ui/AudioWaveform.d.ts +22 -0
  7. package/dist/ui/AvailabilityPicker.d.ts +35 -0
  8. package/dist/ui/CodeEditor.d.ts +25 -0
  9. package/dist/ui/CouponField.d.ts +33 -0
  10. package/dist/ui/EmojiPicker.d.ts +18 -0
  11. package/dist/ui/EventScheduler.d.ts +41 -0
  12. package/dist/ui/FollowingPointer.d.ts +24 -0
  13. package/dist/ui/GanttChart.d.ts +37 -0
  14. package/dist/ui/InteractiveMap.d.ts +50 -0
  15. package/dist/ui/JsonViewer.d.ts +24 -0
  16. package/dist/ui/Kanban.d.ts +49 -0
  17. package/dist/ui/Lamp.d.ts +22 -0
  18. package/dist/ui/Lightbox.d.ts +41 -0
  19. package/dist/ui/LocationPicker.d.ts +25 -0
  20. package/dist/ui/MaskedInput.d.ts +26 -0
  21. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  22. package/dist/ui/OtpInput.d.ts +29 -0
  23. package/dist/ui/PivotTable.d.ts +37 -0
  24. package/dist/ui/PresenceAvatars.d.ts +54 -0
  25. package/dist/ui/QueryBuilder.d.ts +56 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/SignaturePad.d.ts +19 -0
  28. package/dist/ui/SpreadsheetGrid.d.ts +27 -0
  29. package/dist/ui/TreeTable.d.ts +52 -0
  30. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  31. package/package.json +1 -1
  32. package/src/index.ts +60 -1
  33. package/src/ui/ActivityFeed.tsx +178 -0
  34. package/src/ui/AudioWaveform.tsx +190 -0
  35. package/src/ui/AvailabilityPicker.tsx +163 -0
  36. package/src/ui/CodeEditor.tsx +277 -0
  37. package/src/ui/CouponField.tsx +120 -0
  38. package/src/ui/EmojiPicker.tsx +424 -0
  39. package/src/ui/EventScheduler.tsx +280 -0
  40. package/src/ui/FollowingPointer.tsx +112 -0
  41. package/src/ui/GanttChart.tsx +283 -0
  42. package/src/ui/InteractiveMap.tsx +349 -0
  43. package/src/ui/JsonViewer.tsx +189 -0
  44. package/src/ui/Kanban.tsx +250 -0
  45. package/src/ui/Lamp.tsx +88 -0
  46. package/src/ui/Lightbox.tsx +261 -0
  47. package/src/ui/LocationPicker.tsx +96 -0
  48. package/src/ui/MaskedInput.tsx +108 -0
  49. package/src/ui/OnboardingChecklist.tsx +163 -0
  50. package/src/ui/OtpInput.tsx +153 -0
  51. package/src/ui/PivotTable.tsx +0 -0
  52. package/src/ui/PresenceAvatars.tsx +142 -0
  53. package/src/ui/QueryBuilder.tsx +280 -0
  54. package/src/ui/SheetSnap.tsx +264 -0
  55. package/src/ui/SignaturePad.tsx +221 -0
  56. package/src/ui/SpreadsheetGrid.tsx +304 -0
  57. package/src/ui/TreeTable.tsx +172 -0
  58. package/src/ui/VideoPlayerShell.tsx +282 -0
@@ -0,0 +1,221 @@
1
+ // Canvas-based signature capture. DPR-aware sizing (crisp strokes on retina),
2
+ // pointer events (mouse + touch + pen in one listener set), and a stroke stack
3
+ // so Undo can redraw from scratch instead of trying to "erase" a curve.
4
+ import { Eraser, Undo2 } from 'lucide-solid'
5
+ import { createSignal, onCleanup, onMount, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ interface Point {
10
+ x: number
11
+ y: number
12
+ }
13
+
14
+ type Stroke = Point[]
15
+
16
+ export interface SignaturePadProps {
17
+ /** Called with a PNG data URL after each completed stroke, or `null` once the pad is empty (cleared/undone to nothing). */
18
+ onChange?: (dataUrl: string | null) => void
19
+ /** Canvas height in CSS pixels. @default 200 */
20
+ height?: number
21
+ class?: string
22
+ }
23
+
24
+ /**
25
+ * A signature capture pad: draw with mouse, pen, or touch on a DPR-aware
26
+ * `<canvas>`, with Clear and Undo controls. Strokes are kept as point lists so
27
+ * Undo can fully redraw the remaining ones rather than trying to erase pixels.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <SignaturePad height={220} onChange={(dataUrl) => setSignature(dataUrl)} />
32
+ * ```
33
+ */
34
+ export function SignaturePad(props: SignaturePadProps): JSX.Element {
35
+ let canvas!: HTMLCanvasElement
36
+ let container!: HTMLDivElement
37
+ let ctx: CanvasRenderingContext2D | null = null
38
+
39
+ const strokes: Stroke[] = []
40
+ let current: Stroke | null = null
41
+ let drawing = false
42
+
43
+ const [hasInk, setHasInk] = createSignal(false)
44
+
45
+ const height = () => props.height ?? 200
46
+
47
+ const resize = (): void => {
48
+ if (!canvas || !container) return
49
+ const dpr = window.devicePixelRatio || 1
50
+ const width = container.clientWidth
51
+ const h = height()
52
+ canvas.width = Math.max(1, Math.round(width * dpr))
53
+ canvas.height = Math.max(1, Math.round(h * dpr))
54
+ canvas.style.width = `${width}px`
55
+ canvas.style.height = `${h}px`
56
+ ctx = canvas.getContext('2d')
57
+ if (ctx) ctx.scale(dpr, dpr)
58
+ redraw()
59
+ }
60
+
61
+ const strokeColor = (): string => {
62
+ // Resolved at draw time so it stays correct across theme/light-dark toggles.
63
+ const root = getComputedStyle(document.documentElement)
64
+ const hsl = root.getPropertyValue('--foreground').trim()
65
+ return hsl ? `hsl(${hsl})` : '#000'
66
+ }
67
+
68
+ const drawStroke = (context: CanvasRenderingContext2D, stroke: Stroke): void => {
69
+ if (stroke.length < 2) {
70
+ // A single tap/click: draw a dot so it's not silently lost.
71
+ if (stroke.length === 1) {
72
+ context.beginPath()
73
+ context.arc(stroke[0].x, stroke[0].y, 1.25, 0, Math.PI * 2)
74
+ context.fillStyle = strokeColor()
75
+ context.fill()
76
+ }
77
+ return
78
+ }
79
+ context.lineJoin = 'round'
80
+ context.lineCap = 'round'
81
+ context.lineWidth = 2.25
82
+ context.strokeStyle = strokeColor()
83
+ context.beginPath()
84
+ context.moveTo(stroke[0].x, stroke[0].y)
85
+ for (let i = 1; i < stroke.length; i++) context.lineTo(stroke[i].x, stroke[i].y)
86
+ context.stroke()
87
+ }
88
+
89
+ const clearCanvas = (context: CanvasRenderingContext2D): void => {
90
+ const dpr = window.devicePixelRatio || 1
91
+ context.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr)
92
+ }
93
+
94
+ const redraw = (): void => {
95
+ if (!ctx) return
96
+ clearCanvas(ctx)
97
+ for (const stroke of strokes) drawStroke(ctx, stroke)
98
+ setHasInk(strokes.length > 0)
99
+ }
100
+
101
+ const emitChange = (): void => {
102
+ props.onChange?.(strokes.length > 0 ? canvas.toDataURL('image/png') : null)
103
+ }
104
+
105
+ const pointFromEvent = (event: PointerEvent): Point => {
106
+ const rect = canvas.getBoundingClientRect()
107
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top }
108
+ }
109
+
110
+ const onPointerDown = (event: PointerEvent): void => {
111
+ if (!ctx) return
112
+ event.preventDefault()
113
+ drawing = true
114
+ current = [pointFromEvent(event)]
115
+ canvas.setPointerCapture(event.pointerId)
116
+ }
117
+
118
+ const onPointerMove = (event: PointerEvent): void => {
119
+ if (!drawing || !current || !ctx) return
120
+ const point = pointFromEvent(event)
121
+ const prev = current[current.length - 1]
122
+ current.push(point)
123
+ ctx.lineJoin = 'round'
124
+ ctx.lineCap = 'round'
125
+ ctx.lineWidth = 2.25
126
+ ctx.strokeStyle = strokeColor()
127
+ ctx.beginPath()
128
+ ctx.moveTo(prev.x, prev.y)
129
+ ctx.lineTo(point.x, point.y)
130
+ ctx.stroke()
131
+ }
132
+
133
+ const endStroke = (): void => {
134
+ if (!drawing) return
135
+ drawing = false
136
+ if (current && current.length > 0) {
137
+ strokes.push(current)
138
+ setHasInk(true)
139
+ emitChange()
140
+ }
141
+ current = null
142
+ }
143
+
144
+ const onPointerUp = (event: PointerEvent): void => {
145
+ if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId)
146
+ endStroke()
147
+ }
148
+
149
+ const handleClear = (): void => {
150
+ strokes.length = 0
151
+ current = null
152
+ drawing = false
153
+ redraw()
154
+ emitChange()
155
+ }
156
+
157
+ const handleUndo = (): void => {
158
+ strokes.pop()
159
+ redraw()
160
+ emitChange()
161
+ }
162
+
163
+ onMount(() => {
164
+ resize()
165
+ window.addEventListener('resize', resize)
166
+ canvas.addEventListener('pointerdown', onPointerDown)
167
+ canvas.addEventListener('pointermove', onPointerMove)
168
+ canvas.addEventListener('pointerup', onPointerUp)
169
+ canvas.addEventListener('pointercancel', onPointerUp)
170
+ })
171
+
172
+ onCleanup(() => {
173
+ window.removeEventListener('resize', resize)
174
+ canvas.removeEventListener('pointerdown', onPointerDown)
175
+ canvas.removeEventListener('pointermove', onPointerMove)
176
+ canvas.removeEventListener('pointerup', onPointerUp)
177
+ canvas.removeEventListener('pointercancel', onPointerUp)
178
+ })
179
+
180
+ return (
181
+ <div class={cn('flex flex-col gap-2', props.class)} role="group" aria-label="Signature pad">
182
+ <div
183
+ ref={container}
184
+ class="relative w-full touch-none overflow-hidden rounded-md border border-input bg-background"
185
+ style={{ height: `${height()}px` }}
186
+ >
187
+ <canvas
188
+ ref={canvas}
189
+ class="block h-full w-full cursor-crosshair touch-none"
190
+ aria-label="Draw your signature here"
191
+ />
192
+ {!hasInk() && (
193
+ <div class="pointer-events-none absolute inset-x-4 bottom-6 flex flex-col items-center gap-1">
194
+ <span class="w-full border-b border-dashed border-border" />
195
+ <span class="text-xs text-muted-foreground">Sign here</span>
196
+ </div>
197
+ )}
198
+ </div>
199
+ <div class="flex items-center justify-end gap-2">
200
+ <button
201
+ type="button"
202
+ onClick={handleUndo}
203
+ disabled={strokes.length === 0}
204
+ class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50"
205
+ >
206
+ <Undo2 class="h-3.5 w-3.5" />
207
+ Undo
208
+ </button>
209
+ <button
210
+ type="button"
211
+ onClick={handleClear}
212
+ disabled={!hasInk()}
213
+ class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50"
214
+ >
215
+ <Eraser class="h-3.5 w-3.5" />
216
+ Clear
217
+ </button>
218
+ </div>
219
+ </div>
220
+ )
221
+ }
@@ -0,0 +1,304 @@
1
+ // Excel-like editable grid: header row + row-number gutter, keyboard navigation,
2
+ // rectangular range selection, and TSV copy/paste. Fixed rows×cols for v1 (no
3
+ // add-row); for huge grids, virtualize with @tanstack/solid-virtual instead.
4
+ import type { JSX } from 'solid-js'
5
+ import { createMemo, createSignal, For } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ export interface SpreadsheetGridProps {
10
+ /** Number of rows in the grid (fixed for the component's lifetime). */
11
+ rows: number
12
+ /** Number of columns in the grid (fixed for the component's lifetime). */
13
+ cols: number
14
+ /** Initial cell values, seeded once on mount (missing cells default to `''`). */
15
+ data?: string[][]
16
+ /** Called with the full `rows×cols` matrix after any edit or paste. */
17
+ onChange?: (data: string[][]) => void
18
+ /** Column header labels; defaults to `A, B, C, …, Z, AA, AB, …`. */
19
+ columnHeaders?: string[]
20
+ class?: string
21
+ }
22
+
23
+ interface CellPos {
24
+ r: number
25
+ c: number
26
+ }
27
+
28
+ /** Converts a zero-based column index to a spreadsheet-style letter label. */
29
+ function colLabel(index: number): string {
30
+ let n = index
31
+ let label = ''
32
+ while (n >= 0) {
33
+ label = String.fromCharCode(65 + (n % 26)) + label
34
+ n = Math.floor(n / 26) - 1
35
+ }
36
+ return label
37
+ }
38
+
39
+ function seedData(props: SpreadsheetGridProps): string[][] {
40
+ const grid: string[][] = []
41
+ for (let r = 0; r < props.rows; r++) {
42
+ const row: string[] = []
43
+ for (let c = 0; c < props.cols; c++) row.push(props.data?.[r]?.[c] ?? '')
44
+ grid.push(row)
45
+ }
46
+ return grid
47
+ }
48
+
49
+ function cloneData(data: string[][]): string[][] {
50
+ return data.map((row) => [...row])
51
+ }
52
+
53
+ /**
54
+ * Excel-like editable grid. Arrow keys move the active cell; typing or Enter
55
+ * opens an inline editor that commits on Enter/blur and cancels on Escape.
56
+ * Shift+click / Shift+arrow extend a rectangular selection (Ctrl/Cmd+C copies
57
+ * it as TSV, Ctrl/Cmd+V pastes TSV starting at the active cell).
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * const [sheet, setSheet] = createSignal<string[][]>([])
62
+ * <SpreadsheetGrid rows={10} cols={6} onChange={setSheet} />
63
+ * ```
64
+ */
65
+ export function SpreadsheetGrid(props: SpreadsheetGridProps): JSX.Element {
66
+ const [data, setData] = createSignal<string[][]>(seedData(props))
67
+ const [active, setActive] = createSignal<CellPos>({ r: 0, c: 0 })
68
+ const [anchor, setAnchor] = createSignal<CellPos>({ r: 0, c: 0 })
69
+ const [editing, setEditing] = createSignal<CellPos | null>(null)
70
+ const [editValue, setEditValue] = createSignal('')
71
+ let containerRef: HTMLDivElement | undefined
72
+
73
+ const headers = createMemo(
74
+ () => props.columnHeaders ?? Array.from({ length: props.cols }, (_, i) => colLabel(i)),
75
+ )
76
+ const rowIndices = createMemo(() => Array.from({ length: props.rows }, (_, i) => i))
77
+ const colIndices = createMemo(() => Array.from({ length: props.cols }, (_, i) => i))
78
+
79
+ const bounds = createMemo(() => {
80
+ const a = anchor()
81
+ const b = active()
82
+ return {
83
+ minR: Math.min(a.r, b.r),
84
+ maxR: Math.max(a.r, b.r),
85
+ minC: Math.min(a.c, b.c),
86
+ maxC: Math.max(a.c, b.c),
87
+ }
88
+ })
89
+
90
+ function inSelection(r: number, c: number): boolean {
91
+ const b = bounds()
92
+ return r >= b.minR && r <= b.maxR && c >= b.minC && c <= b.maxC
93
+ }
94
+
95
+ function isActive(r: number, c: number): boolean {
96
+ const a = active()
97
+ return a.r === r && a.c === c
98
+ }
99
+
100
+ function isAnchor(r: number, c: number): boolean {
101
+ const a = anchor()
102
+ return a.r === r && a.c === c
103
+ }
104
+
105
+ function isEditing(r: number, c: number): boolean {
106
+ const e = editing()
107
+ return e !== null && e.r === r && e.c === c
108
+ }
109
+
110
+ function commitCell(r: number, c: number, value: string) {
111
+ const next = cloneData(data())
112
+ next[r][c] = value
113
+ setData(next)
114
+ props.onChange?.(next)
115
+ }
116
+
117
+ function startEdit(r: number, c: number, initial?: string) {
118
+ setEditing({ r, c })
119
+ setEditValue(initial ?? data()[r][c])
120
+ }
121
+
122
+ function commitEdit() {
123
+ const e = editing()
124
+ if (!e) return
125
+ commitCell(e.r, e.c, editValue())
126
+ setEditing(null)
127
+ }
128
+
129
+ function cancelEdit() {
130
+ setEditing(null)
131
+ }
132
+
133
+ function moveActive(dr: number, dc: number, extend: boolean) {
134
+ const cur = active()
135
+ const r = Math.min(Math.max(cur.r + dr, 0), props.rows - 1)
136
+ const c = Math.min(Math.max(cur.c + dc, 0), props.cols - 1)
137
+ setActive({ r, c })
138
+ if (!extend) setAnchor({ r, c })
139
+ }
140
+
141
+ function selectCell(r: number, c: number, extend: boolean) {
142
+ setActive({ r, c })
143
+ if (!extend) setAnchor({ r, c })
144
+ }
145
+
146
+ function copySelection() {
147
+ const b = bounds()
148
+ const tsv = data()
149
+ .slice(b.minR, b.maxR + 1)
150
+ .map((row) => row.slice(b.minC, b.maxC + 1).join('\t'))
151
+ .join('\n')
152
+ void navigator.clipboard.writeText(tsv)
153
+ }
154
+
155
+ function pasteAtActive() {
156
+ void navigator.clipboard.readText().then((text) => {
157
+ const lines = text.replace(/\r/g, '').split('\n')
158
+ while (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
159
+ const grid = lines.map((line) => line.split('\t'))
160
+ const start = active()
161
+ const next = cloneData(data())
162
+ grid.forEach((line, ri) => {
163
+ line.forEach((value, ci) => {
164
+ const r = start.r + ri
165
+ const c = start.c + ci
166
+ if (r < props.rows && c < props.cols) next[r][c] = value
167
+ })
168
+ })
169
+ setData(next)
170
+ props.onChange?.(next)
171
+ })
172
+ }
173
+
174
+ function handleKeyDown(ev: KeyboardEvent) {
175
+ if (editing()) {
176
+ if (ev.key === 'Enter') {
177
+ ev.preventDefault()
178
+ commitEdit()
179
+ moveActive(1, 0, false)
180
+ containerRef?.focus()
181
+ } else if (ev.key === 'Escape') {
182
+ ev.preventDefault()
183
+ cancelEdit()
184
+ containerRef?.focus()
185
+ }
186
+ return
187
+ }
188
+
189
+ const mod = ev.ctrlKey || ev.metaKey
190
+ if (mod && ev.key.toLowerCase() === 'c') {
191
+ ev.preventDefault()
192
+ copySelection()
193
+ return
194
+ }
195
+ if (mod && ev.key.toLowerCase() === 'v') {
196
+ ev.preventDefault()
197
+ pasteAtActive()
198
+ return
199
+ }
200
+
201
+ switch (ev.key) {
202
+ case 'ArrowUp':
203
+ ev.preventDefault()
204
+ moveActive(-1, 0, ev.shiftKey)
205
+ break
206
+ case 'ArrowDown':
207
+ ev.preventDefault()
208
+ moveActive(1, 0, ev.shiftKey)
209
+ break
210
+ case 'ArrowLeft':
211
+ ev.preventDefault()
212
+ moveActive(0, -1, ev.shiftKey)
213
+ break
214
+ case 'ArrowRight':
215
+ ev.preventDefault()
216
+ moveActive(0, 1, ev.shiftKey)
217
+ break
218
+ case 'Tab':
219
+ ev.preventDefault()
220
+ moveActive(0, ev.shiftKey ? -1 : 1, false)
221
+ break
222
+ case 'Enter':
223
+ ev.preventDefault()
224
+ startEdit(active().r, active().c)
225
+ break
226
+ default:
227
+ if (ev.key.length === 1 && !mod && !ev.altKey) {
228
+ startEdit(active().r, active().c, ev.key)
229
+ }
230
+ }
231
+ }
232
+
233
+ return (
234
+ <div
235
+ ref={containerRef}
236
+ tabIndex={0}
237
+ role="grid"
238
+ aria-rowcount={props.rows}
239
+ aria-colcount={props.cols}
240
+ class={cn(
241
+ 'inline-block select-none overflow-auto rounded-md border border-border bg-card text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary',
242
+ props.class,
243
+ )}
244
+ onKeyDown={handleKeyDown}
245
+ >
246
+ <div class="grid" style={{ 'grid-template-columns': `48px repeat(${props.cols}, minmax(80px, 1fr))` }}>
247
+ <div class="border-b border-r border-border bg-muted" />
248
+ <For each={headers()}>
249
+ {(h) => (
250
+ <div
251
+ role="columnheader"
252
+ class="border-b border-r border-border bg-muted px-2 py-1 text-center text-xs font-semibold text-muted-foreground"
253
+ >
254
+ {h}
255
+ </div>
256
+ )}
257
+ </For>
258
+ <For each={rowIndices()}>
259
+ {(r) => (
260
+ <>
261
+ <div
262
+ role="rowheader"
263
+ class="border-b border-r border-border bg-muted px-2 py-1 text-center text-xs text-muted-foreground"
264
+ >
265
+ {r + 1}
266
+ </div>
267
+ <For each={colIndices()}>
268
+ {(c) => (
269
+ <div
270
+ role="gridcell"
271
+ aria-selected={isActive(r, c)}
272
+ class={cn(
273
+ 'relative h-8 border-b border-r border-border px-2 py-1 text-sm cursor-cell',
274
+ inSelection(r, c) && 'bg-primary/10',
275
+ isActive(r, c) && 'ring-2 ring-inset ring-primary',
276
+ isAnchor(r, c) && !isActive(r, c) && 'border-primary',
277
+ )}
278
+ onClick={(ev) => selectCell(r, c, ev.shiftKey)}
279
+ onDblClick={() => startEdit(r, c)}
280
+ >
281
+ {isEditing(r, c) ? (
282
+ <input
283
+ ref={(el) => {
284
+ el.focus()
285
+ el.select()
286
+ }}
287
+ class="absolute inset-0 h-full w-full border-none bg-transparent px-2 py-1 text-sm text-foreground outline-none"
288
+ value={editValue()}
289
+ onInput={(ev) => setEditValue(ev.currentTarget.value)}
290
+ onBlur={() => commitEdit()}
291
+ />
292
+ ) : (
293
+ <span class="block truncate">{data()[r][c]}</span>
294
+ )}
295
+ </div>
296
+ )}
297
+ </For>
298
+ </>
299
+ )}
300
+ </For>
301
+ </div>
302
+ </div>
303
+ )
304
+ }
@@ -0,0 +1,172 @@
1
+ // Hierarchical data table: rows can nest via `children`, expand/collapse lives
2
+ // in the first column (chevron + depth indentation), all other columns render
3
+ // like a plain DataGrid column. Built on the Table primitives + Tree's toggle idiom.
4
+ import { ChevronRight } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { createSignal, For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from './Table'
10
+
11
+ /** A column definition for {@link TreeTable}. */
12
+ export interface TreeTableColumn<T> {
13
+ /** Row data property this column reads when `cell` is omitted. */
14
+ key: string
15
+ /** Column heading content. */
16
+ header: JSX.Element
17
+ /** Custom cell renderer; falls back to `String(row[key])` when omitted. */
18
+ cell?: (row: T) => JSX.Element
19
+ /** Right-align the column (numbers, totals). */
20
+ align?: 'left' | 'right'
21
+ }
22
+
23
+ /** A single node in a {@link TreeTable}; nodes may nest via `children`. */
24
+ export interface TreeTableRow<T> {
25
+ /** Unique identifier; used to track expanded state. */
26
+ id: string
27
+ /** Row payload passed to each column's `cell` renderer. */
28
+ data: T
29
+ /** Child rows revealed when this row is expanded. */
30
+ children?: TreeTableRow<T>[]
31
+ }
32
+
33
+ export interface TreeTableProps<T> {
34
+ columns: TreeTableColumn<T>[]
35
+ rows: TreeTableRow<T>[]
36
+ /** Expand every row with children on first render. Defaults to false. */
37
+ defaultExpanded?: boolean
38
+ class?: string
39
+ }
40
+
41
+ function collectIds<T>(rows: TreeTableRow<T>[], out: Set<string>): void {
42
+ for (const row of rows) {
43
+ if (row.children?.length) {
44
+ out.add(row.id)
45
+ collectIds(row.children, out)
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Hierarchical table: rows may carry `children`, revealed by clicking the
52
+ * chevron in the first column. Depth is shown via indentation; expanded state
53
+ * is tracked internally, seeded fully expanded when `defaultExpanded` is set.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * <TreeTable
58
+ * defaultExpanded
59
+ * columns={[
60
+ * { key: 'name', header: 'Name' },
61
+ * { key: 'size', header: 'Size', align: 'right', cell: (row) => `${row.size} KB` },
62
+ * ]}
63
+ * rows={[
64
+ * {
65
+ * id: 'src',
66
+ * data: { name: 'src', size: 0 },
67
+ * children: [{ id: 'index', data: { name: 'index.ts', size: 2 } }],
68
+ * },
69
+ * ]}
70
+ * />
71
+ * ```
72
+ */
73
+ export function TreeTable<T>(props: TreeTableProps<T>): JSX.Element {
74
+ const [expanded, setExpanded] = createSignal<Set<string>>(
75
+ props.defaultExpanded ? seedExpanded(props.rows) : new Set<string>(),
76
+ )
77
+
78
+ function seedExpanded(rows: TreeTableRow<T>[]): Set<string> {
79
+ const ids = new Set<string>()
80
+ collectIds(rows, ids)
81
+ return ids
82
+ }
83
+
84
+ const toggle = (id: string) =>
85
+ setExpanded((prev) => {
86
+ const next = new Set(prev)
87
+ if (next.has(id)) next.delete(id)
88
+ else next.add(id)
89
+ return next
90
+ })
91
+
92
+ const Rows = (rowsProps: { rows: TreeTableRow<T>[]; depth: number }): JSX.Element => (
93
+ <For each={rowsProps.rows}>
94
+ {(row) => {
95
+ const hasChildren = () => (row.children?.length ?? 0) > 0
96
+ const isExpanded = () => expanded().has(row.id)
97
+
98
+ return (
99
+ <>
100
+ <TableRow>
101
+ <For each={props.columns}>
102
+ {(col, colIndex) => (
103
+ <TableCell class={cn(col.align === 'right' && 'text-right tabular-nums')}>
104
+ <Show
105
+ when={colIndex() === 0}
106
+ fallback={
107
+ col.cell
108
+ ? col.cell(row.data)
109
+ : String((row.data as Record<string, unknown>)[col.key] ?? '')
110
+ }
111
+ >
112
+ <div
113
+ class="flex items-center gap-1.5"
114
+ style={{ 'padding-left': `${rowsProps.depth * 1}rem` }}
115
+ >
116
+ <Show
117
+ when={hasChildren()}
118
+ fallback={<span class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
119
+ >
120
+ <button
121
+ type="button"
122
+ class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
123
+ aria-expanded={isExpanded()}
124
+ aria-label={isExpanded() ? 'Collapse row' : 'Expand row'}
125
+ onClick={() => toggle(row.id)}
126
+ >
127
+ <ChevronRight
128
+ class={cn(
129
+ 'h-3.5 w-3.5 transition-transform duration-200',
130
+ isExpanded() && 'rotate-90',
131
+ )}
132
+ aria-hidden="true"
133
+ />
134
+ </button>
135
+ </Show>
136
+ <span>
137
+ {col.cell
138
+ ? col.cell(row.data)
139
+ : String((row.data as Record<string, unknown>)[col.key] ?? '')}
140
+ </span>
141
+ </div>
142
+ </Show>
143
+ </TableCell>
144
+ )}
145
+ </For>
146
+ </TableRow>
147
+ <Show when={hasChildren() && isExpanded()}>
148
+ <Rows rows={row.children ?? []} depth={rowsProps.depth + 1} />
149
+ </Show>
150
+ </>
151
+ )
152
+ }}
153
+ </For>
154
+ )
155
+
156
+ return (
157
+ <Table class={props.class}>
158
+ <TableHead>
159
+ <TableRow>
160
+ <For each={props.columns}>
161
+ {(col) => (
162
+ <TableHeadCell class={cn(col.align === 'right' && 'text-right')}>{col.header}</TableHeadCell>
163
+ )}
164
+ </For>
165
+ </TableRow>
166
+ </TableHead>
167
+ <TableBody>
168
+ <Rows rows={props.rows} depth={0} />
169
+ </TableBody>
170
+ </Table>
171
+ )
172
+ }