@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,308 @@
|
|
|
1
|
+
// Carousel3D — a 3D coverflow/arc carousel. A `perspective` + `preserve-3d`
|
|
2
|
+
// stage holds absolutely-positioned slides that are repositioned ONLY via
|
|
3
|
+
// `transform` (translate/rotateY/scale) + `opacity` — never width/height/
|
|
4
|
+
// top/left — so every pose change stays compositor-only. Transform changes
|
|
5
|
+
// are driven by a Motion spring when the active slide changes. Falls back to
|
|
6
|
+
// a flat opacity crossfade (no 3D) under reduced motion.
|
|
7
|
+
import { ChevronLeft, ChevronRight } from 'lucide-solid'
|
|
8
|
+
import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, type JSX } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
12
|
+
|
|
13
|
+
/** Layout curve for a {@link Carousel3D}. Defaults to `'coverflow'`. */
|
|
14
|
+
export type Carousel3DVariant = 'coverflow' | 'arc'
|
|
15
|
+
|
|
16
|
+
export interface Carousel3DProps {
|
|
17
|
+
/** Slides shown around the active one, arranged by distance from it. */
|
|
18
|
+
slides: JSX.Element[]
|
|
19
|
+
/** Layout curve. @default 'coverflow' */
|
|
20
|
+
variant?: Carousel3DVariant
|
|
21
|
+
/** Controlled active index. */
|
|
22
|
+
active?: number
|
|
23
|
+
/** Initial active index when uncontrolled. @default 0 */
|
|
24
|
+
defaultActive?: number
|
|
25
|
+
/** Fires with the new index whenever the active slide changes. */
|
|
26
|
+
onChange?: (index: number) => void
|
|
27
|
+
class?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SlidePose {
|
|
31
|
+
x: number
|
|
32
|
+
y: number
|
|
33
|
+
z: number
|
|
34
|
+
rotateY: number
|
|
35
|
+
scale: number
|
|
36
|
+
opacity: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Base position (before the per-offset pose) centers each slide in the stage;
|
|
40
|
+
// the transform's x always starts at `-50%` and adds pixel offsets on top.
|
|
41
|
+
const FLAT_TRANSFORM = 'translate3d(-50%, 0, 0)'
|
|
42
|
+
|
|
43
|
+
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value))
|
|
44
|
+
|
|
45
|
+
function coverflowPose(offset: number): SlidePose {
|
|
46
|
+
const spacing = 130
|
|
47
|
+
const depth = 160
|
|
48
|
+
const angle = 55
|
|
49
|
+
return {
|
|
50
|
+
x: offset * spacing,
|
|
51
|
+
y: 0,
|
|
52
|
+
z: -Math.abs(offset) * depth,
|
|
53
|
+
rotateY: clamp(-offset * angle, -45, 45),
|
|
54
|
+
scale: offset === 0 ? 1 : 0.82,
|
|
55
|
+
opacity: clamp(1 - Math.abs(offset) * 0.35, 0, 1),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function arcPose(offset: number): SlidePose {
|
|
60
|
+
const spacing = 110
|
|
61
|
+
const angle = 14
|
|
62
|
+
const dip = 16
|
|
63
|
+
return {
|
|
64
|
+
x: offset * spacing,
|
|
65
|
+
y: offset ** 2 * dip,
|
|
66
|
+
z: -Math.abs(offset) * 70,
|
|
67
|
+
rotateY: clamp(offset * angle, -50, 50),
|
|
68
|
+
scale: clamp(1 - Math.abs(offset) * 0.16, 0.55, 1),
|
|
69
|
+
opacity: clamp(1 - Math.abs(offset) * 0.3, 0, 1),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function poseFor(variant: Carousel3DVariant, offset: number): SlidePose {
|
|
74
|
+
return variant === 'arc' ? arcPose(offset) : coverflowPose(offset)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function poseTransform(pose: SlidePose): string {
|
|
78
|
+
return `translate3d(calc(-50% + ${pose.x}px), ${pose.y}px, ${pose.z}px) rotateY(${pose.rotateY}deg) scale(${pose.scale})`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A 3D carousel that arranges slides around the active one on a coverflow or
|
|
83
|
+
* shallow arc curve — `perspective` + `preserve-3d`, each slide positioned
|
|
84
|
+
* purely by `transform`/`opacity` (Motion spring) so it stays
|
|
85
|
+
* compositor-only. Supports controlled/uncontrolled `active`, prev/next
|
|
86
|
+
* buttons, dot indicators, arrow-key navigation, and pointer-drag swiping.
|
|
87
|
+
* Falls back to a flat opacity crossfade (no 3D transforms) under reduced
|
|
88
|
+
* motion.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```tsx
|
|
92
|
+
* <Carousel3D
|
|
93
|
+
* variant="coverflow"
|
|
94
|
+
* slides={[
|
|
95
|
+
* <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail A — Ridge Loop</div>,
|
|
96
|
+
* <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail B — Falls Overlook</div>,
|
|
97
|
+
* <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail C — Summit Pass</div>,
|
|
98
|
+
* ]}
|
|
99
|
+
* />
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function Carousel3D(props: Carousel3DProps): JSX.Element {
|
|
103
|
+
const [uncontrolled, setUncontrolled] = createSignal(props.defaultActive ?? 0)
|
|
104
|
+
const count = (): number => props.slides.length
|
|
105
|
+
const clampIndex = (i: number): number => clamp(i, 0, Math.max(0, count() - 1))
|
|
106
|
+
const isControlled = (): boolean => props.active !== undefined
|
|
107
|
+
const active = createMemo((): number =>
|
|
108
|
+
clampIndex(isControlled() ? (props.active as number) : uncontrolled()),
|
|
109
|
+
)
|
|
110
|
+
const variant = (): Carousel3DVariant => props.variant ?? 'coverflow'
|
|
111
|
+
|
|
112
|
+
const setActive = (index: number): void => {
|
|
113
|
+
const next = clampIndex(index)
|
|
114
|
+
if (next === active()) return
|
|
115
|
+
if (!isControlled()) setUncontrolled(next)
|
|
116
|
+
props.onChange?.(next)
|
|
117
|
+
}
|
|
118
|
+
const prev = (): void => setActive(active() - 1)
|
|
119
|
+
const next = (): void => setActive(active() + 1)
|
|
120
|
+
|
|
121
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
122
|
+
if (e.key === 'ArrowLeft') {
|
|
123
|
+
e.preventDefault()
|
|
124
|
+
prev()
|
|
125
|
+
} else if (e.key === 'ArrowRight') {
|
|
126
|
+
e.preventDefault()
|
|
127
|
+
next()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- pose application (static snap on mount, Motion spring on change) ----
|
|
132
|
+
let stage: HTMLDivElement | undefined
|
|
133
|
+
const controls = new Map<number, ReturnType<typeof animate>>()
|
|
134
|
+
|
|
135
|
+
const stopAll = (): void => {
|
|
136
|
+
controls.forEach((c) => c.stop())
|
|
137
|
+
controls.clear()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const items = (): HTMLElement[] =>
|
|
141
|
+
stage ? Array.from(stage.querySelectorAll<HTMLElement>('[data-carousel3d-item]')) : []
|
|
142
|
+
|
|
143
|
+
function applyStatic(): void {
|
|
144
|
+
const a = active()
|
|
145
|
+
const v = variant()
|
|
146
|
+
const reduced = motionReduced()
|
|
147
|
+
items().forEach((el, i) => {
|
|
148
|
+
el.style.zIndex = String(-Math.abs(i - a))
|
|
149
|
+
if (reduced) {
|
|
150
|
+
el.style.transform = FLAT_TRANSFORM
|
|
151
|
+
el.style.opacity = i === a ? '1' : '0'
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const pose = poseFor(v, i - a)
|
|
155
|
+
el.style.transform = poseTransform(pose)
|
|
156
|
+
el.style.opacity = String(pose.opacity)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function applyAnimated(): void {
|
|
161
|
+
if (!stage) return
|
|
162
|
+
const a = active()
|
|
163
|
+
const v = variant()
|
|
164
|
+
const reduced = motionReduced()
|
|
165
|
+
stopAll()
|
|
166
|
+
items().forEach((el, i) => {
|
|
167
|
+
el.style.zIndex = String(-Math.abs(i - a))
|
|
168
|
+
if (reduced) {
|
|
169
|
+
el.style.transform = FLAT_TRANSFORM
|
|
170
|
+
controls.set(i, animate(el, { opacity: i === a ? 1 : 0 }, { duration: 0.25, ease: 'easeOut' }))
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
const pose = poseFor(v, i - a)
|
|
174
|
+
controls.set(
|
|
175
|
+
i,
|
|
176
|
+
animate(
|
|
177
|
+
el,
|
|
178
|
+
{
|
|
179
|
+
x: pose.x,
|
|
180
|
+
y: pose.y,
|
|
181
|
+
z: pose.z,
|
|
182
|
+
rotateY: pose.rotateY,
|
|
183
|
+
scale: pose.scale,
|
|
184
|
+
opacity: pose.opacity,
|
|
185
|
+
},
|
|
186
|
+
{ type: 'spring', stiffness: 300, damping: 32 },
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
onMount(applyStatic)
|
|
193
|
+
|
|
194
|
+
// Re-pose (animated) whenever the active slide, variant, slide count, or
|
|
195
|
+
// reduced-motion state changes. `defer: true` skips the initial run — the
|
|
196
|
+
// onMount `applyStatic` above already seeds the correct pose with no
|
|
197
|
+
// animation, so a slide never flashes in from an untransformed position.
|
|
198
|
+
createEffect(on([active, variant, count, motionReduced], () => applyAnimated(), { defer: true }))
|
|
199
|
+
|
|
200
|
+
onCleanup(stopAll)
|
|
201
|
+
|
|
202
|
+
// --- pointer drag: horizontal movement past a threshold advances one slide
|
|
203
|
+
// per threshold crossed, so a long drag can page through several slides. --
|
|
204
|
+
const [dragging, setDragging] = createSignal(false)
|
|
205
|
+
let startX = 0
|
|
206
|
+
const DRAG_THRESHOLD = 60
|
|
207
|
+
|
|
208
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
209
|
+
if (count() < 2) return
|
|
210
|
+
startX = e.clientX
|
|
211
|
+
setDragging(true)
|
|
212
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
213
|
+
}
|
|
214
|
+
const onPointerMove = (e: PointerEvent): void => {
|
|
215
|
+
if (!dragging()) return
|
|
216
|
+
const dx = e.clientX - startX
|
|
217
|
+
if (dx <= -DRAG_THRESHOLD) {
|
|
218
|
+
next()
|
|
219
|
+
startX = e.clientX
|
|
220
|
+
} else if (dx >= DRAG_THRESHOLD) {
|
|
221
|
+
prev()
|
|
222
|
+
startX = e.clientX
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const endDrag = (): void => {
|
|
226
|
+
setDragging(false)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
role="region"
|
|
232
|
+
aria-roledescription="carousel"
|
|
233
|
+
tabindex="0"
|
|
234
|
+
class={cn(
|
|
235
|
+
'flex w-full flex-col gap-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
|
236
|
+
props.class,
|
|
237
|
+
)}
|
|
238
|
+
onKeyDown={onKeyDown}
|
|
239
|
+
>
|
|
240
|
+
<div
|
|
241
|
+
class={cn(
|
|
242
|
+
'relative h-80 select-none',
|
|
243
|
+
count() > 1 && 'touch-pan-y cursor-grab active:cursor-grabbing',
|
|
244
|
+
)}
|
|
245
|
+
style={{ perspective: '1200px' }}
|
|
246
|
+
onPointerDown={onPointerDown}
|
|
247
|
+
onPointerMove={onPointerMove}
|
|
248
|
+
onPointerUp={endDrag}
|
|
249
|
+
onPointerCancel={endDrag}
|
|
250
|
+
>
|
|
251
|
+
<div ref={stage} class="relative h-full w-full" style={{ 'transform-style': 'preserve-3d' }}>
|
|
252
|
+
<For each={props.slides}>
|
|
253
|
+
{(slide, i) => (
|
|
254
|
+
<div
|
|
255
|
+
data-carousel3d-item
|
|
256
|
+
class="absolute left-1/2 top-0 h-full w-64 will-change-transform"
|
|
257
|
+
aria-hidden={i() !== active()}
|
|
258
|
+
>
|
|
259
|
+
{slide}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</For>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
aria-label="Previous slide"
|
|
268
|
+
disabled={count() === 0 || active() === 0}
|
|
269
|
+
onClick={prev}
|
|
270
|
+
class="absolute left-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-40"
|
|
271
|
+
>
|
|
272
|
+
<ChevronLeft class="h-5 w-5" />
|
|
273
|
+
</button>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
aria-label="Next slide"
|
|
277
|
+
disabled={count() === 0 || active() === count() - 1}
|
|
278
|
+
onClick={next}
|
|
279
|
+
class="absolute right-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-40"
|
|
280
|
+
>
|
|
281
|
+
<ChevronRight class="h-5 w-5" />
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div class="flex items-center justify-center gap-1">
|
|
286
|
+
<For each={props.slides}>
|
|
287
|
+
{(_, i) => (
|
|
288
|
+
// 24×24 hit target (a11y target-size) around a small visual dot.
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
aria-label={`Go to slide ${i() + 1}`}
|
|
292
|
+
aria-current={active() === i() ? 'true' : undefined}
|
|
293
|
+
onClick={() => setActive(i())}
|
|
294
|
+
class="grid h-6 w-6 place-items-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
295
|
+
>
|
|
296
|
+
<span
|
|
297
|
+
class={cn(
|
|
298
|
+
'h-2 w-2 rounded-full transition-colors',
|
|
299
|
+
active() === i() ? 'bg-primary' : 'bg-muted',
|
|
300
|
+
)}
|
|
301
|
+
/>
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
</For>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Spotlight-the-hovered-one container: hovering/focusing one item keeps it
|
|
2
|
+
// sharp/full-opacity while the rest blur + dim. Purely visual — pointer/focus
|
|
3
|
+
// driven, never traps focus or hijacks keyboard. Only `opacity` and `filter`
|
|
4
|
+
// (blur) are animated so the effect stays a compositor-only transition (no
|
|
5
|
+
// layout props ever transition).
|
|
6
|
+
import { createSignal, For, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { motionReduced } from '../lib/motion'
|
|
10
|
+
|
|
11
|
+
export interface FocusBlurGroupProps<T> {
|
|
12
|
+
/** The full data set to render. */
|
|
13
|
+
items: T[]
|
|
14
|
+
/** Row renderer. */
|
|
15
|
+
children: (item: T, index: number) => JSX.Element
|
|
16
|
+
/** Blur (px) applied to non-focused items. @default 2 */
|
|
17
|
+
blur?: number
|
|
18
|
+
/** Opacity (0..1) applied to non-focused items. @default 0.5 */
|
|
19
|
+
dim?: number
|
|
20
|
+
/** Applied to the container — use it for layout (e.g. `flex gap-6`). */
|
|
21
|
+
class?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Container that spotlights whichever item is hovered or keyboard-focused:
|
|
26
|
+
* that item stays sharp and fully opaque while its siblings blur and dim.
|
|
27
|
+
* Only `opacity`/`filter` transition — never a layout property — and under
|
|
28
|
+
* `motionReduced()` the blur is skipped (dim-only, no transition) since blur
|
|
29
|
+
* motion reads as motion-sickness-adjacent.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <FocusBlurGroup items={navLinks} class="flex gap-6">
|
|
34
|
+
* {(link) => (
|
|
35
|
+
* <a href={link.href} class="text-sm font-medium text-foreground">
|
|
36
|
+
* {link.label}
|
|
37
|
+
* </a>
|
|
38
|
+
* )}
|
|
39
|
+
* </FocusBlurGroup>
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function FocusBlurGroup<T>(props: FocusBlurGroupProps<T>): JSX.Element {
|
|
43
|
+
const [hovered, setHovered] = createSignal<number | null>(null)
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div class={cn(props.class)} onPointerLeave={() => setHovered(null)} onFocusOut={() => setHovered(null)}>
|
|
47
|
+
<For each={props.items}>
|
|
48
|
+
{(item, index) => {
|
|
49
|
+
const isFocused = () => hovered() === null || hovered() === index()
|
|
50
|
+
const reduced = motionReduced()
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
onPointerEnter={() => setHovered(index())}
|
|
55
|
+
onFocusIn={() => setHovered(index())}
|
|
56
|
+
style={{
|
|
57
|
+
opacity: isFocused() ? 1 : (props.dim ?? 0.5),
|
|
58
|
+
filter: reduced || isFocused() ? 'none' : `blur(${props.blur ?? 2}px)`,
|
|
59
|
+
transition: reduced ? 'none' : 'opacity 200ms ease, filter 200ms ease',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{props.children(item, index())}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}}
|
|
66
|
+
</For>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|