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