@a4ui/core 0.34.0 → 0.35.1
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 +119 -0
- package/dist/elements.iife.js +2 -2
- package/dist/elements.js +115 -114
- package/dist/full.css +119 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8175 -5443
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/RingProgress.d.ts +2 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/package.json +1 -1
- package/src/index.ts +36 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +1 -3
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +286 -0
- package/src/ui/GanttChart.tsx +9 -1
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +1 -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 +281 -0
- package/src/ui/RingProgress.tsx +3 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +307 -0
|
@@ -0,0 +1,307 @@
|
|
|
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-label="Spreadsheet"
|
|
239
|
+
aria-rowcount={props.rows}
|
|
240
|
+
aria-colcount={props.cols}
|
|
241
|
+
class={cn(
|
|
242
|
+
'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',
|
|
243
|
+
props.class,
|
|
244
|
+
)}
|
|
245
|
+
onKeyDown={handleKeyDown}
|
|
246
|
+
>
|
|
247
|
+
<div class="grid" style={{ 'grid-template-columns': `48px repeat(${props.cols}, minmax(80px, 1fr))` }}>
|
|
248
|
+
<div role="row" class="contents">
|
|
249
|
+
<div aria-hidden="true" class="border-b border-r border-border bg-muted" />
|
|
250
|
+
<For each={headers()}>
|
|
251
|
+
{(h) => (
|
|
252
|
+
<div
|
|
253
|
+
role="columnheader"
|
|
254
|
+
class="border-b border-r border-border bg-muted px-2 py-1 text-center text-xs font-semibold text-muted-foreground"
|
|
255
|
+
>
|
|
256
|
+
{h}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</For>
|
|
260
|
+
</div>
|
|
261
|
+
<For each={rowIndices()}>
|
|
262
|
+
{(r) => (
|
|
263
|
+
<div role="row" class="contents">
|
|
264
|
+
<div
|
|
265
|
+
role="rowheader"
|
|
266
|
+
class="border-b border-r border-border bg-muted px-2 py-1 text-center text-xs text-muted-foreground"
|
|
267
|
+
>
|
|
268
|
+
{r + 1}
|
|
269
|
+
</div>
|
|
270
|
+
<For each={colIndices()}>
|
|
271
|
+
{(c) => (
|
|
272
|
+
<div
|
|
273
|
+
role="gridcell"
|
|
274
|
+
aria-selected={isActive(r, c)}
|
|
275
|
+
class={cn(
|
|
276
|
+
'relative h-8 border-b border-r border-border px-2 py-1 text-sm cursor-cell',
|
|
277
|
+
inSelection(r, c) && 'bg-primary/10',
|
|
278
|
+
isActive(r, c) && 'ring-2 ring-inset ring-primary',
|
|
279
|
+
isAnchor(r, c) && !isActive(r, c) && 'border-primary',
|
|
280
|
+
)}
|
|
281
|
+
onClick={(ev) => selectCell(r, c, ev.shiftKey)}
|
|
282
|
+
onDblClick={() => startEdit(r, c)}
|
|
283
|
+
>
|
|
284
|
+
{isEditing(r, c) ? (
|
|
285
|
+
<input
|
|
286
|
+
ref={(el) => {
|
|
287
|
+
el.focus()
|
|
288
|
+
el.select()
|
|
289
|
+
}}
|
|
290
|
+
class="absolute inset-0 h-full w-full border-none bg-transparent px-2 py-1 text-sm text-foreground outline-none"
|
|
291
|
+
value={editValue()}
|
|
292
|
+
onInput={(ev) => setEditValue(ev.currentTarget.value)}
|
|
293
|
+
onBlur={() => commitEdit()}
|
|
294
|
+
/>
|
|
295
|
+
) : (
|
|
296
|
+
<span class="block truncate">{data()[r][c]}</span>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
</For>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</For>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
)
|
|
307
|
+
}
|