@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,349 @@
|
|
|
1
|
+
// Slippy tile map from scratch — no leaflet/mapbox-gl. Standard Web Mercator
|
|
2
|
+
// (EPSG:3857) tile math: latLngToWorldPixel/worldPixelToLatLng convert between
|
|
3
|
+
// lat/lng and "world pixel" space at a given integer zoom (world size =
|
|
4
|
+
// 256 * 2^zoom px). The visible tile range is derived from the container size
|
|
5
|
+
// and the world-pixel position of the top-left corner; each tile renders as an
|
|
6
|
+
// absolutely-positioned <img>, repositioned (not reloaded) as the user pans.
|
|
7
|
+
import { Minus, Plus, MapPin } from 'lucide-solid'
|
|
8
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, untrack, type JSX } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { motionReduced } from '../lib/motion'
|
|
12
|
+
import { Button } from './Button'
|
|
13
|
+
|
|
14
|
+
export interface MapMarker {
|
|
15
|
+
lat: number
|
|
16
|
+
lng: number
|
|
17
|
+
label?: string
|
|
18
|
+
id?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface InteractiveMapProps {
|
|
22
|
+
/** Initial view center. Also acts as a recenter trigger: passing a new
|
|
23
|
+
* `{ lat, lng }` after mount pans the view there (e.g. a "use my
|
|
24
|
+
* location" button) without fighting the user's own drag/zoom in between.
|
|
25
|
+
* @default { lat: 0, lng: 0 } */
|
|
26
|
+
center?: { lat: number; lng: number }
|
|
27
|
+
/** Initial integer zoom level, clamped to `[2, 18]`. Synced the same way
|
|
28
|
+
* as `center` when it changes after mount. @default 3 */
|
|
29
|
+
zoom?: number
|
|
30
|
+
/** Pins rendered on top of the tiles, projected to their pixel position. */
|
|
31
|
+
markers?: MapMarker[]
|
|
32
|
+
/** Tile URL template with `{z}`/`{x}`/`{y}` placeholders.
|
|
33
|
+
* @default 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' */
|
|
34
|
+
tileUrl?: string
|
|
35
|
+
/** Height of the map, in px. @default 400 */
|
|
36
|
+
height?: number
|
|
37
|
+
/** Fires with the lat/lng under the pointer on a click (drags don't count). */
|
|
38
|
+
onClick?: (coord: { lat: number; lng: number }) => void
|
|
39
|
+
class?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TILE_SIZE = 256
|
|
43
|
+
const MIN_ZOOM = 2
|
|
44
|
+
const MAX_ZOOM = 18
|
|
45
|
+
const MAX_LAT = 85.05112878 // Web Mercator's latitude limit (where the projection would reach infinity)
|
|
46
|
+
const DEFAULT_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
|
47
|
+
const DRAG_THRESHOLD_PX = 4 // pointer movement below this still counts as a click, not a pan
|
|
48
|
+
|
|
49
|
+
interface WorldPixel {
|
|
50
|
+
x: number
|
|
51
|
+
y: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface TileDescriptor {
|
|
55
|
+
id: string
|
|
56
|
+
left: number
|
|
57
|
+
top: number
|
|
58
|
+
src: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface MarkerPosition {
|
|
62
|
+
marker: MapMarker
|
|
63
|
+
x: number
|
|
64
|
+
y: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clampZoom(z: number): number {
|
|
68
|
+
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(z)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clampLat(lat: number): number {
|
|
72
|
+
return Math.min(MAX_LAT, Math.max(-MAX_LAT, lat))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function wrapLng(lng: number): number {
|
|
76
|
+
return ((((lng + 180) % 360) + 360) % 360) - 180
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Projects a lat/lng to "world pixel" space at an integer zoom (the pixel
|
|
81
|
+
* grid the size of the whole rendered world map, `256 * 2^zoom` square).
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* latLngToWorldPixel(0, 0, 0) // → { x: 128, y: 128 } (center of a single 256px tile)
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
function latLngToWorldPixel(lat: number, lng: number, zoom: number): WorldPixel {
|
|
89
|
+
const scale = TILE_SIZE * 2 ** zoom
|
|
90
|
+
const x = ((wrapLng(lng) + 180) / 360) * scale
|
|
91
|
+
const sinLat = Math.sin((clampLat(lat) * Math.PI) / 180)
|
|
92
|
+
const y = (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale
|
|
93
|
+
return { x, y }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Inverse of {@link latLngToWorldPixel}: world pixel + zoom → lat/lng. */
|
|
97
|
+
function worldPixelToLatLng(x: number, y: number, zoom: number): { lat: number; lng: number } {
|
|
98
|
+
const scale = TILE_SIZE * 2 ** zoom
|
|
99
|
+
const lng = (x / scale) * 360 - 180
|
|
100
|
+
const n = Math.PI - (2 * Math.PI * y) / scale
|
|
101
|
+
const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
|
|
102
|
+
return { lat: clampLat(lat), lng: wrapLng(lng) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildTileUrl(template: string, z: number, x: number, y: number): string {
|
|
106
|
+
return template.replace(/\{z\}/g, String(z)).replace(/\{x\}/g, String(x)).replace(/\{y\}/g, String(y))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A slippy tile map built directly on Web Mercator math — no map SDK. Pan by
|
|
111
|
+
* dragging, zoom with the +/- buttons or the wheel (zoom keeps the point
|
|
112
|
+
* under the cursor fixed), place `markers`, and get the lat/lng of a click
|
|
113
|
+
* via `onClick`. Recomputes its tile grid on resize (`ResizeObserver`).
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```tsx
|
|
117
|
+
* <InteractiveMap
|
|
118
|
+
* center={{ lat: 48.8584, lng: 2.2945 }}
|
|
119
|
+
* zoom={13}
|
|
120
|
+
* markers={[{ lat: 48.8584, lng: 2.2945, label: 'Eiffel Tower' }]}
|
|
121
|
+
* onClick={(coord) => console.log('clicked', coord)}
|
|
122
|
+
* />
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function InteractiveMap(props: InteractiveMapProps): JSX.Element {
|
|
126
|
+
let containerRef: HTMLDivElement | undefined
|
|
127
|
+
|
|
128
|
+
const [size, setSize] = createSignal({ w: 0, h: 0 })
|
|
129
|
+
const [zoom, setZoom] = createSignal(clampZoom(props.zoom ?? 3))
|
|
130
|
+
const [center, setCenter] = createSignal(props.center ?? { lat: 0, lng: 0 })
|
|
131
|
+
|
|
132
|
+
// `center`/`zoom` are initial values that also act as recenter triggers —
|
|
133
|
+
// syncing only on a genuine value change (not every render) so they don't
|
|
134
|
+
// fight the user's own drag/zoom between prop updates.
|
|
135
|
+
let lastCenterProp = props.center
|
|
136
|
+
createEffect(() => {
|
|
137
|
+
const next = props.center
|
|
138
|
+
const prevApplied = untrack(center)
|
|
139
|
+
if (next && next !== lastCenterProp && (next.lat !== prevApplied.lat || next.lng !== prevApplied.lng)) {
|
|
140
|
+
setCenter({ lat: next.lat, lng: next.lng })
|
|
141
|
+
}
|
|
142
|
+
lastCenterProp = next
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
let lastZoomProp = props.zoom
|
|
146
|
+
createEffect(() => {
|
|
147
|
+
const next = props.zoom
|
|
148
|
+
if (next !== undefined && next !== lastZoomProp) setZoom(clampZoom(next))
|
|
149
|
+
lastZoomProp = next
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
onMount(() => {
|
|
153
|
+
if (!containerRef) return
|
|
154
|
+
const measure = (): void => {
|
|
155
|
+
if (!containerRef) return
|
|
156
|
+
const rect = containerRef.getBoundingClientRect()
|
|
157
|
+
setSize({ w: rect.width, h: rect.height })
|
|
158
|
+
}
|
|
159
|
+
measure()
|
|
160
|
+
|
|
161
|
+
let observer: ResizeObserver | undefined
|
|
162
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
163
|
+
observer = new ResizeObserver(measure)
|
|
164
|
+
observer.observe(containerRef)
|
|
165
|
+
}
|
|
166
|
+
onCleanup(() => observer?.disconnect())
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// World-pixel position of the container's top-left corner — the origin
|
|
170
|
+
// every tile/marker position is measured against.
|
|
171
|
+
const topLeftPx = createMemo<WorldPixel>(() => {
|
|
172
|
+
const c = latLngToWorldPixel(center().lat, center().lng, zoom())
|
|
173
|
+
const { w, h } = size()
|
|
174
|
+
return { x: c.x - w / 2, y: c.y - h / 2 }
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const tiles = createMemo<TileDescriptor[]>(() => {
|
|
178
|
+
const { w, h } = size()
|
|
179
|
+
if (w <= 0 || h <= 0) return []
|
|
180
|
+
const z = zoom()
|
|
181
|
+
const numTiles = 2 ** z
|
|
182
|
+
const tl = topLeftPx()
|
|
183
|
+
const url = props.tileUrl ?? DEFAULT_TILE_URL
|
|
184
|
+
|
|
185
|
+
const minX = Math.floor(tl.x / TILE_SIZE) - 1
|
|
186
|
+
const maxX = Math.floor((tl.x + w) / TILE_SIZE) + 1
|
|
187
|
+
const minY = Math.max(0, Math.floor(tl.y / TILE_SIZE) - 1)
|
|
188
|
+
const maxY = Math.min(numTiles - 1, Math.floor((tl.y + h) / TILE_SIZE) + 1)
|
|
189
|
+
|
|
190
|
+
const list: TileDescriptor[] = []
|
|
191
|
+
for (let x = minX; x <= maxX; x++) {
|
|
192
|
+
const wrappedX = ((x % numTiles) + numTiles) % numTiles
|
|
193
|
+
for (let y = minY; y <= maxY; y++) {
|
|
194
|
+
list.push({
|
|
195
|
+
id: `${z}/${x}/${y}`,
|
|
196
|
+
left: x * TILE_SIZE - tl.x,
|
|
197
|
+
top: y * TILE_SIZE - tl.y,
|
|
198
|
+
src: buildTileUrl(url, z, wrappedX, y),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return list
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const markerPositions = createMemo<MarkerPosition[]>(() => {
|
|
206
|
+
const tl = topLeftPx()
|
|
207
|
+
const z = zoom()
|
|
208
|
+
return (props.markers ?? []).map((marker) => {
|
|
209
|
+
const px = latLngToWorldPixel(marker.lat, marker.lng, z)
|
|
210
|
+
return { marker, x: px.x - tl.x, y: px.y - tl.y }
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const clientToLatLng = (clientX: number, clientY: number): { lat: number; lng: number } | null => {
|
|
215
|
+
if (!containerRef) return null
|
|
216
|
+
const rect = containerRef.getBoundingClientRect()
|
|
217
|
+
const tl = topLeftPx()
|
|
218
|
+
return worldPixelToLatLng(tl.x + (clientX - rect.left), tl.y + (clientY - rect.top), zoom())
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- pan (pointer drag) ----------------------------------------------
|
|
222
|
+
let removeDragListeners: (() => void) | null = null
|
|
223
|
+
|
|
224
|
+
const handlePointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }): void => {
|
|
225
|
+
const target = event.currentTarget
|
|
226
|
+
target.setPointerCapture(event.pointerId)
|
|
227
|
+
|
|
228
|
+
const drag = {
|
|
229
|
+
startClientX: event.clientX,
|
|
230
|
+
startClientY: event.clientY,
|
|
231
|
+
startCenterPx: latLngToWorldPixel(center().lat, center().lng, zoom()),
|
|
232
|
+
moved: false,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const handleMove = (moveEvent: PointerEvent): void => {
|
|
236
|
+
const dx = moveEvent.clientX - drag.startClientX
|
|
237
|
+
const dy = moveEvent.clientY - drag.startClientY
|
|
238
|
+
if (!drag.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) drag.moved = true
|
|
239
|
+
if (!drag.moved) return
|
|
240
|
+
const z = zoom()
|
|
241
|
+
setCenter(worldPixelToLatLng(drag.startCenterPx.x - dx, drag.startCenterPx.y - dy, z))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const release = (upEvent: PointerEvent): void => {
|
|
245
|
+
removeDragListeners?.()
|
|
246
|
+
if (!drag.moved) {
|
|
247
|
+
const coord = clientToLatLng(upEvent.clientX, upEvent.clientY)
|
|
248
|
+
if (coord) props.onClick?.(coord)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
target.addEventListener('pointermove', handleMove)
|
|
253
|
+
target.addEventListener('pointerup', release)
|
|
254
|
+
target.addEventListener('lostpointercapture', release)
|
|
255
|
+
removeDragListeners = () => {
|
|
256
|
+
target.removeEventListener('pointermove', handleMove)
|
|
257
|
+
target.removeEventListener('pointerup', release)
|
|
258
|
+
target.removeEventListener('lostpointercapture', release)
|
|
259
|
+
removeDragListeners = null
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
onCleanup(() => removeDragListeners?.())
|
|
264
|
+
|
|
265
|
+
// --- zoom (wheel, keeping the point under the cursor fixed) ----------
|
|
266
|
+
const handleWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }): void => {
|
|
267
|
+
event.preventDefault()
|
|
268
|
+
const oldZoom = zoom()
|
|
269
|
+
const newZoom = clampZoom(oldZoom + (event.deltaY < 0 ? 1 : -1))
|
|
270
|
+
if (newZoom === oldZoom) return
|
|
271
|
+
|
|
272
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
273
|
+
const px = event.clientX - rect.left
|
|
274
|
+
const py = event.clientY - rect.top
|
|
275
|
+
const tl = topLeftPx()
|
|
276
|
+
const cursorLatLng = worldPixelToLatLng(tl.x + px, tl.y + py, oldZoom)
|
|
277
|
+
const cursorAtNewZoom = latLngToWorldPixel(cursorLatLng.lat, cursorLatLng.lng, newZoom)
|
|
278
|
+
const { w, h } = size()
|
|
279
|
+
|
|
280
|
+
setZoom(newZoom)
|
|
281
|
+
setCenter(worldPixelToLatLng(cursorAtNewZoom.x - px + w / 2, cursorAtNewZoom.y - py + h / 2, newZoom))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const zoomBy = (delta: number): void => {
|
|
285
|
+
setZoom((z) => clampZoom(z + delta))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div
|
|
290
|
+
class={cn('relative overflow-hidden rounded-lg border border-border bg-muted', props.class)}
|
|
291
|
+
style={{ height: `${props.height ?? 400}px` }}
|
|
292
|
+
>
|
|
293
|
+
<div
|
|
294
|
+
ref={containerRef}
|
|
295
|
+
class="absolute inset-0 touch-none cursor-grab select-none active:cursor-grabbing"
|
|
296
|
+
role="application"
|
|
297
|
+
aria-label="Interactive map"
|
|
298
|
+
onPointerDown={handlePointerDown}
|
|
299
|
+
onWheel={handleWheel}
|
|
300
|
+
>
|
|
301
|
+
<For each={tiles()}>
|
|
302
|
+
{(tile) => (
|
|
303
|
+
<img
|
|
304
|
+
src={tile.src}
|
|
305
|
+
alt=""
|
|
306
|
+
draggable={false}
|
|
307
|
+
class={cn(
|
|
308
|
+
'absolute h-[256px] w-[256px] max-w-none select-none',
|
|
309
|
+
!motionReduced() &&
|
|
310
|
+
'opacity-0 transition-opacity duration-150 [&.a4ui-tile-loaded]:opacity-100',
|
|
311
|
+
)}
|
|
312
|
+
style={{ left: `${tile.left}px`, top: `${tile.top}px` }}
|
|
313
|
+
onLoad={(event) => event.currentTarget.classList.add('a4ui-tile-loaded')}
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
</For>
|
|
317
|
+
<For each={markerPositions()}>
|
|
318
|
+
{(mp) => (
|
|
319
|
+
<div
|
|
320
|
+
class="pointer-events-auto absolute -translate-x-1/2 -translate-y-full"
|
|
321
|
+
style={{ left: `${mp.x}px`, top: `${mp.y}px` }}
|
|
322
|
+
title={mp.marker.label}
|
|
323
|
+
>
|
|
324
|
+
<MapPin class="h-7 w-7 text-primary drop-shadow" aria-label={mp.marker.label ?? 'Marker'} />
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
</For>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div class="absolute top-2 right-2 flex flex-col gap-1">
|
|
331
|
+
<Button variant="secondary" class="h-8 w-8 p-0" aria-label="Zoom in" onClick={() => zoomBy(1)}>
|
|
332
|
+
<Plus class="h-4 w-4" />
|
|
333
|
+
</Button>
|
|
334
|
+
<Button variant="secondary" class="h-8 w-8 p-0" aria-label="Zoom out" onClick={() => zoomBy(-1)}>
|
|
335
|
+
<Minus class="h-4 w-4" />
|
|
336
|
+
</Button>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<a
|
|
340
|
+
href="https://www.openstreetmap.org/copyright"
|
|
341
|
+
target="_blank"
|
|
342
|
+
rel="noreferrer"
|
|
343
|
+
class="absolute bottom-0.5 left-1 rounded bg-card/70 px-1 text-[10px] text-muted-foreground backdrop-blur-sm hover:text-foreground"
|
|
344
|
+
>
|
|
345
|
+
© OpenStreetMap
|
|
346
|
+
</a>
|
|
347
|
+
</div>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Collapsible JSON tree with type-tinted primitive values.
|
|
2
|
+
import { ChevronRight } from 'lucide-solid'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { createMemo, createSignal, For, Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
import { Input } from './Input'
|
|
8
|
+
|
|
9
|
+
export interface JsonViewerProps {
|
|
10
|
+
/** The value to render — object, array, or a primitive at the root. */
|
|
11
|
+
data: unknown
|
|
12
|
+
/** Expand every object/array node on first render. Defaults to `false`. */
|
|
13
|
+
defaultExpanded?: boolean
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Entry = { key: string; value: unknown }
|
|
18
|
+
|
|
19
|
+
function isExpandable(value: unknown): value is Record<string, unknown> | unknown[] {
|
|
20
|
+
return typeof value === 'object' && value !== null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function entriesOf(value: Record<string, unknown> | unknown[]): Entry[] {
|
|
24
|
+
return Array.isArray(value)
|
|
25
|
+
? value.map((v, i) => ({ key: String(i), value: v }))
|
|
26
|
+
: Object.entries(value).map(([key, v]) => ({ key, value: v }))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function summaryOf(value: Record<string, unknown> | unknown[]): string {
|
|
30
|
+
const count = entriesOf(value).length
|
|
31
|
+
return Array.isArray(value) ? `[${count}]` : `{${count}}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Collects every expandable node's path so `defaultExpanded` can seed them all. */
|
|
35
|
+
function collectPaths(value: unknown, path: string, acc: Set<string>): void {
|
|
36
|
+
if (!isExpandable(value)) return
|
|
37
|
+
acc.add(path)
|
|
38
|
+
for (const { key, value: child } of entriesOf(value)) collectPaths(child, `${path}.${key}`, acc)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function valueClass(value: unknown): string {
|
|
42
|
+
if (value === null) return 'text-muted-foreground'
|
|
43
|
+
switch (typeof value) {
|
|
44
|
+
case 'string':
|
|
45
|
+
return 'text-accent'
|
|
46
|
+
case 'number':
|
|
47
|
+
return 'text-primary'
|
|
48
|
+
case 'boolean':
|
|
49
|
+
return 'text-secondary-foreground'
|
|
50
|
+
default:
|
|
51
|
+
return 'text-foreground'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatPrimitive(value: unknown): string {
|
|
56
|
+
return typeof value === 'string' ? `"${value}"` : String(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function matches(entry: Entry, query: string): boolean {
|
|
60
|
+
if (entry.key.toLowerCase().includes(query)) return true
|
|
61
|
+
if (isExpandable(entry.value)) return entriesOf(entry.value).some((e) => matches(e, query))
|
|
62
|
+
return formatPrimitive(entry.value).toLowerCase().includes(query)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Collapsible, indented JSON tree. Objects/arrays are expandable nodes (chevron
|
|
67
|
+
* + key + a `{…}` / `[…]` summary with child count); primitives render as
|
|
68
|
+
* `key: value` with the value tinted by type. Expanded state is tracked
|
|
69
|
+
* internally by path, seeded fully-open when `defaultExpanded` is set. An
|
|
70
|
+
* optional search box highlights keys/values that match.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```tsx
|
|
74
|
+
* <JsonViewer
|
|
75
|
+
* defaultExpanded
|
|
76
|
+
* data={{ user: { id: 1, name: 'Ada', active: true, tags: ['admin', 'core'] } }}
|
|
77
|
+
* />
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function JsonViewer(props: JsonViewerProps): JSX.Element {
|
|
81
|
+
const [expanded, setExpanded] = createSignal<Set<string>>(seedExpanded())
|
|
82
|
+
const [query, setQuery] = createSignal('')
|
|
83
|
+
|
|
84
|
+
function seedExpanded(): Set<string> {
|
|
85
|
+
if (!props.defaultExpanded) return new Set()
|
|
86
|
+
const acc = new Set<string>()
|
|
87
|
+
collectPaths(props.data, '$', acc)
|
|
88
|
+
return acc
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const toggle = (path: string) =>
|
|
92
|
+
setExpanded((prev) => {
|
|
93
|
+
const next = new Set(prev)
|
|
94
|
+
if (next.has(path)) next.delete(path)
|
|
95
|
+
else next.add(path)
|
|
96
|
+
return next
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const normalizedQuery = createMemo(() => query().trim().toLowerCase())
|
|
100
|
+
|
|
101
|
+
const highlight = (text: string): JSX.Element => {
|
|
102
|
+
const q = normalizedQuery()
|
|
103
|
+
if (!q) return <>{text}</>
|
|
104
|
+
const i = text.toLowerCase().indexOf(q)
|
|
105
|
+
if (i === -1) return <>{text}</>
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
{text.slice(0, i)}
|
|
109
|
+
<mark class="bg-accent/30 text-inherit">{text.slice(i, i + q.length)}</mark>
|
|
110
|
+
{text.slice(i + q.length)}
|
|
111
|
+
</>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const Node = (nodeProps: { entry: Entry; path: string; depth: number }): JSX.Element => {
|
|
116
|
+
const value = () => nodeProps.entry.value
|
|
117
|
+
const path = () => nodeProps.path
|
|
118
|
+
const q = normalizedQuery()
|
|
119
|
+
const visible = createMemo(() => !q || matches(nodeProps.entry, q))
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Show when={visible()}>
|
|
123
|
+
<li style={{ 'padding-left': `${nodeProps.depth * 1}rem` }}>
|
|
124
|
+
<Show
|
|
125
|
+
when={isExpandable(value())}
|
|
126
|
+
fallback={
|
|
127
|
+
<div class="flex items-baseline gap-1 py-0.5">
|
|
128
|
+
<span class="w-3.5 shrink-0" aria-hidden="true" />
|
|
129
|
+
<span class="text-foreground">{highlight(nodeProps.entry.key)}:</span>
|
|
130
|
+
<span class={valueClass(value())}>{highlight(formatPrimitive(value()))}</span>
|
|
131
|
+
</div>
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
{(() => {
|
|
135
|
+
const container = value() as Record<string, unknown> | unknown[]
|
|
136
|
+
const isOpen = () => expanded().has(path())
|
|
137
|
+
return (
|
|
138
|
+
<>
|
|
139
|
+
<div
|
|
140
|
+
class="flex items-baseline gap-1 py-0.5 cursor-pointer rounded hover:bg-muted"
|
|
141
|
+
onClick={() => toggle(path())}
|
|
142
|
+
>
|
|
143
|
+
<ChevronRight
|
|
144
|
+
class={cn(
|
|
145
|
+
'h-3.5 w-3.5 shrink-0 self-center transition-transform duration-150',
|
|
146
|
+
isOpen() && 'rotate-90',
|
|
147
|
+
)}
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
/>
|
|
150
|
+
<span class="text-foreground">{highlight(nodeProps.entry.key)}:</span>
|
|
151
|
+
<span class="text-muted-foreground">{summaryOf(container)}</span>
|
|
152
|
+
</div>
|
|
153
|
+
<Show when={isOpen()}>
|
|
154
|
+
<ul>
|
|
155
|
+
<For each={entriesOf(container)}>
|
|
156
|
+
{(child) => (
|
|
157
|
+
<Node entry={child} path={`${path()}.${child.key}`} depth={nodeProps.depth + 1} />
|
|
158
|
+
)}
|
|
159
|
+
</For>
|
|
160
|
+
</ul>
|
|
161
|
+
</Show>
|
|
162
|
+
</>
|
|
163
|
+
)
|
|
164
|
+
})()}
|
|
165
|
+
</Show>
|
|
166
|
+
</li>
|
|
167
|
+
</Show>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const rootEntries = createMemo<Entry[]>(() =>
|
|
172
|
+
isExpandable(props.data) ? entriesOf(props.data) : [{ key: '$', value: props.data }],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div class={cn('font-mono text-sm whitespace-pre', props.class)}>
|
|
177
|
+
<Input
|
|
178
|
+
value={query()}
|
|
179
|
+
onInput={setQuery}
|
|
180
|
+
placeholder="Search keys/values…"
|
|
181
|
+
class="mb-2 font-sans text-sm"
|
|
182
|
+
aria-label="Search JSON"
|
|
183
|
+
/>
|
|
184
|
+
<ul>
|
|
185
|
+
<For each={rootEntries()}>{(entry) => <Node entry={entry} path={`$.${entry.key}`} depth={0} />}</For>
|
|
186
|
+
</ul>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|