@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,197 @@
|
|
|
1
|
+
// TimeMachineStack — Apple-style "time machine" depth stack: the active slide
|
|
2
|
+
// sits full-size at the front while the rest recede into the screen behind it
|
|
3
|
+
// (translated up, pushed back on Z, scaled down, dimmed), with a vertical
|
|
4
|
+
// scrubber of real buttons to jump to any index. Only `transform`/`opacity`
|
|
5
|
+
// are animated (Motion spring), so the stack stays compositor-only. Reduced
|
|
6
|
+
// motion collapses to a flat view of just the active slide.
|
|
7
|
+
import { createEffect, createSignal, For, onCleanup, onMount, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
// Depth-stack geometry, per unit of depth (slides behind the active one).
|
|
13
|
+
const DY = 26 // px recede upward per depth step
|
|
14
|
+
const DZ = 64 // px pushed back on z per depth step
|
|
15
|
+
const SCALE_STEP = 0.08
|
|
16
|
+
const MIN_SCALE = 0.7
|
|
17
|
+
const OPACITY_STEP = 0.18
|
|
18
|
+
|
|
19
|
+
export interface TimeMachineStackProps {
|
|
20
|
+
/** Slide contents, one per stack position. */
|
|
21
|
+
slides: JSX.Element[]
|
|
22
|
+
/** Controlled active index. Omit to manage it internally. */
|
|
23
|
+
active?: number
|
|
24
|
+
/** Initial active index when uncontrolled. @default 0 */
|
|
25
|
+
defaultActive?: number
|
|
26
|
+
/** Fired whenever the active index changes, via click or keyboard. */
|
|
27
|
+
onChange?: (index: number) => void
|
|
28
|
+
class?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DepthTransform {
|
|
32
|
+
y: number
|
|
33
|
+
z: number
|
|
34
|
+
scale: number
|
|
35
|
+
opacity: number
|
|
36
|
+
zIndex: number
|
|
37
|
+
pointerEvents: 'auto' | 'none'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// depth = index - active. depth 0 is the active (front) slide; depth > 0
|
|
41
|
+
// slides recede behind it. depth < 0 are already-viewed slides — they fly
|
|
42
|
+
// forward, out of the stack, and vanish rather than reversing the recede math.
|
|
43
|
+
function depthTransform(index: number, active: number): DepthTransform {
|
|
44
|
+
const depth = index - active
|
|
45
|
+
if (depth >= 0) {
|
|
46
|
+
return {
|
|
47
|
+
y: -depth * DY,
|
|
48
|
+
z: -depth * DZ,
|
|
49
|
+
scale: Math.max(1 - depth * SCALE_STEP, MIN_SCALE),
|
|
50
|
+
opacity: Math.max(1 - depth * OPACITY_STEP, 0),
|
|
51
|
+
zIndex: -depth,
|
|
52
|
+
pointerEvents: depth === 0 ? 'auto' : 'none',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { y: DY, z: DZ * 0.5, scale: 1.05, opacity: 0, zIndex: -depth, pointerEvents: 'none' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apple-style "time machine" depth stack: the active slide is front and
|
|
60
|
+
* full-size, and the rest recede into the screen behind it — each further
|
|
61
|
+
* slide translated up, pushed back on Z, scaled down, and dimmed. A vertical
|
|
62
|
+
* scrubber of real buttons (one per slide), pinned to the right edge, jumps
|
|
63
|
+
* to any index; the stage handles ArrowUp/ArrowDown (and Left/Right) when
|
|
64
|
+
* focused. Animates only `transform`/`opacity` (Motion spring); reduced
|
|
65
|
+
* motion collapses to a flat view of just the active slide.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```tsx
|
|
69
|
+
* <TimeMachineStack
|
|
70
|
+
* defaultActive={0}
|
|
71
|
+
* slides={[
|
|
72
|
+
* <div class="flex h-full items-center justify-center p-6">Sunset over the bay</div>,
|
|
73
|
+
* <div class="flex h-full items-center justify-center p-6">Empty beach at dawn</div>,
|
|
74
|
+
* <div class="flex h-full items-center justify-center p-6">Fog through the pines</div>,
|
|
75
|
+
* ]}
|
|
76
|
+
* />
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function TimeMachineStack(props: TimeMachineStackProps): JSX.Element {
|
|
80
|
+
const [internalActive, setInternalActive] = createSignal(props.defaultActive ?? 0)
|
|
81
|
+
const active = () => props.active ?? internalActive()
|
|
82
|
+
|
|
83
|
+
const goTo = (index: number): void => {
|
|
84
|
+
const clamped = Math.max(0, Math.min(props.slides.length - 1, index))
|
|
85
|
+
if (props.active === undefined) setInternalActive(clamped)
|
|
86
|
+
props.onChange?.(clamped)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
90
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
goTo(active() + 1)
|
|
93
|
+
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
goTo(active() - 1)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const slideEls = new Map<number, HTMLElement>()
|
|
100
|
+
const controls = new Map<number, ReturnType<typeof animate>>()
|
|
101
|
+
|
|
102
|
+
const applyTransform = (index: number, animated: boolean): void => {
|
|
103
|
+
const el = slideEls.get(index)
|
|
104
|
+
if (!el) return
|
|
105
|
+
|
|
106
|
+
if (motionReduced()) {
|
|
107
|
+
// Flat: only the active slide is visible, no depth, no animation.
|
|
108
|
+
const isActive = index === active()
|
|
109
|
+
controls.get(index)?.stop()
|
|
110
|
+
el.style.transform = 'none'
|
|
111
|
+
el.style.opacity = isActive ? '1' : '0'
|
|
112
|
+
el.style.zIndex = isActive ? '1' : '0'
|
|
113
|
+
el.style.pointerEvents = isActive ? 'auto' : 'none'
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const t = depthTransform(index, active())
|
|
118
|
+
el.style.zIndex = String(t.zIndex)
|
|
119
|
+
el.style.pointerEvents = t.pointerEvents
|
|
120
|
+
|
|
121
|
+
if (!animated) {
|
|
122
|
+
el.style.transform = `translateY(${t.y}px) translateZ(${t.z}px) scale(${t.scale})`
|
|
123
|
+
el.style.opacity = String(t.opacity)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
controls.get(index)?.stop()
|
|
128
|
+
const anim = animate(
|
|
129
|
+
el,
|
|
130
|
+
{ y: t.y, z: t.z, scale: t.scale, opacity: t.opacity },
|
|
131
|
+
{ type: 'spring', stiffness: 300, damping: 32 },
|
|
132
|
+
)
|
|
133
|
+
controls.set(index, anim)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const syncAll = (animated: boolean): void => {
|
|
137
|
+
props.slides.forEach((_, index) => applyTransform(index, animated))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMount(() => syncAll(false))
|
|
141
|
+
createEffect(() => {
|
|
142
|
+
active() // track active-index changes (controlled or internal)
|
|
143
|
+
syncAll(true)
|
|
144
|
+
})
|
|
145
|
+
onCleanup(() => controls.forEach((c) => c.stop()))
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div class={cn('relative flex h-80 w-full gap-4', props.class)}>
|
|
149
|
+
<div
|
|
150
|
+
role="group"
|
|
151
|
+
aria-roledescription="time machine stack"
|
|
152
|
+
tabindex="0"
|
|
153
|
+
class="relative min-w-0 flex-1 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
154
|
+
style={{ perspective: '1200px' }}
|
|
155
|
+
onKeyDown={onKeyDown}
|
|
156
|
+
>
|
|
157
|
+
<div class="relative h-full w-full" style={{ 'transform-style': 'preserve-3d' }}>
|
|
158
|
+
<For each={props.slides}>
|
|
159
|
+
{(slide, index) => (
|
|
160
|
+
<div
|
|
161
|
+
ref={(el) => {
|
|
162
|
+
slideEls.set(index(), el)
|
|
163
|
+
onCleanup(() => slideEls.delete(index()))
|
|
164
|
+
}}
|
|
165
|
+
aria-hidden={index() !== active()}
|
|
166
|
+
class="absolute inset-0 origin-bottom overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-lg will-change-transform"
|
|
167
|
+
>
|
|
168
|
+
{slide}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</For>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div
|
|
176
|
+
role="group"
|
|
177
|
+
aria-label="Jump to slide"
|
|
178
|
+
class="flex shrink-0 flex-col items-end justify-center gap-1.5"
|
|
179
|
+
>
|
|
180
|
+
<For each={props.slides}>
|
|
181
|
+
{(_, index) => (
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
aria-label={`Slide ${index() + 1} of ${props.slides.length}`}
|
|
185
|
+
aria-current={index() === active() ? 'true' : undefined}
|
|
186
|
+
onClick={() => goTo(index())}
|
|
187
|
+
class={cn(
|
|
188
|
+
'h-1.5 w-6 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
189
|
+
index() === active() ? 'bg-primary' : 'bg-muted hover:bg-muted-foreground/40',
|
|
190
|
+
)}
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
</For>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|