@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.
- package/dist/elements.css +154 -0
- package/dist/full.css +154 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +4184 -3403
- package/dist/ui/CardSpread.d.ts +37 -0
- package/dist/ui/Carousel3D.d.ts +38 -0
- package/dist/ui/FocusBlurGroup.d.ts +32 -0
- package/dist/ui/IconMorphButton.d.ts +50 -0
- package/dist/ui/LikeButton.d.ts +35 -0
- package/dist/ui/MicroButton.d.ts +32 -0
- package/dist/ui/SlideArrowButton.d.ts +30 -0
- package/dist/ui/TimeMachineStack.d.ts +34 -0
- package/package.json +2 -1
- package/src/index.ts +21 -1
- package/src/ui/CardSpread.tsx +194 -0
- package/src/ui/Carousel3D.tsx +308 -0
- package/src/ui/FocusBlurGroup.tsx +69 -0
- package/src/ui/IconMorphButton.tsx +220 -0
- package/src/ui/LikeButton.tsx +199 -0
- package/src/ui/MicroButton.tsx +205 -0
- package/src/ui/SlideArrowButton.tsx +108 -0
- package/src/ui/StreamingText.tsx +7 -2
- package/src/ui/TimeMachineStack.tsx +197 -0
|
@@ -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
|
+
}
|
package/src/ui/StreamingText.tsx
CHANGED
|
@@ -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
|
-
|
|
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()
|