@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,196 @@
|
|
|
1
|
+
// CardSpread — a stack of cards that fans out into one of several layouts.
|
|
2
|
+
// Rest state is a tight near-centered stack; opening (controlled via `open`,
|
|
3
|
+
// or on hover when uncontrolled) spreads the cards per `layout`. Only
|
|
4
|
+
// `transform`/`opacity` are animated (Motion spring), never layout properties,
|
|
5
|
+
// so the fan stays compositor-only. Reduced motion renders the spread layout
|
|
6
|
+
// statically (no transition, no hover animation).
|
|
7
|
+
import { createEffect, createSignal, For, on, onCleanup, onMount, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
/** Fan shape applied when a {@link CardSpread} opens. Defaults to `'arc'`. */
|
|
13
|
+
export type CardSpreadLayout = 'arc' | 'long-arc' | 'linear' | 'corner' | 'cascade' | 'scatter' | 'wheel'
|
|
14
|
+
|
|
15
|
+
export interface CardSpreadProps {
|
|
16
|
+
/** Card contents; each entry becomes one card in the stack. */
|
|
17
|
+
items: JSX.Element[]
|
|
18
|
+
/** Fan shape. @default 'arc' */
|
|
19
|
+
layout?: CardSpreadLayout
|
|
20
|
+
/** Controlled open state. Omit to spread on hover instead. */
|
|
21
|
+
open?: boolean
|
|
22
|
+
/** Strength knob: degrees for arced/wheel layouts, px for linear/corner/cascade/scatter. Per-layout default applies when omitted. */
|
|
23
|
+
spread?: number
|
|
24
|
+
class?: string
|
|
25
|
+
'aria-label'?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CardTransform {
|
|
29
|
+
x: number
|
|
30
|
+
y: number
|
|
31
|
+
rotate: number
|
|
32
|
+
origin: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_SPREAD: Record<CardSpreadLayout, number> = {
|
|
36
|
+
arc: 10,
|
|
37
|
+
'long-arc': 6,
|
|
38
|
+
linear: 56,
|
|
39
|
+
corner: 12,
|
|
40
|
+
cascade: 18,
|
|
41
|
+
scatter: 14,
|
|
42
|
+
wheel: 16,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Deterministic pseudo-random offset in [-1, 1] from an index — no Math.random.
|
|
46
|
+
function pseudoRandom(i: number): number {
|
|
47
|
+
const s = Math.sin(i * 12.9898) * 43758.5453
|
|
48
|
+
return (s - Math.floor(s)) * 2 - 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function restTransform(i: number, n: number): CardTransform {
|
|
52
|
+
// Resting deck: a gentle symmetric peek-fan (cards' edges show) so the pile
|
|
53
|
+
// reads as multiple cards even before it opens, without stealing the reveal.
|
|
54
|
+
const mid = (n - 1) / 2
|
|
55
|
+
const d = i - mid
|
|
56
|
+
return { x: d * 7, y: Math.abs(d) * 2.5, rotate: d * 2.5, origin: 'bottom center' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function openTransform(layout: CardSpreadLayout, i: number, n: number, spread: number): CardTransform {
|
|
60
|
+
const mid = (n - 1) / 2
|
|
61
|
+
const d = i - mid
|
|
62
|
+
|
|
63
|
+
switch (layout) {
|
|
64
|
+
case 'arc': {
|
|
65
|
+
const gap = 44
|
|
66
|
+
return { x: d * gap, y: Math.abs(d) ** 2 * 6, rotate: d * spread, origin: 'bottom center' }
|
|
67
|
+
}
|
|
68
|
+
case 'long-arc': {
|
|
69
|
+
const gap = 68
|
|
70
|
+
return { x: d * gap, y: Math.abs(d) ** 2 * 4, rotate: d * spread, origin: 'bottom center' }
|
|
71
|
+
}
|
|
72
|
+
case 'linear':
|
|
73
|
+
return { x: d * spread, y: 0, rotate: 0, origin: 'bottom center' }
|
|
74
|
+
case 'corner':
|
|
75
|
+
return { x: i * (spread * 0.6), y: -i * (spread * 0.15), rotate: i * spread, origin: 'bottom left' }
|
|
76
|
+
case 'cascade':
|
|
77
|
+
return {
|
|
78
|
+
x: i * spread,
|
|
79
|
+
y: i * (spread * 0.55),
|
|
80
|
+
rotate: (i % 2 === 0 ? 1 : -1) * 3,
|
|
81
|
+
origin: 'bottom center',
|
|
82
|
+
}
|
|
83
|
+
case 'scatter': {
|
|
84
|
+
const r = pseudoRandom(i)
|
|
85
|
+
const r2 = pseudoRandom(i + 100)
|
|
86
|
+
return { x: r * spread * 3, y: r2 * spread * 1.5, rotate: r * spread, origin: 'center center' }
|
|
87
|
+
}
|
|
88
|
+
case 'wheel':
|
|
89
|
+
return { x: d * 18, y: Math.abs(d) * 10, rotate: d * spread, origin: 'bottom center' }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function transformString(t: CardTransform): string {
|
|
94
|
+
return `translate(${t.x}px, ${t.y}px) rotate(${t.rotate}deg)`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A stack of cards that fans out into an arc, line, corner, cascade, scatter,
|
|
99
|
+
* or wheel shape. At rest the cards sit in a near-centered pile; opening
|
|
100
|
+
* (controlled via `open`, or on hover when uncontrolled) spreads them.
|
|
101
|
+
* Animates only `transform`/`opacity` (Motion spring) so the fan stays
|
|
102
|
+
* compositor-only, and falls back to a static fanned layout under reduced
|
|
103
|
+
* motion.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```tsx
|
|
107
|
+
* <CardSpread
|
|
108
|
+
* layout="arc"
|
|
109
|
+
* aria-label="Trip itinerary cards"
|
|
110
|
+
* items={[
|
|
111
|
+
* <div class="p-4">Day 1 — Arrival</div>,
|
|
112
|
+
* <div class="p-4">Day 2 — Old Town</div>,
|
|
113
|
+
* <div class="p-4">Day 3 — Coast drive</div>,
|
|
114
|
+
* ]}
|
|
115
|
+
* />
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function CardSpread(props: CardSpreadProps): JSX.Element {
|
|
119
|
+
const cardRefs: (HTMLElement | undefined)[] = []
|
|
120
|
+
const controls: (ReturnType<typeof animate> | undefined)[] = []
|
|
121
|
+
const [hovered, setHovered] = createSignal(false)
|
|
122
|
+
|
|
123
|
+
const layout = () => props.layout ?? 'arc'
|
|
124
|
+
const spread = () => props.spread ?? DEFAULT_SPREAD[layout()]
|
|
125
|
+
const isOpen = () => (props.open !== undefined ? props.open : hovered())
|
|
126
|
+
|
|
127
|
+
// Reduced motion always renders the fanned layout, statically, regardless
|
|
128
|
+
// of open/hover state — so `open`/`hover` never trigger visible motion.
|
|
129
|
+
const applyTransforms = (animated: boolean) => {
|
|
130
|
+
const n = props.items.length
|
|
131
|
+
const reduced = motionReduced()
|
|
132
|
+
const open = reduced ? true : isOpen()
|
|
133
|
+
|
|
134
|
+
cardRefs.forEach((card, i) => {
|
|
135
|
+
if (!card) return
|
|
136
|
+
const t = open ? openTransform(layout(), i, n, spread()) : restTransform(i, n)
|
|
137
|
+
card.style.transformOrigin = t.origin
|
|
138
|
+
card.style.zIndex = String(open ? i : n - i)
|
|
139
|
+
|
|
140
|
+
controls[i]?.stop()
|
|
141
|
+
if (reduced || !animated) {
|
|
142
|
+
card.style.transform = transformString(t)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
controls[i] = animate(
|
|
146
|
+
card,
|
|
147
|
+
{ x: t.x, y: t.y, rotate: t.rotate },
|
|
148
|
+
{ type: 'spring', stiffness: 260, damping: 24 },
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Initial paint only: snap to the correct state (no animation) so a controlled
|
|
154
|
+
// `open` or a reduced-motion preference doesn't flash the closed stack first.
|
|
155
|
+
// Deliberately untracked (onMount, not createEffect) — it must run exactly
|
|
156
|
+
// once, otherwise it would re-fire on every hover/open change and snap the
|
|
157
|
+
// cards straight to their target, leaving the animated effect below nothing
|
|
158
|
+
// to spring from.
|
|
159
|
+
onMount(() => applyTransforms(false))
|
|
160
|
+
// Subsequent changes to open/hover/layout/spread animate in.
|
|
161
|
+
createEffect(
|
|
162
|
+
on([() => props.open, hovered, layout, spread, () => props.items.length], () => applyTransforms(true), {
|
|
163
|
+
defer: true,
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
onCleanup(() => controls.forEach((c) => c?.stop()))
|
|
167
|
+
|
|
168
|
+
const handleEnter = () => {
|
|
169
|
+
if (props.open === undefined && !motionReduced()) setHovered(true)
|
|
170
|
+
}
|
|
171
|
+
const handleLeave = () => {
|
|
172
|
+
if (props.open === undefined && !motionReduced()) setHovered(false)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div
|
|
177
|
+
role="group"
|
|
178
|
+
aria-label={props['aria-label']}
|
|
179
|
+
class={cn('relative inline-block h-56 w-40', props.class)}
|
|
180
|
+
onPointerEnter={handleEnter}
|
|
181
|
+
onPointerLeave={handleLeave}
|
|
182
|
+
>
|
|
183
|
+
<For each={props.items}>
|
|
184
|
+
{(item, i) => (
|
|
185
|
+
<div
|
|
186
|
+
ref={(el) => (cardRefs[i()] = el)}
|
|
187
|
+
class="absolute inset-x-0 top-0 h-56 w-40 rounded-xl border border-border bg-card text-card-foreground shadow-sm will-change-transform"
|
|
188
|
+
style={{ transform: transformString(restTransform(i(), props.items.length)) }}
|
|
189
|
+
>
|
|
190
|
+
{item}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</For>
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -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
|
+
}
|