@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,218 @@
|
|
|
1
|
+
// MorphPresets — thin, opinionated wrappers over IconMorphButton for common
|
|
2
|
+
// two-state icon toggles (copy, play/pause, mute, lock, theme). Each preset
|
|
3
|
+
// only supplies icons + sensible defaults; the morph animation itself lives
|
|
4
|
+
// in IconMorphButton and is not reimplemented here.
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { Check, Copy, Lock, Moon, Pause, Play, Sun, Unlock, Volume2, VolumeX } from 'lucide-solid'
|
|
7
|
+
|
|
8
|
+
import { IconMorphButton, type IconMorphButtonVariant } from './IconMorphButton'
|
|
9
|
+
|
|
10
|
+
export interface CopyButtonProps {
|
|
11
|
+
/** Text copied to the clipboard when the button is pressed. */
|
|
12
|
+
value: string
|
|
13
|
+
/** Optional label shown next to the icon before copying. */
|
|
14
|
+
label?: JSX.Element
|
|
15
|
+
/** Optional label shown next to the icon once copied. */
|
|
16
|
+
copiedLabel?: JSX.Element
|
|
17
|
+
/** Visual style. Defaults to `'ghost'`. */
|
|
18
|
+
variant?: IconMorphButtonVariant
|
|
19
|
+
class?: string
|
|
20
|
+
'aria-label'?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Copy-to-clipboard button that morphs Copy → Check and auto-reverts after
|
|
25
|
+
* 1.5s.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <CopyButton value="npm install @a4ui/core" aria-label="Copy install command" />
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function CopyButton(props: CopyButtonProps): JSX.Element {
|
|
33
|
+
return (
|
|
34
|
+
<IconMorphButton
|
|
35
|
+
inactive={<Copy size={18} />}
|
|
36
|
+
active={<Check size={18} />}
|
|
37
|
+
label={props.label}
|
|
38
|
+
activeLabel={props.copiedLabel}
|
|
39
|
+
variant={props.variant}
|
|
40
|
+
class={props.class}
|
|
41
|
+
aria-label={props['aria-label']}
|
|
42
|
+
revertAfter={1500}
|
|
43
|
+
onChange={(pressed) => {
|
|
44
|
+
if (pressed) void navigator.clipboard?.writeText(props.value)
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PlayPauseButtonProps {
|
|
51
|
+
/** Controlled playing state. Omit to let the button manage its own state. */
|
|
52
|
+
pressed?: boolean
|
|
53
|
+
/** Initial playing state when uncontrolled. Defaults to `false`. */
|
|
54
|
+
defaultPressed?: boolean
|
|
55
|
+
/** Fired with the next playing state, controlled or uncontrolled. */
|
|
56
|
+
onChange?: (pressed: boolean) => void
|
|
57
|
+
/** Optional label shown next to the icon while paused. */
|
|
58
|
+
label?: JSX.Element
|
|
59
|
+
/** Optional label shown next to the icon while playing. */
|
|
60
|
+
activeLabel?: JSX.Element
|
|
61
|
+
/** Visual style. Defaults to `'ghost'`. */
|
|
62
|
+
variant?: IconMorphButtonVariant
|
|
63
|
+
class?: string
|
|
64
|
+
'aria-label'?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Play/pause toggle button whose icon morphs Play → Pause.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```tsx
|
|
72
|
+
* <PlayPauseButton defaultPressed={false} aria-label="Play" />
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function PlayPauseButton(props: PlayPauseButtonProps): JSX.Element {
|
|
76
|
+
return (
|
|
77
|
+
<IconMorphButton
|
|
78
|
+
inactive={<Play size={18} />}
|
|
79
|
+
active={<Pause size={18} />}
|
|
80
|
+
pressed={props.pressed}
|
|
81
|
+
defaultPressed={props.defaultPressed}
|
|
82
|
+
onChange={props.onChange}
|
|
83
|
+
label={props.label}
|
|
84
|
+
activeLabel={props.activeLabel}
|
|
85
|
+
variant={props.variant}
|
|
86
|
+
class={props.class}
|
|
87
|
+
aria-label={props['aria-label']}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MuteButtonProps {
|
|
93
|
+
/** Controlled muted state. Omit to let the button manage its own state. */
|
|
94
|
+
pressed?: boolean
|
|
95
|
+
/** Initial muted state when uncontrolled. Defaults to `false`. */
|
|
96
|
+
defaultPressed?: boolean
|
|
97
|
+
/** Fired with the next muted state, controlled or uncontrolled. */
|
|
98
|
+
onChange?: (pressed: boolean) => void
|
|
99
|
+
/** Optional label shown next to the icon while unmuted. */
|
|
100
|
+
label?: JSX.Element
|
|
101
|
+
/** Optional label shown next to the icon while muted. */
|
|
102
|
+
activeLabel?: JSX.Element
|
|
103
|
+
/** Visual style. Defaults to `'ghost'`. */
|
|
104
|
+
variant?: IconMorphButtonVariant
|
|
105
|
+
class?: string
|
|
106
|
+
'aria-label'?: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mute toggle button whose icon morphs Volume2 → VolumeX.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```tsx
|
|
114
|
+
* <MuteButton defaultPressed={false} aria-label="Mute" />
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export function MuteButton(props: MuteButtonProps): JSX.Element {
|
|
118
|
+
return (
|
|
119
|
+
<IconMorphButton
|
|
120
|
+
inactive={<Volume2 size={18} />}
|
|
121
|
+
active={<VolumeX size={18} />}
|
|
122
|
+
pressed={props.pressed}
|
|
123
|
+
defaultPressed={props.defaultPressed}
|
|
124
|
+
onChange={props.onChange}
|
|
125
|
+
label={props.label}
|
|
126
|
+
activeLabel={props.activeLabel}
|
|
127
|
+
variant={props.variant}
|
|
128
|
+
class={props.class}
|
|
129
|
+
aria-label={props['aria-label']}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface LockButtonProps {
|
|
135
|
+
/** Controlled locked state. Omit to let the button manage its own state. */
|
|
136
|
+
pressed?: boolean
|
|
137
|
+
/** Initial locked state when uncontrolled. Defaults to `false`. */
|
|
138
|
+
defaultPressed?: boolean
|
|
139
|
+
/** Fired with the next locked state, controlled or uncontrolled. */
|
|
140
|
+
onChange?: (pressed: boolean) => void
|
|
141
|
+
/** Optional label shown next to the icon while unlocked. */
|
|
142
|
+
label?: JSX.Element
|
|
143
|
+
/** Optional label shown next to the icon while locked. */
|
|
144
|
+
activeLabel?: JSX.Element
|
|
145
|
+
/** Visual style. Defaults to `'ghost'`. */
|
|
146
|
+
variant?: IconMorphButtonVariant
|
|
147
|
+
class?: string
|
|
148
|
+
'aria-label'?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Lock toggle button whose icon morphs Unlock → Lock.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```tsx
|
|
156
|
+
* <LockButton defaultPressed={false} aria-label="Lock" />
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export function LockButton(props: LockButtonProps): JSX.Element {
|
|
160
|
+
return (
|
|
161
|
+
<IconMorphButton
|
|
162
|
+
inactive={<Unlock size={18} />}
|
|
163
|
+
active={<Lock size={18} />}
|
|
164
|
+
pressed={props.pressed}
|
|
165
|
+
defaultPressed={props.defaultPressed}
|
|
166
|
+
onChange={props.onChange}
|
|
167
|
+
label={props.label}
|
|
168
|
+
activeLabel={props.activeLabel}
|
|
169
|
+
variant={props.variant}
|
|
170
|
+
class={props.class}
|
|
171
|
+
aria-label={props['aria-label']}
|
|
172
|
+
/>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ThemeMorphButtonProps {
|
|
177
|
+
/** Controlled dark-mode state. Omit to let the button manage its own state. */
|
|
178
|
+
pressed?: boolean
|
|
179
|
+
/** Initial dark-mode state when uncontrolled. Defaults to `false`. */
|
|
180
|
+
defaultPressed?: boolean
|
|
181
|
+
/** Fired with the next dark-mode state, controlled or uncontrolled. */
|
|
182
|
+
onChange?: (pressed: boolean) => void
|
|
183
|
+
/** Optional label shown next to the icon in light mode. */
|
|
184
|
+
label?: JSX.Element
|
|
185
|
+
/** Optional label shown next to the icon in dark mode. */
|
|
186
|
+
activeLabel?: JSX.Element
|
|
187
|
+
/** Visual style. Defaults to `'ghost'`. */
|
|
188
|
+
variant?: IconMorphButtonVariant
|
|
189
|
+
class?: string
|
|
190
|
+
'aria-label'?: string
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Purely presentational theme toggle whose icon morphs Sun → Moon. Does not
|
|
195
|
+
* touch the theme system itself — wire `pressed`/`onChange` to your theme
|
|
196
|
+
* state.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```tsx
|
|
200
|
+
* <ThemeMorphButton pressed={isDark()} onChange={setIsDark} aria-label="Toggle theme" />
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
export function ThemeMorphButton(props: ThemeMorphButtonProps): JSX.Element {
|
|
204
|
+
return (
|
|
205
|
+
<IconMorphButton
|
|
206
|
+
inactive={<Sun size={18} />}
|
|
207
|
+
active={<Moon size={18} />}
|
|
208
|
+
pressed={props.pressed}
|
|
209
|
+
defaultPressed={props.defaultPressed}
|
|
210
|
+
onChange={props.onChange}
|
|
211
|
+
label={props.label}
|
|
212
|
+
activeLabel={props.activeLabel}
|
|
213
|
+
variant={props.variant}
|
|
214
|
+
class={props.class}
|
|
215
|
+
aria-label={props['aria-label']}
|
|
216
|
+
/>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|