@a4ui/core 0.29.0 → 0.30.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.
@@ -0,0 +1,199 @@
1
+ // LikeButton — a like/favorite/save-later toggle driven by the `icon` prop
2
+ // (heart/star/bookmark) instead of three near-identical components. On
3
+ // becoming pressed the icon fills, morphs to the accent color, springs a
4
+ // scale pop (Motion's `spring`), and bursts a handful of tiny icon copies
5
+ // outward (staggered via `stagger`) before fading. Only `transform`/`opacity`
6
+ // are animated, matching this file's Motion helpers (see ../lib/motion.ts).
7
+ import { Bookmark, Heart, Star, type LucideProps } from 'lucide-solid'
8
+ import type { Component, JSX } from 'solid-js'
9
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
10
+ import { Dynamic } from 'solid-js/web'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { animate, motionReduced, spring, stagger } from '../lib/motion'
14
+
15
+ /** Which icon a {@link LikeButton} renders — picks the semantic use (like/favorite/save). */
16
+ export type LikeButtonIcon = 'heart' | 'star' | 'bookmark'
17
+
18
+ const ICONS: Record<LikeButtonIcon, Component<LucideProps>> = {
19
+ heart: Heart,
20
+ star: Star,
21
+ bookmark: Bookmark,
22
+ }
23
+
24
+ const BURST_COUNT = 5
25
+ const BURST_RADIUS_PX = 18
26
+ const BURST_STAGGER_S = 0.03
27
+ const BURST_DURATION_S = 0.5
28
+
29
+ interface BurstParticle {
30
+ id: number
31
+ angle: number
32
+ }
33
+
34
+ export interface LikeButtonProps {
35
+ /** Controlled pressed state. When set, wins over internal state — pair with `onChange`. */
36
+ pressed?: boolean
37
+ /** Initial pressed state for uncontrolled use. @default false */
38
+ defaultPressed?: boolean
39
+ onChange?: (pressed: boolean) => void
40
+ /** Which icon to render. @default 'heart' */
41
+ icon?: LikeButtonIcon
42
+ /** Count shown next to the icon (e.g. total likes). */
43
+ count?: number
44
+ /** Show `count`. @default false */
45
+ showCount?: boolean
46
+ /** Class applied to the icon/count when pressed. @default 'text-primary' */
47
+ activeClass?: string
48
+ class?: string
49
+ 'aria-label'?: string
50
+ }
51
+
52
+ /**
53
+ * Like/favorite/save-later toggle button. One component covers all three via
54
+ * `icon` ('heart' | 'star' | 'bookmark') rather than three near-identical
55
+ * toggles. On becoming pressed the icon fills, morphs to `activeClass`,
56
+ * springs a scale pop, and bursts a handful of tiny icon copies outward —
57
+ * skipped under reduced motion, which just flips the filled/unfilled state
58
+ * instantly. Works controlled (`pressed` + `onChange`) or uncontrolled
59
+ * (`defaultPressed`).
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * <LikeButton defaultPressed count={128} showCount aria-label="Like this post" />
64
+ * ```
65
+ */
66
+ export function LikeButton(props: LikeButtonProps): JSX.Element {
67
+ const [internalPressed, setInternalPressed] = createSignal(props.defaultPressed ?? false)
68
+ const pressed = createMemo(() => props.pressed ?? internalPressed())
69
+ const Icon = createMemo(() => ICONS[props.icon ?? 'heart'])
70
+
71
+ const [particles, setParticles] = createSignal<BurstParticle[]>([])
72
+ const particleEls = new Map<number, HTMLSpanElement>()
73
+ let particleId = 0
74
+ let iconEl: SVGSVGElement | undefined
75
+ let countEl: HTMLSpanElement | undefined
76
+ let popControls: ReturnType<typeof animate> | undefined
77
+ let disposed = false
78
+
79
+ onCleanup(() => {
80
+ disposed = true
81
+ popControls?.stop()
82
+ })
83
+
84
+ // Fade + slide the count in whenever it changes (not on initial mount), skipped under reduced motion.
85
+ let hasMountedCount = false
86
+ createEffect(() => {
87
+ void props.count
88
+ if (hasMountedCount && countEl && !motionReduced()) {
89
+ animate(countEl, { opacity: [0, 1], y: [-6, 0] }, { duration: 0.22, ease: 'easeOut' })
90
+ }
91
+ hasMountedCount = true
92
+ })
93
+
94
+ const playPop = (): void => {
95
+ if (!iconEl) return
96
+ popControls?.stop()
97
+ const el = iconEl
98
+ popControls = animate(el, { scale: 1.3 }, { type: spring, stiffness: 600, damping: 10 })
99
+ popControls.finished
100
+ .then(() => {
101
+ if (disposed) return
102
+ popControls = animate(el, { scale: 1 }, { type: spring, stiffness: 500, damping: 14 })
103
+ })
104
+ .catch(() => {})
105
+ }
106
+
107
+ const spawnBurst = (): void => {
108
+ const delayFor = stagger(BURST_STAGGER_S)
109
+ const next: BurstParticle[] = Array.from({ length: BURST_COUNT }, (_, index) => ({
110
+ id: particleId++,
111
+ angle: (360 / BURST_COUNT) * index,
112
+ }))
113
+ setParticles((current) => [...current, ...next])
114
+
115
+ queueMicrotask(() => {
116
+ if (disposed) return
117
+ const finished = next.map((particle, index) => {
118
+ const el = particleEls.get(particle.id)
119
+ if (!el) return Promise.resolve()
120
+ const radians = (particle.angle * Math.PI) / 180
121
+ const dx = Math.cos(radians) * BURST_RADIUS_PX
122
+ const dy = Math.sin(radians) * BURST_RADIUS_PX
123
+ const controls = animate(
124
+ el,
125
+ {
126
+ transform: [
127
+ 'translate(-50%, -50%) scale(0.9)',
128
+ `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.3)`,
129
+ ],
130
+ opacity: [1, 0],
131
+ },
132
+ { duration: BURST_DURATION_S, delay: delayFor(index, next.length), ease: 'easeOut' },
133
+ )
134
+ return controls.finished
135
+ })
136
+
137
+ Promise.allSettled(finished).then(() => {
138
+ for (const particle of next) particleEls.delete(particle.id)
139
+ if (disposed) return
140
+ setParticles((current) => current.filter((p) => !next.some((n) => n.id === p.id)))
141
+ })
142
+ })
143
+ }
144
+
145
+ const handleClick = (): void => {
146
+ const next = !pressed()
147
+ if (props.pressed === undefined) setInternalPressed(next)
148
+ props.onChange?.(next)
149
+
150
+ if (!next || motionReduced()) return
151
+ playPop()
152
+ spawnBurst()
153
+ }
154
+
155
+ return (
156
+ <button
157
+ type="button"
158
+ aria-pressed={pressed()}
159
+ aria-label={props['aria-label']}
160
+ class={cn(
161
+ 'inline-flex items-center gap-1.5 rounded-md text-muted-foreground transition-colors hover:text-foreground',
162
+ pressed() && (props.activeClass ?? 'text-primary'),
163
+ props.class,
164
+ )}
165
+ onClick={handleClick}
166
+ >
167
+ <span class="relative inline-flex h-5 w-5 items-center justify-center">
168
+ <Dynamic
169
+ component={Icon()}
170
+ ref={(el: SVGSVGElement) => {
171
+ iconEl = el
172
+ }}
173
+ class={cn('h-5 w-5 will-change-transform', pressed() && 'fill-current')}
174
+ />
175
+ <span class="pointer-events-none absolute inset-0" aria-hidden="true">
176
+ <For each={particles()}>
177
+ {(particle) => (
178
+ <span
179
+ ref={(el) => particleEls.set(particle.id, el)}
180
+ class="absolute left-1/2 top-1/2 h-2.5 w-2.5 will-change-transform"
181
+ style={{ transform: 'translate(-50%, -50%)' }}
182
+ >
183
+ <Dynamic
184
+ component={Icon()}
185
+ class={cn('h-2.5 w-2.5 fill-current', props.activeClass ?? 'text-primary')}
186
+ />
187
+ </span>
188
+ )}
189
+ </For>
190
+ </span>
191
+ </span>
192
+ <Show when={props.showCount}>
193
+ <span ref={countEl} class="tabular-nums will-change-transform">
194
+ {props.count}
195
+ </span>
196
+ </Show>
197
+ </button>
198
+ )
199
+ }
@@ -0,0 +1,205 @@
1
+ // MicroButton — small icon/label button that plays a short, physically-tuned
2
+ // feedback micro-animation on interaction: spin, shake, pulse, ring, sparkle
3
+ // (on click), or a diagonal glare sweep (on hover). Every effect animates only
4
+ // transform/opacity (never layout), and decorative particles spawn then
5
+ // remove themselves — the same one-shot pattern as spawnRipple in Ripple.tsx.
6
+ // No-op (plain static button) under reduced motion.
7
+ import { type JSX, Show, splitProps } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced, stagger } from '../lib/motion'
11
+
12
+ /** Feedback micro-animation a {@link MicroButton} plays. Defaults to `'ring'`. */
13
+ export type MicroButtonEffect = 'spin' | 'shake' | 'pulse' | 'ring' | 'sparkle' | 'glare'
14
+
15
+ /** Visual style of a {@link MicroButton}. Defaults to `'solid'`. */
16
+ export type MicroButtonVariant = 'solid' | 'ghost' | 'outline'
17
+
18
+ const VARIANT_CLASSES: Record<MicroButtonVariant, string> = {
19
+ solid: 'bg-primary text-primary-foreground',
20
+ ghost: 'text-foreground hover:bg-muted',
21
+ outline: 'border border-border text-foreground',
22
+ }
23
+
24
+ const BASE =
25
+ 'micro-button relative inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'
26
+
27
+ const PARTICLE_BASE = 'pointer-events-none absolute will-change-transform'
28
+
29
+ export interface MicroButtonProps {
30
+ children?: JSX.Element
31
+ /** Feedback micro-animation. `'glare'` plays on hover; the rest play on click. @default 'ring' */
32
+ effect?: MicroButtonEffect
33
+ onClick?: (e: MouseEvent) => void
34
+ /** Visual style. @default 'solid' */
35
+ variant?: MicroButtonVariant
36
+ disabled?: boolean
37
+ class?: string
38
+ 'aria-label'?: string
39
+ }
40
+
41
+ /**
42
+ * Small icon/label button that plays a short feedback micro-animation on
43
+ * interaction — spin, shake, pulse, ring, or sparkle on click, or a diagonal
44
+ * glare sweep on hover. Every effect animates only `transform`/`opacity`, so
45
+ * it stays cheap even on low-end devices; decorative particles are spawned
46
+ * and removed on completion, never left in the DOM. Renders a plain static
47
+ * `<button>` with no effect under reduced motion.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <MicroButton effect="sparkle" aria-label="Like">
52
+ * <Heart size={16} />
53
+ * </MicroButton>
54
+ * ```
55
+ */
56
+ export function MicroButton(props: MicroButtonProps): JSX.Element {
57
+ const [local, rest] = splitProps(props, ['children', 'effect', 'onClick', 'variant', 'disabled', 'class'])
58
+
59
+ let buttonEl!: HTMLButtonElement
60
+ let contentEl!: HTMLSpanElement
61
+ let glareEl: HTMLSpanElement | undefined
62
+ let spins = 0
63
+
64
+ // Every in-flight animation registers a stopper here so onCleanup can halt
65
+ // it (and any pending particle removal) if the button unmounts mid-effect.
66
+ const activeStops = new Set<() => void>()
67
+
68
+ const track = (stop: () => void): (() => void) => {
69
+ activeStops.add(stop)
70
+ return () => activeStops.delete(stop)
71
+ }
72
+
73
+ const trackTransient = (controls: ReturnType<typeof animate>): void => {
74
+ const untrack = track(() => controls.stop())
75
+ controls.then(untrack)
76
+ }
77
+
78
+ const spawnParticle = (build: (el: HTMLSpanElement) => void): HTMLSpanElement => {
79
+ const el = document.createElement('span')
80
+ el.setAttribute('aria-hidden', 'true')
81
+ el.className = PARTICLE_BASE
82
+ build(el)
83
+ buttonEl.appendChild(el)
84
+ return el
85
+ }
86
+
87
+ const trackParticle = (el: HTMLSpanElement, controls: ReturnType<typeof animate>): void => {
88
+ const untrack = track(() => controls.stop())
89
+ const finish = (): void => {
90
+ untrack()
91
+ el.remove()
92
+ }
93
+ controls.then(finish)
94
+ }
95
+
96
+ const playSpin = (): void => {
97
+ spins += 360
98
+ trackTransient(animate(contentEl, { rotate: spins }, { type: 'spring', stiffness: 180, damping: 14 }))
99
+ }
100
+
101
+ const playShake = (): void => {
102
+ trackTransient(animate(contentEl, { x: [0, -4, 4, -3, 3, 0] }, { duration: 0.4, ease: 'easeInOut' }))
103
+ }
104
+
105
+ const spawnRing = (delay: number): void => {
106
+ const el = spawnParticle((e) => {
107
+ e.classList.add('inset-0', 'rounded-full', 'border-2', 'border-current')
108
+ })
109
+ trackParticle(
110
+ el,
111
+ animate(el, { scale: [1, 1.8], opacity: [0.6, 0] }, { duration: 0.7, delay, ease: 'easeOut' }),
112
+ )
113
+ }
114
+
115
+ const playRing = (): void => spawnRing(0)
116
+
117
+ const playPulse = (): void => {
118
+ spawnRing(0)
119
+ spawnRing(0.15)
120
+ }
121
+
122
+ const playSparkle = (): void => {
123
+ const count = 6
124
+ const radius = 20
125
+ for (let i = 0; i < count; i++) {
126
+ const angle = (i / count) * Math.PI * 2
127
+ const dx = Math.cos(angle) * radius
128
+ const dy = Math.sin(angle) * radius
129
+ const el = spawnParticle((e) => {
130
+ e.classList.add('left-1/2', 'top-1/2', 'h-1', 'w-1', 'rounded-full', 'bg-current')
131
+ e.style.marginLeft = '-2px'
132
+ e.style.marginTop = '-2px'
133
+ })
134
+ trackParticle(
135
+ el,
136
+ animate(
137
+ el,
138
+ { x: [0, dx], y: [0, dy], rotate: [0, 180], opacity: [1, 0], scale: [1, 0.4] },
139
+ { duration: 0.55, delay: stagger(0.03)(i, count), ease: 'easeOut' },
140
+ ),
141
+ )
142
+ }
143
+ }
144
+
145
+ const handleClick = (event: MouseEvent): void => {
146
+ local.onClick?.(event)
147
+ if (local.disabled || motionReduced()) return
148
+ switch (local.effect ?? 'ring') {
149
+ case 'spin':
150
+ playSpin()
151
+ break
152
+ case 'shake':
153
+ playShake()
154
+ break
155
+ case 'pulse':
156
+ playPulse()
157
+ break
158
+ case 'sparkle':
159
+ playSparkle()
160
+ break
161
+ case 'ring':
162
+ playRing()
163
+ break
164
+ // 'glare' plays on hover, not click — see handlePointerEnter.
165
+ }
166
+ }
167
+
168
+ const handlePointerEnter = (): void => {
169
+ if ((local.effect ?? 'ring') !== 'glare' || !glareEl || motionReduced()) return
170
+ trackTransient(
171
+ animate(glareEl, { x: ['-100%', '250%'], opacity: [0, 1, 0] }, { duration: 0.6, ease: 'easeInOut' }),
172
+ )
173
+ }
174
+
175
+ return (
176
+ <button
177
+ ref={buttonEl}
178
+ type="button"
179
+ class={cn(
180
+ BASE,
181
+ VARIANT_CLASSES[local.variant ?? 'solid'],
182
+ (local.effect ?? 'ring') === 'glare' && 'overflow-hidden',
183
+ local.class,
184
+ )}
185
+ disabled={local.disabled}
186
+ onClick={handleClick}
187
+ onPointerEnter={handlePointerEnter}
188
+ {...rest}
189
+ >
190
+ <span
191
+ ref={contentEl}
192
+ class="relative inline-flex items-center justify-center gap-2 will-change-transform"
193
+ >
194
+ {local.children}
195
+ </span>
196
+ <Show when={(local.effect ?? 'ring') === 'glare'}>
197
+ <span
198
+ ref={glareEl}
199
+ aria-hidden="true"
200
+ class="pointer-events-none absolute inset-y-0 left-0 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-foreground/25 to-transparent opacity-0 will-change-transform"
201
+ />
202
+ </Show>
203
+ </button>
204
+ )
205
+ }
@@ -0,0 +1,108 @@
1
+ // SlideArrowButton — CTA button where the label slides toward `direction` on
2
+ // hover while an arrow slides in from the opposite edge. Pure CSS
3
+ // (transform + opacity only), so it needs no JS animation engine and gets
4
+ // the reduced-motion guard for free from the global @media block in styles.css.
5
+ import type { JSX, ParentProps } from 'solid-js'
6
+ import { Show, splitProps } from 'solid-js'
7
+ import { ArrowLeft, ArrowRight } from 'lucide-solid'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ /** Direction the label/arrow slide toward on hover. Defaults to `'right'`. */
12
+ export type SlideArrowDirection = 'right' | 'left'
13
+
14
+ /** Visual style of a {@link SlideArrowButton}. Defaults to `'solid'`. */
15
+ export type SlideArrowButtonVariant = 'solid' | 'outline'
16
+
17
+ const VARIANT_CLASSES: Record<SlideArrowButtonVariant, string> = {
18
+ solid: 'bg-primary text-primary-foreground hover:bg-primary/90',
19
+ outline: 'border border-border bg-transparent text-foreground hover:bg-muted',
20
+ }
21
+
22
+ const BUTTON_BASE =
23
+ 'group relative inline-flex items-center justify-center overflow-hidden rounded-md px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50'
24
+
25
+ // Both the label and the arrow only ever animate `transform`/`opacity` —
26
+ // compositor-only properties — so this is a plain CSS transition, no JS
27
+ // animation loop and no layout thrashing.
28
+ const TRANSITION = 'transition-transform duration-200 ease-out'
29
+
30
+ export interface SlideArrowButtonProps extends ParentProps {
31
+ onClick?: (e: MouseEvent) => void
32
+ /** Direction the label/arrow slide toward on hover. Defaults to `'right'`. */
33
+ direction?: SlideArrowDirection
34
+ /** Custom icon; defaults to `ArrowRight`/`ArrowLeft` per `direction`. */
35
+ icon?: JSX.Element
36
+ /** Visual style. Defaults to `'solid'`. */
37
+ variant?: SlideArrowButtonVariant
38
+ disabled?: boolean
39
+ class?: string
40
+ /** Accessible label, forwarded to the underlying `<button>`. */
41
+ 'aria-label'?: string
42
+ }
43
+
44
+ /**
45
+ * CTA button: at rest the label sits centered; on hover it slides toward
46
+ * `direction` while an arrow slides in from the opposite edge. Pure CSS
47
+ * transform/opacity transitions — no JS animation engine.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <SlideArrowButton onClick={() => go()}>Get started</SlideArrowButton>
52
+ * <SlideArrowButton direction="left" variant="outline">Back</SlideArrowButton>
53
+ * ```
54
+ */
55
+ export function SlideArrowButton(props: SlideArrowButtonProps): JSX.Element {
56
+ const [local, rest] = splitProps(props, [
57
+ 'children',
58
+ 'onClick',
59
+ 'direction',
60
+ 'icon',
61
+ 'variant',
62
+ 'disabled',
63
+ 'class',
64
+ 'aria-label',
65
+ ])
66
+
67
+ const direction = (): SlideArrowDirection => local.direction ?? 'right'
68
+ const isRight = () => direction() === 'right'
69
+
70
+ const icon = (): JSX.Element =>
71
+ local.icon ??
72
+ (isRight() ? (
73
+ <ArrowRight class="h-4 w-4" aria-hidden="true" />
74
+ ) : (
75
+ <ArrowLeft class="h-4 w-4" aria-hidden="true" />
76
+ ))
77
+
78
+ // Label slides fully out of the way (100%) in the hover direction, arrow
79
+ // slides in the same amount from the opposite edge — they swap places.
80
+ const labelClasses = (): string =>
81
+ cn(TRANSITION, isRight() ? 'group-hover:-translate-x-full' : 'group-hover:translate-x-full')
82
+
83
+ const arrowClasses = (): string =>
84
+ cn(
85
+ 'absolute inset-0 flex items-center justify-center opacity-0',
86
+ TRANSITION,
87
+ 'transition-[transform,opacity]',
88
+ isRight()
89
+ ? 'translate-x-full group-hover:translate-x-0 group-hover:opacity-100'
90
+ : '-translate-x-full group-hover:translate-x-0 group-hover:opacity-100',
91
+ )
92
+
93
+ return (
94
+ <button
95
+ type="button"
96
+ disabled={local.disabled}
97
+ class={cn(BUTTON_BASE, VARIANT_CLASSES[local.variant ?? 'solid'], local.class)}
98
+ aria-label={local['aria-label']}
99
+ onClick={(e) => local.onClick?.(e)}
100
+ {...rest}
101
+ >
102
+ <span class={labelClasses()}>{local.children}</span>
103
+ <Show when={!local.disabled}>
104
+ <span class={arrowClasses()}>{icon()}</span>
105
+ </Show>
106
+ </button>
107
+ )
108
+ }
@@ -2,7 +2,7 @@
2
2
  // added tail at `speed` chars/sec (via a rAF loop) rather than snapping
3
3
  // straight to the new length, with a blinking caret while more is expected.
4
4
  // Built for AI answer UIs where `text` grows incrementally as tokens arrive.
5
- import { createEffect, createSignal, onCleanup, type JSX } from 'solid-js'
5
+ import { createEffect, createSignal, onCleanup, untrack, type JSX } from 'solid-js'
6
6
 
7
7
  import { cn } from '../lib/cn'
8
8
  import { motionReduced } from '../lib/motion'
@@ -48,7 +48,12 @@ export function StreamingText(props: StreamingTextProps): JSX.Element {
48
48
  createEffect(() => {
49
49
  const text = props.text
50
50
  const isAppend = text.startsWith(prevText)
51
- const from = isAppend ? revealed() : 0
51
+ // `untrack`: read the current revealed count WITHOUT making this effect
52
+ // depend on `revealed` — otherwise the rAF loop's own `setRevealed` would
53
+ // re-trigger this effect every frame, cancelling and restarting the loop
54
+ // (a stuttery, "janky" reveal). The effect should only re-run when the
55
+ // incoming `text`/`streaming`/`speed` change.
56
+ const from = isAppend ? untrack(revealed) : 0
52
57
  prevText = text
53
58
 
54
59
  stop()