@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.
Files changed (63) hide show
  1. package/dist/charts/BarList.d.ts +33 -0
  2. package/dist/charts/CategoryBar.d.ts +23 -0
  3. package/dist/charts/StatusTracker.d.ts +27 -0
  4. package/dist/charts/index.d.ts +3 -0
  5. package/dist/charts.js +446 -316
  6. package/dist/elements.css +278 -0
  7. package/dist/full.css +278 -0
  8. package/dist/index.d.ts +25 -1
  9. package/dist/index.js +6423 -4195
  10. package/dist/ui/AudioWaveform.d.ts +22 -0
  11. package/dist/ui/Callout.d.ts +27 -0
  12. package/dist/ui/Confetti.d.ts +46 -0
  13. package/dist/ui/CouponField.d.ts +33 -0
  14. package/dist/ui/CursorTrail.d.ts +25 -0
  15. package/dist/ui/DiffViewer.d.ts +33 -0
  16. package/dist/ui/FollowingPointer.d.ts +24 -0
  17. package/dist/ui/GanttChart.d.ts +37 -0
  18. package/dist/ui/Globe.d.ts +44 -0
  19. package/dist/ui/Kanban.d.ts +49 -0
  20. package/dist/ui/Lamp.d.ts +22 -0
  21. package/dist/ui/Lightbox.d.ts +41 -0
  22. package/dist/ui/ModelPicker.d.ts +38 -0
  23. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  24. package/dist/ui/PivotTable.d.ts +37 -0
  25. package/dist/ui/ReasoningTrace.d.ts +28 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/Snippet.d.ts +19 -0
  28. package/dist/ui/SuggestionChips.d.ts +25 -0
  29. package/dist/ui/ToolCallTimeline.d.ts +34 -0
  30. package/dist/ui/TreeTable.d.ts +52 -0
  31. package/dist/ui/UsageMeter.d.ts +26 -0
  32. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  33. package/dist/ui/WorldMap.d.ts +36 -0
  34. package/package.json +1 -1
  35. package/src/charts/BarList.tsx +83 -0
  36. package/src/charts/CategoryBar.tsx +95 -0
  37. package/src/charts/StatusTracker.tsx +81 -0
  38. package/src/charts/index.ts +3 -0
  39. package/src/index.ts +48 -1
  40. package/src/ui/AudioWaveform.tsx +190 -0
  41. package/src/ui/Callout.tsx +66 -0
  42. package/src/ui/Confetti.tsx +131 -0
  43. package/src/ui/CouponField.tsx +120 -0
  44. package/src/ui/CursorTrail.tsx +88 -0
  45. package/src/ui/DiffViewer.tsx +166 -0
  46. package/src/ui/FollowingPointer.tsx +112 -0
  47. package/src/ui/GanttChart.tsx +283 -0
  48. package/src/ui/Globe.tsx +317 -0
  49. package/src/ui/Kanban.tsx +250 -0
  50. package/src/ui/Lamp.tsx +88 -0
  51. package/src/ui/Lightbox.tsx +261 -0
  52. package/src/ui/ModelPicker.tsx +119 -0
  53. package/src/ui/OnboardingChecklist.tsx +163 -0
  54. package/src/ui/PivotTable.tsx +0 -0
  55. package/src/ui/ReasoningTrace.tsx +83 -0
  56. package/src/ui/SheetSnap.tsx +264 -0
  57. package/src/ui/Snippet.tsx +64 -0
  58. package/src/ui/SuggestionChips.tsx +65 -0
  59. package/src/ui/ToolCallTimeline.tsx +137 -0
  60. package/src/ui/TreeTable.tsx +172 -0
  61. package/src/ui/UsageMeter.tsx +71 -0
  62. package/src/ui/VideoPlayerShell.tsx +282 -0
  63. 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
+ }