@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,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
+ }