@a4ui/core 0.29.1 → 0.31.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 +10 -1
- package/dist/index.js +4063 -3090
- 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/MorphPresets.d.ts +126 -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 +33 -1
- package/src/ui/CardSpread.tsx +196 -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/MorphPresets.tsx +218 -0
- package/src/ui/SlideArrowButton.tsx +108 -0
- package/src/ui/TimeMachineStack.tsx +197 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// IconMorphButton — a two-state icon toggle whose glyph morphs between an
|
|
2
|
+
// inactive and active icon with a spring: the outgoing icon scales down +
|
|
3
|
+
// rotates + fades out while the incoming scales up + rotates in + fades in,
|
|
4
|
+
// both stacked absolutely inside a fixed-size box. Built for icon actions
|
|
5
|
+
// with an obvious before/after (copy → copied, mute → muted, follow →
|
|
6
|
+
// following). Optional label crossfades alongside the icon.
|
|
7
|
+
import type { JSX } from 'solid-js'
|
|
8
|
+
import { Show, createEffect, createSignal, onCleanup } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
12
|
+
|
|
13
|
+
/** Visual style of an {@link IconMorphButton}. Defaults to `'ghost'`. */
|
|
14
|
+
export type IconMorphButtonVariant = 'solid' | 'ghost' | 'outline'
|
|
15
|
+
|
|
16
|
+
const VARIANT_CLASSES: Record<IconMorphButtonVariant, string> = {
|
|
17
|
+
solid: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
18
|
+
outline: 'border border-border bg-transparent text-foreground hover:bg-muted',
|
|
19
|
+
ghost: 'bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const BASE =
|
|
23
|
+
'inline-flex h-9 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50'
|
|
24
|
+
|
|
25
|
+
const SPRING = { type: 'spring', stiffness: 380, damping: 30 } as const
|
|
26
|
+
|
|
27
|
+
export interface IconMorphButtonProps {
|
|
28
|
+
/** Icon shown while `pressed` is `false`, e.g. `<Copy size={18} />`. */
|
|
29
|
+
inactive: JSX.Element
|
|
30
|
+
/** Icon shown while `pressed` is `true`, e.g. `<Check size={18} />`. */
|
|
31
|
+
active: JSX.Element
|
|
32
|
+
/** Controlled pressed state. Omit to let the button manage its own state. */
|
|
33
|
+
pressed?: boolean
|
|
34
|
+
/** Initial pressed state when uncontrolled. Defaults to `false`. */
|
|
35
|
+
defaultPressed?: boolean
|
|
36
|
+
/** Fired with the next pressed state, controlled or uncontrolled. */
|
|
37
|
+
onChange?: (pressed: boolean) => void
|
|
38
|
+
/**
|
|
39
|
+
* When greater than `0`, auto-reverts to `inactive` after this many ms once
|
|
40
|
+
* pressed (e.g. copy → check → copy). The timer is cleared on cleanup and
|
|
41
|
+
* restarted on every re-press. @default 0
|
|
42
|
+
*/
|
|
43
|
+
revertAfter?: number
|
|
44
|
+
/** Optional label shown next to the icon while `pressed` is `false`. */
|
|
45
|
+
label?: JSX.Element
|
|
46
|
+
/** Optional label shown next to the icon while `pressed` is `true`. */
|
|
47
|
+
activeLabel?: JSX.Element
|
|
48
|
+
/** Visual style. Defaults to `'ghost'`. */
|
|
49
|
+
variant?: IconMorphButtonVariant
|
|
50
|
+
class?: string
|
|
51
|
+
'aria-label'?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Two-state icon toggle button whose icon morphs (spring scale + rotate +
|
|
56
|
+
* fade) between `inactive` and `active` glyphs, stacked in a fixed-size box.
|
|
57
|
+
* Supports controlled (`pressed`/`onChange`) or uncontrolled
|
|
58
|
+
* (`defaultPressed`) use, an optional auto-revert timer, and an optional
|
|
59
|
+
* crossfading label. Falls back to an instant swap under reduced motion.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* <IconMorphButton
|
|
64
|
+
* inactive={<Copy size={18} />}
|
|
65
|
+
* active={<Check size={18} />}
|
|
66
|
+
* aria-label="Copy to clipboard"
|
|
67
|
+
* revertAfter={1500}
|
|
68
|
+
* onChange={(pressed) => {
|
|
69
|
+
* if (pressed) void navigator.clipboard?.writeText('npm install @a4ui/core')
|
|
70
|
+
* }}
|
|
71
|
+
* />
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function IconMorphButton(props: IconMorphButtonProps): JSX.Element {
|
|
75
|
+
const [internalPressed, setInternalPressed] = createSignal(props.defaultPressed ?? false)
|
|
76
|
+
const pressed = (): boolean => props.pressed ?? internalPressed()
|
|
77
|
+
|
|
78
|
+
const setPressed = (next: boolean): void => {
|
|
79
|
+
if (props.pressed === undefined) setInternalPressed(next)
|
|
80
|
+
props.onChange?.(next)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let revertTimer: ReturnType<typeof setTimeout> | undefined
|
|
84
|
+
|
|
85
|
+
const handleClick = (): void => {
|
|
86
|
+
clearTimeout(revertTimer)
|
|
87
|
+
const next = !pressed()
|
|
88
|
+
setPressed(next)
|
|
89
|
+
const revertAfter = props.revertAfter ?? 0
|
|
90
|
+
if (next && revertAfter > 0) {
|
|
91
|
+
revertTimer = setTimeout(() => setPressed(false), revertAfter)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let inactiveIconEl: HTMLSpanElement | undefined
|
|
96
|
+
let activeIconEl: HTMLSpanElement | undefined
|
|
97
|
+
let inactiveLabelEl: HTMLSpanElement | undefined
|
|
98
|
+
let activeLabelEl: HTMLSpanElement | undefined
|
|
99
|
+
|
|
100
|
+
let iconOutControls: ReturnType<typeof animate> | undefined
|
|
101
|
+
let iconInControls: ReturnType<typeof animate> | undefined
|
|
102
|
+
let labelOutControls: ReturnType<typeof animate> | undefined
|
|
103
|
+
let labelInControls: ReturnType<typeof animate> | undefined
|
|
104
|
+
|
|
105
|
+
const setInstant = (el: HTMLElement | undefined, visible: boolean, morph: boolean): void => {
|
|
106
|
+
if (!el) return
|
|
107
|
+
el.style.opacity = visible ? '1' : '0'
|
|
108
|
+
if (morph) el.style.transform = visible ? 'scale(1) rotate(0deg)' : 'scale(0.6) rotate(0deg)'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const applyInstant = (isPressed: boolean): void => {
|
|
112
|
+
setInstant(inactiveIconEl, !isPressed, true)
|
|
113
|
+
setInstant(activeIconEl, isPressed, true)
|
|
114
|
+
setInstant(inactiveLabelEl, !isPressed, false)
|
|
115
|
+
setInstant(activeLabelEl, isPressed, false)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let isFirstRun = true
|
|
119
|
+
|
|
120
|
+
createEffect(() => {
|
|
121
|
+
const isPressed = pressed()
|
|
122
|
+
|
|
123
|
+
if (isFirstRun) {
|
|
124
|
+
isFirstRun = false
|
|
125
|
+
applyInstant(isPressed)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
iconOutControls?.stop()
|
|
130
|
+
iconInControls?.stop()
|
|
131
|
+
labelOutControls?.stop()
|
|
132
|
+
labelInControls?.stop()
|
|
133
|
+
|
|
134
|
+
if (motionReduced()) {
|
|
135
|
+
applyInstant(isPressed)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const outgoingIcon = isPressed ? inactiveIconEl : activeIconEl
|
|
140
|
+
const incomingIcon = isPressed ? activeIconEl : inactiveIconEl
|
|
141
|
+
const outRotate = isPressed ? 90 : -90
|
|
142
|
+
const inRotate = isPressed ? -90 : 90
|
|
143
|
+
|
|
144
|
+
if (outgoingIcon) {
|
|
145
|
+
iconOutControls = animate(
|
|
146
|
+
outgoingIcon,
|
|
147
|
+
{ opacity: [1, 0], scale: [1, 0.6], rotate: [0, outRotate] },
|
|
148
|
+
SPRING,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (incomingIcon) {
|
|
152
|
+
iconInControls = animate(
|
|
153
|
+
incomingIcon,
|
|
154
|
+
{ opacity: [0, 1], scale: [0.6, 1], rotate: [inRotate, 0] },
|
|
155
|
+
SPRING,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const outgoingLabel = isPressed ? inactiveLabelEl : activeLabelEl
|
|
160
|
+
const incomingLabel = isPressed ? activeLabelEl : inactiveLabelEl
|
|
161
|
+
|
|
162
|
+
if (outgoingLabel) {
|
|
163
|
+
labelOutControls = animate(outgoingLabel, { opacity: [1, 0] }, { duration: 0.18, ease: 'easeOut' })
|
|
164
|
+
}
|
|
165
|
+
if (incomingLabel) {
|
|
166
|
+
labelInControls = animate(incomingLabel, { opacity: [0, 1] }, { duration: 0.18, ease: 'easeOut' })
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
onCleanup(() => {
|
|
171
|
+
clearTimeout(revertTimer)
|
|
172
|
+
iconOutControls?.stop()
|
|
173
|
+
iconInControls?.stop()
|
|
174
|
+
labelOutControls?.stop()
|
|
175
|
+
labelInControls?.stop()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const hasLabel = (): boolean => props.label !== undefined || props.activeLabel !== undefined
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
aria-pressed={pressed()}
|
|
184
|
+
aria-label={props['aria-label']}
|
|
185
|
+
onClick={handleClick}
|
|
186
|
+
class={cn(BASE, hasLabel() ? 'px-3' : 'w-9', VARIANT_CLASSES[props.variant ?? 'ghost'], props.class)}
|
|
187
|
+
>
|
|
188
|
+
<span class="relative inline-block h-5 w-5 shrink-0">
|
|
189
|
+
<span
|
|
190
|
+
ref={inactiveIconEl}
|
|
191
|
+
class="absolute inset-0 flex items-center justify-center will-change-transform"
|
|
192
|
+
aria-hidden={pressed()}
|
|
193
|
+
>
|
|
194
|
+
{props.inactive}
|
|
195
|
+
</span>
|
|
196
|
+
<span
|
|
197
|
+
ref={activeIconEl}
|
|
198
|
+
class="absolute inset-0 flex items-center justify-center will-change-transform"
|
|
199
|
+
aria-hidden={!pressed()}
|
|
200
|
+
>
|
|
201
|
+
{props.active}
|
|
202
|
+
</span>
|
|
203
|
+
</span>
|
|
204
|
+
<Show when={hasLabel()}>
|
|
205
|
+
<span class="inline-grid">
|
|
206
|
+
<Show when={props.label}>
|
|
207
|
+
<span ref={inactiveLabelEl} class="col-start-1 row-start-1" aria-hidden={pressed()}>
|
|
208
|
+
{props.label}
|
|
209
|
+
</span>
|
|
210
|
+
</Show>
|
|
211
|
+
<Show when={props.activeLabel}>
|
|
212
|
+
<span ref={activeLabelEl} class="col-start-1 row-start-1" aria-hidden={!pressed()}>
|
|
213
|
+
{props.activeLabel}
|
|
214
|
+
</span>
|
|
215
|
+
</Show>
|
|
216
|
+
</span>
|
|
217
|
+
</Show>
|
|
218
|
+
</button>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
@@ -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
|
+
}
|