@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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Bar-style audio waveform over a hidden native <audio> element — no media
|
|
2
|
+
// library. When no real `peaks` are supplied, a deterministic placeholder
|
|
3
|
+
// waveform is derived from the bar index (abs(sin(...)) blend) so the shape
|
|
4
|
+
// is stable across renders instead of reshuffling on every re-render like
|
|
5
|
+
// Math.random() would.
|
|
6
|
+
import { Pause, Play } from 'lucide-solid'
|
|
7
|
+
import type { JSX } from 'solid-js'
|
|
8
|
+
import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { motionReduced } from '../lib/motion'
|
|
12
|
+
|
|
13
|
+
export interface AudioWaveformProps {
|
|
14
|
+
src?: string
|
|
15
|
+
peaks?: number[]
|
|
16
|
+
/** Bar row height in pixels. @default 64 */
|
|
17
|
+
height?: number
|
|
18
|
+
class?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PLACEHOLDER_BAR_COUNT = 48
|
|
22
|
+
// Demo progress rate (fraction of the fake "duration" per animation frame
|
|
23
|
+
// tick) used only when there's no `src` to drive real audio playback.
|
|
24
|
+
const DEMO_TICK_MS = 100
|
|
25
|
+
const DEMO_STEP = 0.01
|
|
26
|
+
|
|
27
|
+
/** Deterministic placeholder bar heights (0..1), no Math.random. */
|
|
28
|
+
function placeholderPeaks(count: number): number[] {
|
|
29
|
+
return Array.from({ length: count }, (_, i) => {
|
|
30
|
+
const a = Math.abs(Math.sin(i * 0.35))
|
|
31
|
+
const b = Math.abs(Math.sin(i * 0.09 + 1.2))
|
|
32
|
+
return 0.15 + 0.85 * (0.65 * a + 0.35 * b)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Waveform of vertical bars driven by an optional hidden `<audio>` element.
|
|
38
|
+
* Pass `peaks` (numbers in `0..1`) for a real waveform, or omit it for a
|
|
39
|
+
* deterministic placeholder shape. The played portion (up to
|
|
40
|
+
* `currentTime / duration`) is highlighted; clicking a bar seeks there. With
|
|
41
|
+
* no `src`, playback is simulated on a timer for demo purposes.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <AudioWaveform src="/media/track.mp3" />
|
|
46
|
+
* <AudioWaveform peaks={[0.2, 0.8, 0.4, 0.9, 0.3]} height={48} />
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function AudioWaveform(props: AudioWaveformProps): JSX.Element {
|
|
50
|
+
let audioRef: HTMLAudioElement | undefined
|
|
51
|
+
|
|
52
|
+
const [playing, setPlaying] = createSignal(false)
|
|
53
|
+
const [currentTime, setCurrentTime] = createSignal(0)
|
|
54
|
+
const [duration, setDuration] = createSignal(0)
|
|
55
|
+
|
|
56
|
+
const bars = createMemo(() => props.peaks ?? placeholderPeaks(PLACEHOLDER_BAR_COUNT))
|
|
57
|
+
const progress = createMemo(() => (duration() > 0 ? currentTime() / duration() : 0))
|
|
58
|
+
const barHeight = () => props.height ?? 64
|
|
59
|
+
|
|
60
|
+
const togglePlay = (): void => {
|
|
61
|
+
if (props.src) {
|
|
62
|
+
const audio = audioRef
|
|
63
|
+
if (!audio) return
|
|
64
|
+
if (audio.paused) void audio.play()
|
|
65
|
+
else audio.pause()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
// No source: drive a fake demo progress instead of real playback.
|
|
69
|
+
setPlaying((p) => !p)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const seekToRatio = (ratio: number): void => {
|
|
73
|
+
const clamped = Math.min(Math.max(ratio, 0), 1)
|
|
74
|
+
if (props.src) {
|
|
75
|
+
const audio = audioRef
|
|
76
|
+
if (!audio || !duration()) return
|
|
77
|
+
audio.currentTime = clamped * duration()
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
setCurrentTime(clamped * (duration() || 1))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const handleBarClick = (index: number): void => {
|
|
84
|
+
seekToRatio((index + 0.5) / bars().length)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
onMount(() => {
|
|
88
|
+
if (props.src) {
|
|
89
|
+
const audio = audioRef
|
|
90
|
+
if (!audio) return
|
|
91
|
+
|
|
92
|
+
const onPlay = (): void => {
|
|
93
|
+
setPlaying(true)
|
|
94
|
+
}
|
|
95
|
+
const onPause = (): void => {
|
|
96
|
+
setPlaying(false)
|
|
97
|
+
}
|
|
98
|
+
const onTimeUpdate = (): void => {
|
|
99
|
+
setCurrentTime(audio.currentTime)
|
|
100
|
+
}
|
|
101
|
+
const onLoadedMetadata = (): void => {
|
|
102
|
+
setDuration(audio.duration || 0)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
audio.addEventListener('play', onPlay)
|
|
106
|
+
audio.addEventListener('pause', onPause)
|
|
107
|
+
audio.addEventListener('timeupdate', onTimeUpdate)
|
|
108
|
+
audio.addEventListener('loadedmetadata', onLoadedMetadata)
|
|
109
|
+
|
|
110
|
+
onCleanup(() => {
|
|
111
|
+
audio.removeEventListener('play', onPlay)
|
|
112
|
+
audio.removeEventListener('pause', onPause)
|
|
113
|
+
audio.removeEventListener('timeupdate', onTimeUpdate)
|
|
114
|
+
audio.removeEventListener('loadedmetadata', onLoadedMetadata)
|
|
115
|
+
})
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Demo mode: fake a duration and advance currentTime on an interval while playing.
|
|
120
|
+
setDuration(30)
|
|
121
|
+
const interval = setInterval(() => {
|
|
122
|
+
if (!playing() || motionReduced()) return
|
|
123
|
+
setCurrentTime((t) => {
|
|
124
|
+
const next = t + DEMO_STEP * duration()
|
|
125
|
+
if (next >= duration()) {
|
|
126
|
+
setPlaying(false)
|
|
127
|
+
return 0
|
|
128
|
+
}
|
|
129
|
+
return next
|
|
130
|
+
})
|
|
131
|
+
}, DEMO_TICK_MS)
|
|
132
|
+
onCleanup(() => clearInterval(interval))
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div class={cn('card flex items-center gap-3 p-3', props.class)}>
|
|
137
|
+
<Show when={props.src}>
|
|
138
|
+
<audio ref={audioRef} src={props.src} />
|
|
139
|
+
</Show>
|
|
140
|
+
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
aria-label={playing() ? 'Pause' : 'Play'}
|
|
144
|
+
onClick={togglePlay}
|
|
145
|
+
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform duration-150 hover:scale-105 active:scale-95"
|
|
146
|
+
>
|
|
147
|
+
<Show when={playing()} fallback={<Play class="h-4 w-4 translate-x-0.5" fill="currentColor" />}>
|
|
148
|
+
<Pause class="h-4 w-4" fill="currentColor" />
|
|
149
|
+
</Show>
|
|
150
|
+
</button>
|
|
151
|
+
|
|
152
|
+
<div
|
|
153
|
+
role="slider"
|
|
154
|
+
aria-label="Waveform position"
|
|
155
|
+
aria-valuemin={0}
|
|
156
|
+
aria-valuemax={100}
|
|
157
|
+
aria-valuenow={Math.round(progress() * 100)}
|
|
158
|
+
tabIndex={0}
|
|
159
|
+
class="flex flex-1 cursor-pointer items-center gap-[2px]"
|
|
160
|
+
style={{ height: `${barHeight()}px` }}
|
|
161
|
+
onClick={(event) => {
|
|
162
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
163
|
+
seekToRatio((event.clientX - rect.left) / rect.width)
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
<For each={bars()}>
|
|
167
|
+
{(peak, index) => {
|
|
168
|
+
const played = createMemo(() => index() / bars().length <= progress())
|
|
169
|
+
return (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
tabIndex={-1}
|
|
174
|
+
onClick={(event) => {
|
|
175
|
+
event.stopPropagation()
|
|
176
|
+
handleBarClick(index())
|
|
177
|
+
}}
|
|
178
|
+
class={cn(
|
|
179
|
+
'w-full min-w-[2px] flex-1 rounded-full transition-colors duration-150',
|
|
180
|
+
played() ? 'bg-primary' : 'bg-muted',
|
|
181
|
+
)}
|
|
182
|
+
style={{ height: `${Math.max(peak, 0.06) * 100}%` }}
|
|
183
|
+
/>
|
|
184
|
+
)
|
|
185
|
+
}}
|
|
186
|
+
</For>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Prose-embed highlight block for docs/guidance content. Distinct from Alert
|
|
2
|
+
// (a dismissible notification banner): Callout sits inline in a body of text —
|
|
3
|
+
// left accent border, tinted background, comfortable padding — and is never
|
|
4
|
+
// dismissed. Tone tokens are shared with Alert so info/success/warning/danger
|
|
5
|
+
// read identically across both components.
|
|
6
|
+
import { CircleCheck, CircleX, Info, TriangleAlert } from 'lucide-solid'
|
|
7
|
+
import type { Component, JSX } from 'solid-js'
|
|
8
|
+
import { Show } from 'solid-js'
|
|
9
|
+
import { Dynamic } from 'solid-js/web'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
|
|
13
|
+
/** Semantic tone of a {@link Callout}; drives the accent border, tint, and default icon. */
|
|
14
|
+
export type CalloutTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
|
|
15
|
+
|
|
16
|
+
const TONE: Record<CalloutTone, { wrap: string; icon: string; Icon: Component<{ class?: string }> }> = {
|
|
17
|
+
info: { wrap: 'border-sky-500 bg-sky-500/10', icon: 'text-sky-500', Icon: Info },
|
|
18
|
+
success: { wrap: 'border-emerald-500 bg-emerald-500/10', icon: 'text-emerald-500', Icon: CircleCheck },
|
|
19
|
+
warning: { wrap: 'border-amber-500 bg-amber-500/10', icon: 'text-amber-500', Icon: TriangleAlert },
|
|
20
|
+
danger: { wrap: 'border-rose-500 bg-rose-500/10', icon: 'text-rose-500', Icon: CircleX },
|
|
21
|
+
neutral: { wrap: 'border-border bg-muted', icon: 'text-muted-foreground', Icon: Info },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CalloutProps {
|
|
25
|
+
/** Visual/semantic tone. Defaults to `'info'`. */
|
|
26
|
+
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'
|
|
27
|
+
/** Optional bold heading shown above the body. */
|
|
28
|
+
title?: JSX.Element
|
|
29
|
+
/** Overrides the tone's default icon. */
|
|
30
|
+
icon?: JSX.Element
|
|
31
|
+
children: JSX.Element
|
|
32
|
+
class?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Inline highlighted block for embedding guidance/notes inside prose (docs,
|
|
37
|
+
* READMEs, long-form content) — a left accent border, a faint tone-tinted
|
|
38
|
+
* background, and an icon. Unlike {@link Alert} it isn't a dismissible
|
|
39
|
+
* notification; it's part of the content flow.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <Callout tone="warning" title="Before you continue">
|
|
44
|
+
* This action can't be undone once the migration starts.
|
|
45
|
+
* </Callout>
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function Callout(props: CalloutProps): JSX.Element {
|
|
49
|
+
const tone = () => TONE[props.tone ?? 'info']
|
|
50
|
+
return (
|
|
51
|
+
<div class={cn('flex gap-3 rounded-lg border-l-4 p-4 text-sm text-foreground', tone().wrap, props.class)}>
|
|
52
|
+
<Show
|
|
53
|
+
when={props.icon}
|
|
54
|
+
fallback={<Dynamic component={tone().Icon} class={cn('mt-0.5 h-4 w-4 shrink-0', tone().icon)} />}
|
|
55
|
+
>
|
|
56
|
+
{props.icon}
|
|
57
|
+
</Show>
|
|
58
|
+
<div class="flex-1">
|
|
59
|
+
<Show when={props.title}>
|
|
60
|
+
<p class="font-semibold">{props.title}</p>
|
|
61
|
+
</Show>
|
|
62
|
+
<div class="text-muted-foreground">{props.children}</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Decorative confetti burst: a spawn-then-remove particle idiom (same as
|
|
2
|
+
// Ripple.tsx) driven by the native Web Animations API — each piece is a
|
|
3
|
+
// one-shot element with nothing for a JS animation engine to add. Angle and
|
|
4
|
+
// velocity per piece are derived deterministically from the piece's index
|
|
5
|
+
// (via Math.sin), not Math.random, so a burst is reproducible and doesn't
|
|
6
|
+
// depend on an RNG.
|
|
7
|
+
import { createEffect, on, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface ConfettiProps {
|
|
13
|
+
/** Bump this (e.g. a counter) to fire a burst. Mount does not count. */
|
|
14
|
+
trigger?: number
|
|
15
|
+
/** Number of pieces per burst. @default 80 */
|
|
16
|
+
count?: number
|
|
17
|
+
class?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CONFETTI_COLORS = ['hsl(var(--primary))', 'hsl(var(--accent))', 'hsl(var(--foreground))']
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fires one burst of confetti pieces from the bottom-center of `container`,
|
|
24
|
+
* flying up and outward in a fan, rotating, then falling with gravity and
|
|
25
|
+
* fading out — each piece removes itself once its animation finishes. Angle,
|
|
26
|
+
* speed, fall distance and spin are derived deterministically from each
|
|
27
|
+
* piece's index (Math.sin), so the same `count` always produces the same
|
|
28
|
+
* fan. `container` should be `position: relative` (or already positioned)
|
|
29
|
+
* with `overflow: hidden` so pieces don't affect layout. No-op under
|
|
30
|
+
* {@link motionReduced}. This is the primitive behind {@link Confetti}.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* fireConfetti(cardEl, { count: 120 })
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function fireConfetti(container: HTMLElement, opts: { count?: number } = {}): void {
|
|
38
|
+
if (motionReduced()) return
|
|
39
|
+
const count = opts.count ?? 80
|
|
40
|
+
const rect = container.getBoundingClientRect()
|
|
41
|
+
const originX = rect.width / 2
|
|
42
|
+
const originY = rect.height
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < count; i++) {
|
|
45
|
+
// Fan the burst across a ~126° arc centered straight up; wobble is a
|
|
46
|
+
// deterministic pseudo-random 0..1 derived from the index.
|
|
47
|
+
const spread = Math.PI * 0.7
|
|
48
|
+
const wobble = Math.sin(i * 12.9898) * 0.5 + 0.5
|
|
49
|
+
const angle = -Math.PI / 2 + (wobble - 0.5) * spread
|
|
50
|
+
const speed = 180 + (Math.sin(i * 4.567) * 0.5 + 0.5) * 160
|
|
51
|
+
const dx = Math.cos(angle) * speed
|
|
52
|
+
const dy = Math.sin(angle) * speed
|
|
53
|
+
const fall = 200 + (Math.sin(i * 5.113) * 0.5 + 0.5) * 160
|
|
54
|
+
const rotation = 240 + (Math.sin(i * 3.377) * 0.5 + 0.5) * 640
|
|
55
|
+
const duration = 900 + (Math.sin(i * 2.1) * 0.5 + 0.5) * 500
|
|
56
|
+
|
|
57
|
+
const el = document.createElement('span')
|
|
58
|
+
el.setAttribute('aria-hidden', 'true')
|
|
59
|
+
Object.assign(el.style, {
|
|
60
|
+
position: 'absolute',
|
|
61
|
+
left: `${originX}px`,
|
|
62
|
+
top: `${originY}px`,
|
|
63
|
+
width: `${5 + (i % 3) * 2}px`,
|
|
64
|
+
height: `${10 + (i % 4) * 2}px`,
|
|
65
|
+
borderRadius: '1px',
|
|
66
|
+
background: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
|
67
|
+
pointerEvents: 'none',
|
|
68
|
+
})
|
|
69
|
+
container.appendChild(el)
|
|
70
|
+
|
|
71
|
+
const animation = el.animate(
|
|
72
|
+
[
|
|
73
|
+
{ transform: 'translate(-50%,-50%) translate(0px,0px) rotate(0deg)', opacity: '1' },
|
|
74
|
+
{
|
|
75
|
+
transform: `translate(-50%,-50%) translate(${dx * 0.7}px,${dy}px) rotate(${rotation * 0.5}deg)`,
|
|
76
|
+
opacity: '1',
|
|
77
|
+
offset: 0.45,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
transform: `translate(-50%,-50%) translate(${dx}px,${dy + fall}px) rotate(${rotation}deg)`,
|
|
81
|
+
opacity: '0',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
{ duration, easing: 'cubic-bezier(0.2, 0.6, 0.4, 1)' },
|
|
85
|
+
)
|
|
86
|
+
const done = (): void => el.remove()
|
|
87
|
+
animation.finished.then(done).catch(done)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Absolutely-positioned confetti layer: fires a burst of colored pieces from
|
|
93
|
+
* the bottom-center whenever `trigger` changes (the initial mount does not
|
|
94
|
+
* fire one). Purely decorative — `pointer-events-none` and `aria-hidden`.
|
|
95
|
+
* Place it inside a `position: relative` container; pair with a `count`
|
|
96
|
+
* prop, or reach for {@link fireConfetti} directly to fire into an
|
|
97
|
+
* arbitrary element outside Solid's render tree.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* const [bursts, setBursts] = createSignal(0)
|
|
102
|
+
* return (
|
|
103
|
+
* <div class="relative h-64">
|
|
104
|
+
* <Confetti trigger={bursts()} count={100} />
|
|
105
|
+
* <Button onClick={() => setBursts((n) => n + 1)}>Celebrate</Button>
|
|
106
|
+
* </div>
|
|
107
|
+
* )
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export function Confetti(props: ConfettiProps): JSX.Element {
|
|
111
|
+
let containerEl: HTMLDivElement | undefined
|
|
112
|
+
|
|
113
|
+
createEffect(
|
|
114
|
+
on(
|
|
115
|
+
() => props.trigger,
|
|
116
|
+
() => {
|
|
117
|
+
if (!containerEl) return
|
|
118
|
+
fireConfetti(containerEl, { count: props.count })
|
|
119
|
+
},
|
|
120
|
+
{ defer: true },
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
ref={containerEl}
|
|
127
|
+
class={cn('absolute inset-0 overflow-hidden pointer-events-none', props.class)}
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Input + Apply button with explicit idle/loading/success/error states.
|
|
2
|
+
// Rejected coupons surface as { ok: false } and render inline; unexpected
|
|
3
|
+
// rejections from `onApply` are not caught here, so they propagate as
|
|
4
|
+
// unhandled rejections instead of being silently swallowed.
|
|
5
|
+
import { CircleCheck, X } from 'lucide-solid'
|
|
6
|
+
import type { JSX } from 'solid-js'
|
|
7
|
+
import { createSignal, Show } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { Button } from './Button'
|
|
11
|
+
import { Input } from './Input'
|
|
12
|
+
import { Spinner } from './Spinner'
|
|
13
|
+
|
|
14
|
+
export interface CouponFieldProps {
|
|
15
|
+
/** Validate/apply a coupon code. Resolve with `{ ok: false }` for a rejected
|
|
16
|
+
* code — do not throw for expected rejections, only for unexpected failures. */
|
|
17
|
+
onApply: (code: string) => Promise<{ ok: boolean; message?: string; discount?: string }>
|
|
18
|
+
placeholder?: string
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type State =
|
|
23
|
+
| { status: 'idle' | 'loading' }
|
|
24
|
+
| { status: 'success'; code: string; discount?: string }
|
|
25
|
+
| { status: 'error'; message: string }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Coupon/promo code input with an "Apply" button. Tracks explicit
|
|
29
|
+
* idle/loading/success/error state: loading shows a spinner and disables the
|
|
30
|
+
* input, success shows the applied discount with a way to remove it and try
|
|
31
|
+
* another code, error shows the failure message inline and lets the user retry.
|
|
32
|
+
* Rejections are read from the resolved `{ ok: false }` result, not caught
|
|
33
|
+
* exceptions — an unexpected throw from `onApply` propagates uncaught.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <CouponField
|
|
38
|
+
* onApply={async (code) => {
|
|
39
|
+
* const res = await api.applyCoupon(code)
|
|
40
|
+
* return res.valid
|
|
41
|
+
* ? { ok: true, discount: '-$10' }
|
|
42
|
+
* : { ok: false, message: 'That code has expired.' }
|
|
43
|
+
* }}
|
|
44
|
+
* />
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function CouponField(props: CouponFieldProps): JSX.Element {
|
|
48
|
+
const [code, setCode] = createSignal('')
|
|
49
|
+
const [state, setState] = createSignal<State>({ status: 'idle' })
|
|
50
|
+
|
|
51
|
+
const apply = async (): Promise<void> => {
|
|
52
|
+
const value = code().trim()
|
|
53
|
+
if (!value || state().status === 'loading') return
|
|
54
|
+
setState({ status: 'loading' })
|
|
55
|
+
const result = await props.onApply(value)
|
|
56
|
+
if (result.ok) {
|
|
57
|
+
setState({ status: 'success', code: value, discount: result.discount })
|
|
58
|
+
} else {
|
|
59
|
+
setState({ status: 'error', message: result.message ?? 'This code could not be applied.' })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const remove = (): void => {
|
|
64
|
+
setCode('')
|
|
65
|
+
setState({ status: 'idle' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isLoading = () => state().status === 'loading'
|
|
69
|
+
const isSuccess = () => state().status === 'success'
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div class={cn('flex flex-col gap-2', props.class)}>
|
|
73
|
+
<Show
|
|
74
|
+
when={!isSuccess()}
|
|
75
|
+
fallback={
|
|
76
|
+
<div class="flex items-center justify-between rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm">
|
|
77
|
+
<span class="flex items-center gap-2 text-emerald-500">
|
|
78
|
+
<CircleCheck class="h-4 w-4 shrink-0" />
|
|
79
|
+
<span class="text-foreground">
|
|
80
|
+
{(state() as { status: 'success'; code: string; discount?: string }).discount ?? 'Discount'}{' '}
|
|
81
|
+
applied
|
|
82
|
+
</span>
|
|
83
|
+
</span>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
aria-label="Remove coupon"
|
|
87
|
+
onClick={remove}
|
|
88
|
+
class="text-muted-foreground hover:text-foreground"
|
|
89
|
+
>
|
|
90
|
+
<X class="h-4 w-4" />
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<div class="flex gap-2">
|
|
96
|
+
<Input
|
|
97
|
+
value={code()}
|
|
98
|
+
onInput={setCode}
|
|
99
|
+
disabled={isLoading()}
|
|
100
|
+
placeholder={props.placeholder ?? 'Coupon code'}
|
|
101
|
+
onKeyDown={(ev) => {
|
|
102
|
+
if (ev.key === 'Enter') {
|
|
103
|
+
ev.preventDefault()
|
|
104
|
+
void apply()
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
<Button variant="outline" disabled={isLoading() || !code().trim()} onClick={() => void apply()}>
|
|
109
|
+
<Show when={isLoading()} fallback="Apply">
|
|
110
|
+
<Spinner class="h-4 w-4" label="Applying coupon" />
|
|
111
|
+
</Show>
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
<Show when={state().status === 'error'}>
|
|
115
|
+
<p class="text-sm text-destructive">{(state() as { status: 'error'; message: string }).message}</p>
|
|
116
|
+
</Show>
|
|
117
|
+
</Show>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Decorative cursor trail: same spawn-then-remove particle idiom as
|
|
2
|
+
// Ripple.tsx / Confetti.tsx, driven by the native Web Animations API. The
|
|
3
|
+
// layer itself is pointer-events-none, so the listener lives on the
|
|
4
|
+
// `relative` parent that hosts it.
|
|
5
|
+
import { onCleanup, onMount, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface CursorTrailProps {
|
|
11
|
+
/** Blob tone. @default 'primary' */
|
|
12
|
+
color?: 'primary' | 'accent'
|
|
13
|
+
/** Blob diameter in px. @default 24 */
|
|
14
|
+
size?: number
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SPAWN_THROTTLE_MS = 20
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Layer that spawns a soft, blurred blob at the cursor position on every
|
|
22
|
+
* throttled `pointermove` over its parent, scaling down and fading out as
|
|
23
|
+
* it removes itself — leaving a trail. Must sit inside a `position:
|
|
24
|
+
* relative` parent (the layer listens on `parentElement`, since it is
|
|
25
|
+
* itself `pointer-events-none`). Purely decorative — `pointer-events-none`
|
|
26
|
+
* and `aria-hidden`. No-op under {@link motionReduced}; listeners are
|
|
27
|
+
* cleaned up on unmount.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <div class="relative h-48 rounded-lg border">
|
|
32
|
+
* <CursorTrail color="accent" size={32} />
|
|
33
|
+
* </div>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function CursorTrail(props: CursorTrailProps): JSX.Element {
|
|
37
|
+
let layerEl: HTMLDivElement | undefined
|
|
38
|
+
let lastSpawn = 0
|
|
39
|
+
|
|
40
|
+
const handlePointerMove = (event: PointerEvent): void => {
|
|
41
|
+
if (motionReduced() || !layerEl) return
|
|
42
|
+
const now = performance.now()
|
|
43
|
+
if (now - lastSpawn < SPAWN_THROTTLE_MS) return
|
|
44
|
+
lastSpawn = now
|
|
45
|
+
|
|
46
|
+
const rect = layerEl.getBoundingClientRect()
|
|
47
|
+
const x = event.clientX - rect.left
|
|
48
|
+
const y = event.clientY - rect.top
|
|
49
|
+
const size = props.size ?? 24
|
|
50
|
+
const tone = props.color === 'accent' ? 'accent' : 'primary'
|
|
51
|
+
|
|
52
|
+
const el = document.createElement('span')
|
|
53
|
+
el.setAttribute('aria-hidden', 'true')
|
|
54
|
+
Object.assign(el.style, {
|
|
55
|
+
position: 'absolute',
|
|
56
|
+
left: `${x}px`,
|
|
57
|
+
top: `${y}px`,
|
|
58
|
+
width: `${size}px`,
|
|
59
|
+
height: `${size}px`,
|
|
60
|
+
borderRadius: '9999px',
|
|
61
|
+
background: `hsl(var(--${tone}) / 0.35)`,
|
|
62
|
+
filter: 'blur(6px)',
|
|
63
|
+
pointerEvents: 'none',
|
|
64
|
+
transform: 'translate(-50%,-50%) scale(1)',
|
|
65
|
+
})
|
|
66
|
+
layerEl.appendChild(el)
|
|
67
|
+
|
|
68
|
+
const animation = el.animate(
|
|
69
|
+
[
|
|
70
|
+
{ transform: 'translate(-50%,-50%) scale(1)', opacity: '1' },
|
|
71
|
+
{ transform: 'translate(-50%,-50%) scale(0.2)', opacity: '0' },
|
|
72
|
+
],
|
|
73
|
+
{ duration: 500, easing: 'ease-out' },
|
|
74
|
+
)
|
|
75
|
+
const done = (): void => el.remove()
|
|
76
|
+
animation.finished.then(done).catch(done)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onMount(() => {
|
|
80
|
+
const parent = layerEl?.parentElement
|
|
81
|
+
parent?.addEventListener('pointermove', handlePointerMove)
|
|
82
|
+
onCleanup(() => parent?.removeEventListener('pointermove', handlePointerMove))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div ref={layerEl} class={cn('absolute inset-0 pointer-events-none', props.class)} aria-hidden="true" />
|
|
87
|
+
)
|
|
88
|
+
}
|