@a4ui/core 0.32.0 → 0.34.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/charts/BarList.d.ts +33 -0
- package/dist/charts/CategoryBar.d.ts +23 -0
- package/dist/charts/StatusTracker.d.ts +27 -0
- package/dist/charts/index.d.ts +3 -0
- package/dist/charts.js +446 -316
- package/dist/elements.css +278 -0
- package/dist/full.css +278 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +6423 -4195
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/Globe.d.ts +44 -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/ModelPicker.d.ts +38 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/ReasoningTrace.d.ts +28 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/Snippet.d.ts +19 -0
- package/dist/ui/SuggestionChips.d.ts +25 -0
- package/dist/ui/ToolCallTimeline.d.ts +34 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/UsageMeter.d.ts +26 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/dist/ui/WorldMap.d.ts +36 -0
- package/package.json +1 -1
- package/src/charts/BarList.tsx +83 -0
- package/src/charts/CategoryBar.tsx +95 -0
- package/src/charts/StatusTracker.tsx +81 -0
- package/src/charts/index.ts +3 -0
- package/src/index.ts +48 -1
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/Globe.tsx +317 -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/ModelPicker.tsx +119 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/ReasoningTrace.tsx +83 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/Snippet.tsx +64 -0
- package/src/ui/SuggestionChips.tsx +65 -0
- package/src/ui/ToolCallTimeline.tsx +137 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/UsageMeter.tsx +71 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
- package/src/ui/WorldMap.tsx +191 -0
package/src/ui/Globe.tsx
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// A dotted 3D globe rendered on a single <canvas> with plain Canvas 2D — no
|
|
2
|
+
// three.js, no WebGL. The sphere's dots come from a Fibonacci sphere
|
|
3
|
+
// (deterministic, evenly distributed — no Math.random so the dot field is
|
|
4
|
+
// stable across renders/SSR hydration). Each frame: rotate every point around
|
|
5
|
+
// the Y axis, project orthographically (drop z), keep only front-facing
|
|
6
|
+
// points (z > 0), and scale alpha/size by depth for a cheap "3D" read.
|
|
7
|
+
// Markers/arcs project through the same pipeline so they stay glued to the
|
|
8
|
+
// sphere as it spins. Colors are read from the live theme tokens via
|
|
9
|
+
// getComputedStyle (canvas can't consume `hsl(var(--x))` directly). Follows
|
|
10
|
+
// SpaceBackground's onMount/onCleanup idiom: bind listeners into a cleanups
|
|
11
|
+
// array, cancel the rAF, tear everything down on unmount.
|
|
12
|
+
import { createEffect, onCleanup, onMount, type JSX } from 'solid-js'
|
|
13
|
+
|
|
14
|
+
import { cn } from '../lib/cn'
|
|
15
|
+
import { motionReduced } from '../lib/motion'
|
|
16
|
+
|
|
17
|
+
export interface GlobeMarker {
|
|
18
|
+
lat: number
|
|
19
|
+
lng: number
|
|
20
|
+
label?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GlobeArc {
|
|
24
|
+
/** [lat, lng] */
|
|
25
|
+
from: [number, number]
|
|
26
|
+
/** [lat, lng] */
|
|
27
|
+
to: [number, number]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GlobeProps {
|
|
31
|
+
markers?: GlobeMarker[]
|
|
32
|
+
arcs?: GlobeArc[]
|
|
33
|
+
/** Canvas size in CSS px (square). @default 400 */
|
|
34
|
+
size?: number
|
|
35
|
+
/** Spin automatically. @default true (ignored under reduced motion). */
|
|
36
|
+
autoRotate?: boolean
|
|
37
|
+
class?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Vec3 {
|
|
41
|
+
x: number
|
|
42
|
+
y: number
|
|
43
|
+
z: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Projected {
|
|
47
|
+
x: number
|
|
48
|
+
y: number
|
|
49
|
+
z: number
|
|
50
|
+
front: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DOT_COUNT = 900
|
|
54
|
+
const ROTATE_SPEED = 0.12 // rad/s
|
|
55
|
+
const DRAG_SPEED = 0.012 // rad per px of pointer movement
|
|
56
|
+
|
|
57
|
+
// Fibonacci sphere: evenly-spaced points on a unit sphere, fully deterministic
|
|
58
|
+
// (no RNG) so the dot field is stable across renders and reproducible in tests.
|
|
59
|
+
function fibonacciSphere(count: number): Vec3[] {
|
|
60
|
+
const points: Vec3[] = []
|
|
61
|
+
const goldenAngle = Math.PI * (3 - Math.sqrt(5))
|
|
62
|
+
for (let i = 0; i < count; i++) {
|
|
63
|
+
const y = 1 - (i / (count - 1)) * 2 // 1..-1
|
|
64
|
+
const radius = Math.sqrt(Math.max(0, 1 - y * y))
|
|
65
|
+
const theta = goldenAngle * i
|
|
66
|
+
points.push({ x: Math.cos(theta) * radius, y, z: Math.sin(theta) * radius })
|
|
67
|
+
}
|
|
68
|
+
return points
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SPHERE_POINTS = fibonacciSphere(DOT_COUNT)
|
|
72
|
+
|
|
73
|
+
function latLngToVec3(lat: number, lng: number): Vec3 {
|
|
74
|
+
const latRad = (lat * Math.PI) / 180
|
|
75
|
+
const lngRad = (lng * Math.PI) / 180
|
|
76
|
+
return {
|
|
77
|
+
x: Math.cos(latRad) * Math.sin(lngRad),
|
|
78
|
+
y: Math.sin(latRad),
|
|
79
|
+
z: Math.cos(latRad) * Math.cos(lngRad),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Rotate around the Y axis and project orthographically to a `radius`-sized
|
|
84
|
+
// disc centered at (cx, cy). z stays in [-1, 1] (front-facing when > 0).
|
|
85
|
+
function project(p: Vec3, angle: number, radius: number, cx: number, cy: number): Projected {
|
|
86
|
+
const cos = Math.cos(angle)
|
|
87
|
+
const sin = Math.sin(angle)
|
|
88
|
+
const x = p.x * cos + p.z * sin
|
|
89
|
+
const z = -p.x * sin + p.z * cos
|
|
90
|
+
const y = p.y
|
|
91
|
+
return { x: cx + x * radius, y: cy - y * radius, z, front: z > 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveColor(canvas: HTMLCanvasElement, token: string, fallback: string): string {
|
|
95
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim()
|
|
96
|
+
return raw ? `hsl(${raw})` : fallback
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A dotted 3D globe drawn on `<canvas>` with plain Canvas 2D (no three.js,
|
|
101
|
+
* no WebGL): dots come from a deterministic Fibonacci-sphere distribution,
|
|
102
|
+
* rotate around the Y axis, and project orthographically with depth-scaled
|
|
103
|
+
* alpha/size. `markers` render as brighter glowing dots (title = label);
|
|
104
|
+
* `arcs` draw as outward-bulging curves between two front-facing markers,
|
|
105
|
+
* with a small traveling highlight. Auto-rotates unless `autoRotate={false}`;
|
|
106
|
+
* drag with the pointer to spin manually. Renders a single static frame
|
|
107
|
+
* under `motionReduced()` (no auto-rotation; drag still works).
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```tsx
|
|
111
|
+
* <Globe
|
|
112
|
+
* size={360}
|
|
113
|
+
* markers={[
|
|
114
|
+
* { lat: 40.7, lng: -74.0, label: 'New York' },
|
|
115
|
+
* { lat: 35.7, lng: 139.7, label: 'Tokyo' },
|
|
116
|
+
* ]}
|
|
117
|
+
* arcs={[{ from: [40.7, -74.0], to: [35.7, 139.7] }]}
|
|
118
|
+
* />
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export function Globe(props: GlobeProps): JSX.Element {
|
|
122
|
+
let canvas!: HTMLCanvasElement
|
|
123
|
+
const size = () => props.size ?? 400
|
|
124
|
+
|
|
125
|
+
onMount(() => {
|
|
126
|
+
const ctx = canvas.getContext('2d')
|
|
127
|
+
if (!ctx) return
|
|
128
|
+
|
|
129
|
+
const cleanups: Array<() => void> = []
|
|
130
|
+
const bind = (
|
|
131
|
+
target: EventTarget,
|
|
132
|
+
type: string,
|
|
133
|
+
handler: EventListener,
|
|
134
|
+
opts?: AddEventListenerOptions,
|
|
135
|
+
) => {
|
|
136
|
+
target.addEventListener(type, handler, opts)
|
|
137
|
+
cleanups.push(() => target.removeEventListener(type, handler, opts))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let angle = 0
|
|
141
|
+
let dragging = false
|
|
142
|
+
let lastX = 0
|
|
143
|
+
let rafId = 0
|
|
144
|
+
let lastTs = 0
|
|
145
|
+
// Screen-space marker positions from the last draw, for hover hit-testing
|
|
146
|
+
// (cheap: only a handful of markers, refreshed every frame anyway).
|
|
147
|
+
let markerHits: Array<{ x: number; y: number; label: string }> = []
|
|
148
|
+
|
|
149
|
+
const draw = (): void => {
|
|
150
|
+
const dpr = window.devicePixelRatio || 1
|
|
151
|
+
const cssSize = size()
|
|
152
|
+
const radius = cssSize * 0.42
|
|
153
|
+
const cx = cssSize / 2
|
|
154
|
+
const cy = cssSize / 2
|
|
155
|
+
|
|
156
|
+
if (canvas.width !== cssSize * dpr || canvas.height !== cssSize * dpr) {
|
|
157
|
+
canvas.width = cssSize * dpr
|
|
158
|
+
canvas.height = cssSize * dpr
|
|
159
|
+
canvas.style.width = `${cssSize}px`
|
|
160
|
+
canvas.style.height = `${cssSize}px`
|
|
161
|
+
}
|
|
162
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
163
|
+
ctx.clearRect(0, 0, cssSize, cssSize)
|
|
164
|
+
|
|
165
|
+
const dotColor = resolveColor(canvas, '--foreground', '210 20% 90%')
|
|
166
|
+
const markerColor = resolveColor(canvas, '--accent', '200 90% 60%')
|
|
167
|
+
const arcColor = resolveColor(canvas, '--primary', '250 80% 65%')
|
|
168
|
+
|
|
169
|
+
// Base sphere dots, back-to-front isn't needed (no overlap testing) —
|
|
170
|
+
// just skip anything facing away from the viewer.
|
|
171
|
+
for (const p of SPHERE_POINTS) {
|
|
172
|
+
const proj = project(p, angle, radius, cx, cy)
|
|
173
|
+
if (!proj.front) continue
|
|
174
|
+
const depth = proj.z // 0..1
|
|
175
|
+
ctx.beginPath()
|
|
176
|
+
ctx.fillStyle = dotColor
|
|
177
|
+
ctx.globalAlpha = 0.15 + depth * 0.55
|
|
178
|
+
ctx.arc(proj.x, proj.y, 0.6 + depth * 1.2, 0, Math.PI * 2)
|
|
179
|
+
ctx.fill()
|
|
180
|
+
}
|
|
181
|
+
ctx.globalAlpha = 1
|
|
182
|
+
|
|
183
|
+
// Arcs: only drawn when both endpoints are currently front-facing.
|
|
184
|
+
const arcs = props.arcs ?? []
|
|
185
|
+
for (const arc of arcs) {
|
|
186
|
+
const from = project(latLngToVec3(arc.from[0], arc.from[1]), angle, radius, cx, cy)
|
|
187
|
+
const to = project(latLngToVec3(arc.to[0], arc.to[1]), angle, radius, cx, cy)
|
|
188
|
+
if (!from.front || !to.front) continue
|
|
189
|
+
|
|
190
|
+
const mx = (from.x + to.x) / 2
|
|
191
|
+
const my = (from.y + to.y) / 2
|
|
192
|
+
// Bulge outward from the sphere center, proportional to the chord length.
|
|
193
|
+
const dx = mx - cx
|
|
194
|
+
const dy = my - cy
|
|
195
|
+
const dist = Math.hypot(dx, dy) || 1
|
|
196
|
+
const chord = Math.hypot(to.x - from.x, to.y - from.y)
|
|
197
|
+
const bulge = chord * 0.35
|
|
198
|
+
const ctrlX = mx + (dx / dist) * bulge
|
|
199
|
+
const ctrlY = my + (dy / dist) * bulge
|
|
200
|
+
|
|
201
|
+
ctx.beginPath()
|
|
202
|
+
ctx.strokeStyle = arcColor
|
|
203
|
+
ctx.globalAlpha = 0.55
|
|
204
|
+
ctx.lineWidth = 1.4
|
|
205
|
+
ctx.moveTo(from.x, from.y)
|
|
206
|
+
ctx.quadraticCurveTo(ctrlX, ctrlY, to.x, to.y)
|
|
207
|
+
ctx.stroke()
|
|
208
|
+
ctx.globalAlpha = 1
|
|
209
|
+
|
|
210
|
+
// Traveling highlight: a bright dot eased along the same quadratic,
|
|
211
|
+
// looping with the wall clock so it doesn't need its own rAF state.
|
|
212
|
+
if (!motionReduced()) {
|
|
213
|
+
const t = (performance.now() / 1600) % 1
|
|
214
|
+
const it = 1 - t
|
|
215
|
+
const hx = it * it * from.x + 2 * it * t * ctrlX + t * t * to.x
|
|
216
|
+
const hy = it * it * from.y + 2 * it * t * ctrlY + t * t * to.y
|
|
217
|
+
ctx.beginPath()
|
|
218
|
+
ctx.fillStyle = markerColor
|
|
219
|
+
ctx.arc(hx, hy, 2.4, 0, Math.PI * 2)
|
|
220
|
+
ctx.fill()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Markers: brighter glowing dots, only when front-facing.
|
|
225
|
+
const markers = props.markers ?? []
|
|
226
|
+
const hits: typeof markerHits = []
|
|
227
|
+
for (const marker of markers) {
|
|
228
|
+
const proj = project(latLngToVec3(marker.lat, marker.lng), angle, radius, cx, cy)
|
|
229
|
+
if (!proj.front) continue
|
|
230
|
+
ctx.beginPath()
|
|
231
|
+
ctx.fillStyle = markerColor
|
|
232
|
+
ctx.shadowColor = markerColor
|
|
233
|
+
ctx.shadowBlur = 8
|
|
234
|
+
ctx.arc(proj.x, proj.y, 3, 0, Math.PI * 2)
|
|
235
|
+
ctx.fill()
|
|
236
|
+
ctx.shadowBlur = 0
|
|
237
|
+
if (marker.label) hits.push({ x: proj.x, y: proj.y, label: marker.label })
|
|
238
|
+
}
|
|
239
|
+
markerHits = hits
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const tick = (ts: number): void => {
|
|
243
|
+
const dt = lastTs ? (ts - lastTs) / 1000 : 0
|
|
244
|
+
lastTs = ts
|
|
245
|
+
if (!dragging && !motionReduced() && props.autoRotate !== false) {
|
|
246
|
+
angle += ROTATE_SPEED * dt
|
|
247
|
+
}
|
|
248
|
+
draw()
|
|
249
|
+
// Keep animating while auto-rotating, or to refresh the traveling arc
|
|
250
|
+
// highlight — both are no-ops under reduced motion (frozen frame).
|
|
251
|
+
if (!motionReduced()) rafId = requestAnimationFrame(tick)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (motionReduced()) {
|
|
255
|
+
draw() // single static frame
|
|
256
|
+
} else {
|
|
257
|
+
rafId = requestAnimationFrame(tick)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Pointer drag to spin manually — works even under reduced motion.
|
|
261
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
262
|
+
dragging = true
|
|
263
|
+
lastX = e.clientX
|
|
264
|
+
canvas.setPointerCapture(e.pointerId)
|
|
265
|
+
}
|
|
266
|
+
const onPointerMove = (e: PointerEvent): void => {
|
|
267
|
+
if (!dragging) {
|
|
268
|
+
// Hover hit-test against the last drawn marker positions → native tooltip.
|
|
269
|
+
const rect = canvas.getBoundingClientRect()
|
|
270
|
+
const px = e.clientX - rect.left
|
|
271
|
+
const py = e.clientY - rect.top
|
|
272
|
+
const hit = markerHits.find((m) => Math.hypot(m.x - px, m.y - py) <= 6)
|
|
273
|
+
canvas.title = hit?.label ?? ''
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
const dx = e.clientX - lastX
|
|
277
|
+
lastX = e.clientX
|
|
278
|
+
angle += dx * DRAG_SPEED
|
|
279
|
+
if (motionReduced()) draw() // rAF loop is off; redraw explicitly per move
|
|
280
|
+
}
|
|
281
|
+
const onPointerUp = (e: PointerEvent): void => {
|
|
282
|
+
dragging = false
|
|
283
|
+
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId)
|
|
284
|
+
}
|
|
285
|
+
bind(canvas, 'pointerdown', onPointerDown as EventListener)
|
|
286
|
+
bind(canvas, 'pointermove', onPointerMove as EventListener)
|
|
287
|
+
bind(canvas, 'pointerup', onPointerUp as EventListener)
|
|
288
|
+
bind(canvas, 'pointercancel', onPointerUp as EventListener)
|
|
289
|
+
|
|
290
|
+
// Re-render (static frame) if props change while reduced motion holds the
|
|
291
|
+
// loop frozen — otherwise the rAF loop already picks up prop changes.
|
|
292
|
+
createEffect(() => {
|
|
293
|
+
// Track reactive deps: size + reference identity of markers/arcs.
|
|
294
|
+
void size()
|
|
295
|
+
void props.markers
|
|
296
|
+
void props.arcs
|
|
297
|
+
void props.autoRotate
|
|
298
|
+
if (motionReduced()) draw()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
onCleanup(() => {
|
|
302
|
+
if (rafId) cancelAnimationFrame(rafId)
|
|
303
|
+
cleanups.forEach((fn) => fn())
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<canvas
|
|
309
|
+
ref={canvas}
|
|
310
|
+
class={cn('touch-none rounded-full select-none', props.class)}
|
|
311
|
+
width={size()}
|
|
312
|
+
height={size()}
|
|
313
|
+
role="img"
|
|
314
|
+
aria-label="Interactive globe"
|
|
315
|
+
/>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Kanban board: columns of draggable cards, reorderable within a column and
|
|
2
|
+
// movable across columns. Pointer-events based drag (no HTML5 drag/drop),
|
|
3
|
+
// the same grip-handle + floating-clone idiom as Sortable.tsx, extended to
|
|
4
|
+
// track which column the pointer is currently over so cards can cross
|
|
5
|
+
// container boundaries. No transition/animation is applied to the drag
|
|
6
|
+
// itself (same as Sortable), so there's nothing to gate on reduced motion —
|
|
7
|
+
// drops are instant either way.
|
|
8
|
+
import { createEffect, createSignal, For, Show, type JSX } from 'solid-js'
|
|
9
|
+
import { Portal } from 'solid-js/web'
|
|
10
|
+
import { GripVertical } from 'lucide-solid'
|
|
11
|
+
|
|
12
|
+
import { cn } from '../lib/cn'
|
|
13
|
+
import { Badge } from './Badge'
|
|
14
|
+
import { Card } from './Card'
|
|
15
|
+
|
|
16
|
+
/** A single card on a {@link Kanban} board. */
|
|
17
|
+
export interface KanbanCard {
|
|
18
|
+
/** Stable unique id (used for drag tracking + list keying). */
|
|
19
|
+
id: string
|
|
20
|
+
/** Card body — usually a title/label. */
|
|
21
|
+
title: JSX.Element
|
|
22
|
+
/** Optional trailing badge (e.g. priority, assignee initials). */
|
|
23
|
+
badge?: JSX.Element
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A column of a {@link Kanban} board. */
|
|
27
|
+
export interface KanbanColumn {
|
|
28
|
+
/** Stable unique id. */
|
|
29
|
+
id: string
|
|
30
|
+
/** Column heading. */
|
|
31
|
+
title: string
|
|
32
|
+
/** Cards in the column, top to bottom. */
|
|
33
|
+
cards: KanbanCard[]
|
|
34
|
+
/** Optional WIP limit — the count badge switches to a warning tone past it. */
|
|
35
|
+
limit?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface KanbanProps {
|
|
39
|
+
/** Columns, in display order left to right. */
|
|
40
|
+
columns: KanbanColumn[]
|
|
41
|
+
/** Called with the full new columns array after any drag (reorder or move). */
|
|
42
|
+
onChange?: (columns: KanbanColumn[]) => void
|
|
43
|
+
class?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Drag {
|
|
47
|
+
cardId: string
|
|
48
|
+
card: KanbanCard
|
|
49
|
+
fromColumnId: string
|
|
50
|
+
currentColumnId: string
|
|
51
|
+
y: number
|
|
52
|
+
x: number
|
|
53
|
+
offsetY: number
|
|
54
|
+
offsetX: number
|
|
55
|
+
height: number
|
|
56
|
+
width: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Horizontal Kanban board: glass column panels holding vertical lists of
|
|
61
|
+
* draggable cards. Drag a card by its grip handle to reorder it within a
|
|
62
|
+
* column or drop it into another column — a dashed placeholder marks the
|
|
63
|
+
* drop slot and a floating clone follows the pointer, the same pointer-events
|
|
64
|
+
* idiom as {@link Sortable} extended to track which column's list the
|
|
65
|
+
* pointer is currently over. Works controlled (pass `columns` sourced from
|
|
66
|
+
* your own state + `onChange` to write it back) or uncontrolled (pass
|
|
67
|
+
* `columns` once and just read the final layout from `onChange`).
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```tsx
|
|
71
|
+
* const [columns, setColumns] = createSignal<KanbanColumn[]>([
|
|
72
|
+
* { id: 'todo', title: 'To do', cards: [{ id: '1', title: 'Write spec' }] },
|
|
73
|
+
* { id: 'doing', title: 'Doing', cards: [], limit: 2 },
|
|
74
|
+
* { id: 'done', title: 'Done', cards: [] },
|
|
75
|
+
* ])
|
|
76
|
+
* <Kanban columns={columns()} onChange={setColumns} />
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function Kanban(props: KanbanProps): JSX.Element {
|
|
80
|
+
// eslint-disable-next-line solid/reactivity -- seed once; a createEffect below keeps it in sync
|
|
81
|
+
const [local, setLocal] = createSignal<KanbanColumn[]>(props.columns)
|
|
82
|
+
const [drag, setDrag] = createSignal<Drag | null>(null)
|
|
83
|
+
const listRefs: Record<string, HTMLDivElement | undefined> = {}
|
|
84
|
+
|
|
85
|
+
createEffect(() => {
|
|
86
|
+
const next = props.columns
|
|
87
|
+
if (!drag()) setLocal(next)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const moveCard = (cardId: string, targetColumnId: string, targetIndex: number) => {
|
|
91
|
+
setLocal((cur) => {
|
|
92
|
+
const next = cur.map((col) => ({ ...col, cards: col.cards.slice() }))
|
|
93
|
+
const fromCol = next.find((col) => col.cards.some((card) => card.id === cardId))
|
|
94
|
+
const toCol = next.find((col) => col.id === targetColumnId)
|
|
95
|
+
if (!fromCol || !toCol) return cur
|
|
96
|
+
const fromIndex = fromCol.cards.findIndex((card) => card.id === cardId)
|
|
97
|
+
const [moved] = fromCol.cards.splice(fromIndex, 1)
|
|
98
|
+
toCol.cards.splice(Math.min(targetIndex, toCol.cards.length), 0, moved)
|
|
99
|
+
return next
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const onDragMove = (e: PointerEvent) => {
|
|
104
|
+
const d = drag()
|
|
105
|
+
if (!d) return
|
|
106
|
+
|
|
107
|
+
// Which column's list is the pointer over horizontally?
|
|
108
|
+
let targetColumnId = d.currentColumnId
|
|
109
|
+
for (const [colId, el] of Object.entries(listRefs)) {
|
|
110
|
+
if (!el) continue
|
|
111
|
+
const r = el.getBoundingClientRect()
|
|
112
|
+
if (e.clientX >= r.left && e.clientX <= r.right) {
|
|
113
|
+
targetColumnId = colId
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Where in that column's (other) cards does the pointer sit vertically?
|
|
119
|
+
const listEl = listRefs[targetColumnId]
|
|
120
|
+
const rows = listEl
|
|
121
|
+
? Array.from(listEl.querySelectorAll<HTMLElement>('[data-kanban-card]')).filter(
|
|
122
|
+
(row) => row.dataset.kanbanCard !== d.cardId,
|
|
123
|
+
)
|
|
124
|
+
: []
|
|
125
|
+
let targetIndex = rows.length
|
|
126
|
+
for (let i = 0; i < rows.length; i++) {
|
|
127
|
+
const r = rows[i].getBoundingClientRect()
|
|
128
|
+
if (e.clientY < r.top + r.height / 2) {
|
|
129
|
+
targetIndex = i
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setDrag({ ...d, y: e.clientY, x: e.clientX, currentColumnId: targetColumnId })
|
|
135
|
+
moveCard(d.cardId, targetColumnId, targetIndex)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const onDragStart = (e: PointerEvent, card: KanbanCard, columnId: string) => {
|
|
139
|
+
e.preventDefault()
|
|
140
|
+
const row = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-kanban-card]')
|
|
141
|
+
if (!row) return
|
|
142
|
+
const r = row.getBoundingClientRect()
|
|
143
|
+
setDrag({
|
|
144
|
+
cardId: card.id,
|
|
145
|
+
card,
|
|
146
|
+
fromColumnId: columnId,
|
|
147
|
+
currentColumnId: columnId,
|
|
148
|
+
y: e.clientY,
|
|
149
|
+
x: e.clientX,
|
|
150
|
+
offsetY: e.clientY - r.top,
|
|
151
|
+
offsetX: e.clientX - r.left,
|
|
152
|
+
height: r.height,
|
|
153
|
+
width: r.width,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const move = (ev: PointerEvent) => onDragMove(ev)
|
|
157
|
+
const up = () => {
|
|
158
|
+
window.removeEventListener('pointermove', move)
|
|
159
|
+
window.removeEventListener('pointerup', up)
|
|
160
|
+
const had = drag() !== null
|
|
161
|
+
setDrag(null)
|
|
162
|
+
if (had) props.onChange?.(local())
|
|
163
|
+
}
|
|
164
|
+
window.addEventListener('pointermove', move)
|
|
165
|
+
window.addEventListener('pointerup', up)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div class={cn('flex gap-4 overflow-x-auto pb-2', drag() && 'select-none', props.class)}>
|
|
170
|
+
<For each={local()}>
|
|
171
|
+
{(column) => {
|
|
172
|
+
const count = () => column.cards.length
|
|
173
|
+
const overLimit = () => column.limit !== undefined && count() > column.limit
|
|
174
|
+
return (
|
|
175
|
+
<Card glass class="flex w-72 shrink-0 flex-col">
|
|
176
|
+
<div class="flex items-center justify-between gap-2 border-b border-border p-3">
|
|
177
|
+
<h3 class="truncate font-semibold text-foreground">{column.title}</h3>
|
|
178
|
+
<Badge tone={overLimit() ? 'warning' : 'neutral'}>
|
|
179
|
+
{column.limit !== undefined ? `${count()}/${column.limit}` : `${count()}`}
|
|
180
|
+
</Badge>
|
|
181
|
+
</div>
|
|
182
|
+
<div
|
|
183
|
+
ref={(el) => (listRefs[column.id] = el)}
|
|
184
|
+
class="flex min-h-[60px] flex-1 flex-col gap-2 overflow-y-auto p-3"
|
|
185
|
+
>
|
|
186
|
+
<For each={column.cards}>
|
|
187
|
+
{(card) => {
|
|
188
|
+
const isDragged = () => drag()?.cardId === card.id
|
|
189
|
+
return (
|
|
190
|
+
<Show
|
|
191
|
+
when={!isDragged()}
|
|
192
|
+
fallback={
|
|
193
|
+
<div
|
|
194
|
+
data-kanban-card={card.id}
|
|
195
|
+
class="rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
|
|
196
|
+
style={{ height: `${drag()?.height ?? 0}px` }}
|
|
197
|
+
/>
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
<div
|
|
201
|
+
data-kanban-card={card.id}
|
|
202
|
+
tabIndex={0}
|
|
203
|
+
class="flex items-center gap-2 rounded-lg border border-border bg-card p-2 text-card-foreground"
|
|
204
|
+
>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onPointerDown={(e) => onDragStart(e, card, column.id)}
|
|
208
|
+
style={{ 'touch-action': 'none' }}
|
|
209
|
+
class="shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
|
|
210
|
+
aria-label="Drag to move card"
|
|
211
|
+
>
|
|
212
|
+
<GripVertical class="h-4 w-4" />
|
|
213
|
+
</button>
|
|
214
|
+
<div class="min-w-0 flex-1">{card.title}</div>
|
|
215
|
+
<Show when={card.badge}>{card.badge}</Show>
|
|
216
|
+
</div>
|
|
217
|
+
</Show>
|
|
218
|
+
)
|
|
219
|
+
}}
|
|
220
|
+
</For>
|
|
221
|
+
</div>
|
|
222
|
+
</Card>
|
|
223
|
+
)
|
|
224
|
+
}}
|
|
225
|
+
</For>
|
|
226
|
+
<Show when={drag()}>
|
|
227
|
+
{(d) => (
|
|
228
|
+
<Portal>
|
|
229
|
+
<div
|
|
230
|
+
class="fixed flex items-center gap-2 rounded-lg border border-border bg-card p-2 text-card-foreground shadow-2xl"
|
|
231
|
+
style={{
|
|
232
|
+
left: `${d().x - d().offsetX}px`,
|
|
233
|
+
top: `${d().y - d().offsetY}px`,
|
|
234
|
+
width: `${d().width}px`,
|
|
235
|
+
'pointer-events': 'none',
|
|
236
|
+
'z-index': 70,
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<span class="shrink-0 text-muted-foreground">
|
|
240
|
+
<GripVertical class="h-4 w-4" />
|
|
241
|
+
</span>
|
|
242
|
+
<div class="min-w-0 flex-1">{d().card.title}</div>
|
|
243
|
+
<Show when={d().card.badge}>{d().card.badge}</Show>
|
|
244
|
+
</div>
|
|
245
|
+
</Portal>
|
|
246
|
+
)}
|
|
247
|
+
</Show>
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
package/src/ui/Lamp.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Lamp — a hero/section backdrop: a spotlight beam fanning down from the top
|
|
2
|
+
// center (two symmetric blurred gradient cones + a bright thin line), with
|
|
3
|
+
// `children` sitting in the illuminated area below. Same "self-contained
|
|
4
|
+
// backdrop" idiom as Aurora/SpaceBackground (dark base, token-tinted glow,
|
|
5
|
+
// motionReduced()-aware), just scoped to one hero/section instead of the
|
|
6
|
+
// whole page.
|
|
7
|
+
import { onMount, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface LampProps {
|
|
13
|
+
children?: JSX.Element
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hero/section backdrop: a spotlight beam fanning down from the top center —
|
|
19
|
+
* two symmetric blurred gradient cones (`--primary` / `--accent`) plus a
|
|
20
|
+
* bright thin source line — over a dark base, with `children` centered in
|
|
21
|
+
* the illuminated area below. Grows/brightens in on mount via Motion
|
|
22
|
+
* (opacity + scale); static under {@link motionReduced}. Self-contained
|
|
23
|
+
* (`relative overflow-hidden`); size it with `class` (e.g. `min-h-[28rem]`).
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <Lamp class="min-h-[28rem]">
|
|
28
|
+
* <h1 class="text-4xl font-semibold text-foreground">Build in the light</h1>
|
|
29
|
+
* <p class="mt-4 text-muted-foreground">A hero backdrop with a spotlight glow.</p>
|
|
30
|
+
* </Lamp>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function Lamp(props: LampProps): JSX.Element {
|
|
34
|
+
let beamEl: HTMLDivElement | undefined
|
|
35
|
+
|
|
36
|
+
onMount(() => {
|
|
37
|
+
if (motionReduced() || !beamEl) return
|
|
38
|
+
animate(beamEl, { opacity: [0, 1], scale: [0.85, 1] }, { duration: 0.9, ease: 'easeOut' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
class={cn(
|
|
44
|
+
'relative flex min-h-[24rem] w-full flex-col items-center overflow-hidden bg-background',
|
|
45
|
+
props.class,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
ref={beamEl}
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
class="pointer-events-none absolute inset-0"
|
|
52
|
+
style={motionReduced() ? undefined : { opacity: 0, 'transform-origin': 'top center' }}
|
|
53
|
+
>
|
|
54
|
+
{/* Left cone: apex at top-center, fanning down-left. */}
|
|
55
|
+
<div
|
|
56
|
+
class="absolute left-1/2 top-0 h-72 w-72 -translate-x-full blur-2xl"
|
|
57
|
+
style={{
|
|
58
|
+
background: 'linear-gradient(115deg, hsl(var(--primary) / 0.75), transparent 70%)',
|
|
59
|
+
'clip-path': 'polygon(100% 0%, 0% 100%, 100% 100%)',
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
{/* Right cone: mirror of the left, same apex. */}
|
|
63
|
+
<div
|
|
64
|
+
class="absolute left-1/2 top-0 h-72 w-72 blur-2xl"
|
|
65
|
+
style={{
|
|
66
|
+
background: 'linear-gradient(245deg, hsl(var(--accent) / 0.75), transparent 70%)',
|
|
67
|
+
'clip-path': 'polygon(0% 0%, 100% 100%, 0% 100%)',
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
{/* Bright thin line at the beam's source. */}
|
|
71
|
+
<div
|
|
72
|
+
class="absolute left-1/2 top-0 h-px w-64 -translate-x-1/2 blur-[1px]"
|
|
73
|
+
style={{ background: 'hsl(var(--primary))' }}
|
|
74
|
+
/>
|
|
75
|
+
{/* Soft bloom under the line, grounding the beam. */}
|
|
76
|
+
<div
|
|
77
|
+
class="absolute left-1/2 top-0 h-40 w-80 -translate-x-1/2 blur-2xl"
|
|
78
|
+
style={{
|
|
79
|
+
background: 'radial-gradient(ellipse at top, hsl(var(--primary) / 0.5), transparent 70%)',
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
|
84
|
+
{props.children}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|