@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.
- package/dist/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- 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
|
+
}
|