@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,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
+ }