@a4ui/core 0.12.0 → 0.13.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/README.md +44 -11
- package/dist/elements.css +79 -9
- package/dist/full.css +79 -9
- package/dist/index.d.ts +13 -1
- package/dist/index.js +3392 -2537
- package/dist/lib/flyToCart.d.ts +25 -0
- package/dist/ui/Curtain.d.ts +44 -0
- package/dist/ui/Expandable.d.ts +34 -0
- package/dist/ui/FillText.d.ts +18 -0
- package/dist/ui/HoldToConfirm.d.ts +24 -0
- package/dist/ui/LoadingDots.d.ts +20 -0
- package/dist/ui/MultiStateBadge.d.ts +23 -0
- package/dist/ui/NotificationStack.d.ts +32 -0
- package/dist/ui/NowPlaying.d.ts +21 -0
- package/dist/ui/Parallax.d.ts +21 -0
- package/dist/ui/ScrambleText.d.ts +22 -0
- package/dist/ui/TextReveal.d.ts +23 -0
- package/package.json +1 -1
- package/src/index.ts +21 -1
- package/src/lib/flyToCart.ts +103 -0
- package/src/ui/Curtain.tsx +348 -0
- package/src/ui/Expandable.tsx +229 -0
- package/src/ui/FillText.tsx +63 -0
- package/src/ui/HoldToConfirm.tsx +141 -0
- package/src/ui/LoadingDots.tsx +75 -0
- package/src/ui/MultiStateBadge.tsx +93 -0
- package/src/ui/NotificationStack.tsx +104 -0
- package/src/ui/NowPlaying.tsx +100 -0
- package/src/ui/Parallax.tsx +51 -0
- package/src/ui/ScrambleText.tsx +95 -0
- package/src/ui/SpeedDial.tsx +12 -2
- package/src/ui/TextReveal.tsx +93 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Curtain — a controlled, full-screen cover→uncover transition primitive for
|
|
2
|
+
// page/route transitions. It collapses motion.dev's "curtains" example family
|
|
3
|
+
// (fade, sliding doors, blinds, shutter, iris, clip-wipe, pixels) into one component driven by a
|
|
4
|
+
// single `show` boolean: `true` plays the "cover" animation (the curtain
|
|
5
|
+
// closes over the screen), `false` plays "uncover" (the curtain opens,
|
|
6
|
+
// revealing whatever's behind it). Swap your route content while covered —
|
|
7
|
+
// `onCovered` fires the instant the screen is fully masked, `onRevealed`
|
|
8
|
+
// once it's fully open again.
|
|
9
|
+
import { createEffect, createSignal, For, Index, on, onCleanup, onMount, type JSX } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
import { animate, motionReduced, stagger } from '../lib/motion'
|
|
13
|
+
|
|
14
|
+
export type CurtainVariant = 'fade' | 'doors' | 'blinds' | 'iris' | 'shutter' | 'clip' | 'pixels'
|
|
15
|
+
|
|
16
|
+
export interface CurtainProps {
|
|
17
|
+
/** true = cover the screen; false = uncover (reveal content). */
|
|
18
|
+
show: boolean
|
|
19
|
+
/** Which wipe style. @default 'fade' */
|
|
20
|
+
variant?: CurtainVariant
|
|
21
|
+
/** Overlay color (CSS color / var). @default 'hsl(var(--background))' */
|
|
22
|
+
color?: string
|
|
23
|
+
/** Seconds for one direction. @default 0.6 */
|
|
24
|
+
duration?: number
|
|
25
|
+
/** Called once the screen is fully covered (show went true→covered). */
|
|
26
|
+
onCovered?: () => void
|
|
27
|
+
/** Called once fully uncovered (show went false→revealed). */
|
|
28
|
+
onRevealed?: () => void
|
|
29
|
+
class?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CurtainControls {
|
|
33
|
+
finished: Promise<unknown>
|
|
34
|
+
stop: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type CurtainPhase = 'covered' | 'uncovered' | 'covering' | 'uncovering'
|
|
38
|
+
|
|
39
|
+
const STRIP_COUNT = 8
|
|
40
|
+
const STRIP_INDICES = Array.from({ length: STRIP_COUNT }, (_, i) => i)
|
|
41
|
+
|
|
42
|
+
// `pixels` variant: a grid of tiles that pop in/out in a diagonal cascade.
|
|
43
|
+
const PIXEL_COLS = 10
|
|
44
|
+
const PIXEL_ROWS = 6
|
|
45
|
+
const PIXEL_COUNT = PIXEL_COLS * PIXEL_ROWS
|
|
46
|
+
const PIXEL_INDICES = Array.from({ length: PIXEL_COUNT }, (_, i) => i)
|
|
47
|
+
|
|
48
|
+
function combine(controls: CurtainControls[]): CurtainControls {
|
|
49
|
+
return {
|
|
50
|
+
finished: Promise.all(controls.map((c) => c.finished)),
|
|
51
|
+
stop: () => {
|
|
52
|
+
for (const c of controls) c.stop()
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A controlled full-screen curtain for page/route transitions. Set `show` to
|
|
59
|
+
* `true` to cover the viewport, `false` to uncover it, and swap your route
|
|
60
|
+
* content while covered. `variant` picks the wipe style — fade, sliding
|
|
61
|
+
* doors, blinds, shutter (vertical blinds), iris/circle wipe, a left-to-right
|
|
62
|
+
* clip wipe, or a pixel-grid cascade —
|
|
63
|
+
* `onCovered` / `onRevealed` fire once each transition finishes so you can
|
|
64
|
+
* swap content at exactly the right moment. Skips the animation (and still
|
|
65
|
+
* fires the callback on the next microtask) under reduced motion.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```tsx
|
|
69
|
+
* const [covered, setCovered] = createSignal(false)
|
|
70
|
+
* const goTo = (path: string) => setCovered(true)
|
|
71
|
+
* return (
|
|
72
|
+
* <>
|
|
73
|
+
* <Curtain
|
|
74
|
+
* show={covered()}
|
|
75
|
+
* variant="iris"
|
|
76
|
+
* onCovered={() => { loadRoute(); setCovered(false) }}
|
|
77
|
+
* />
|
|
78
|
+
* <Router />
|
|
79
|
+
* </>
|
|
80
|
+
* )
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export function Curtain(props: CurtainProps): JSX.Element {
|
|
84
|
+
// eslint-disable-next-line solid/reactivity -- initial seed only; reactivity handled in the createEffect below
|
|
85
|
+
const [phase, setPhase] = createSignal<CurtainPhase>(props.show ? 'covered' : 'uncovered')
|
|
86
|
+
|
|
87
|
+
let controls: CurtainControls | undefined
|
|
88
|
+
let panelA: HTMLDivElement | undefined
|
|
89
|
+
let panelB: HTMLDivElement | undefined
|
|
90
|
+
const strips: (HTMLDivElement | undefined)[] = []
|
|
91
|
+
|
|
92
|
+
const color = () => props.color ?? 'hsl(var(--background))'
|
|
93
|
+
|
|
94
|
+
function applyFinalState(variant: CurtainVariant, show: boolean): void {
|
|
95
|
+
switch (variant) {
|
|
96
|
+
case 'fade':
|
|
97
|
+
if (panelA) panelA.style.opacity = show ? '1' : '0'
|
|
98
|
+
break
|
|
99
|
+
case 'doors':
|
|
100
|
+
if (panelA) panelA.style.transform = show ? 'translateX(0%)' : 'translateX(-100%)'
|
|
101
|
+
if (panelB) panelB.style.transform = show ? 'translateX(0%)' : 'translateX(100%)'
|
|
102
|
+
break
|
|
103
|
+
case 'blinds':
|
|
104
|
+
for (const el of strips.slice(0, STRIP_COUNT))
|
|
105
|
+
if (el) el.style.transform = show ? 'scaleY(1)' : 'scaleY(0)'
|
|
106
|
+
break
|
|
107
|
+
case 'shutter':
|
|
108
|
+
for (const el of strips.slice(0, STRIP_COUNT))
|
|
109
|
+
if (el) el.style.transform = show ? 'scaleX(1)' : 'scaleX(0)'
|
|
110
|
+
break
|
|
111
|
+
case 'iris':
|
|
112
|
+
if (panelA) panelA.style.clipPath = show ? 'circle(150% at 50% 50%)' : 'circle(0% at 50% 50%)'
|
|
113
|
+
break
|
|
114
|
+
case 'clip':
|
|
115
|
+
if (panelA) panelA.style.clipPath = show ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)'
|
|
116
|
+
break
|
|
117
|
+
case 'pixels':
|
|
118
|
+
for (const el of strips.slice(0, PIXEL_COUNT)) if (el) el.style.opacity = show ? '1' : '0'
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function play(variant: CurtainVariant, show: boolean, duration: number): CurtainControls | undefined {
|
|
124
|
+
const ease = 'easeInOut' as const
|
|
125
|
+
|
|
126
|
+
switch (variant) {
|
|
127
|
+
case 'fade': {
|
|
128
|
+
if (!panelA) return undefined
|
|
129
|
+
return animate(panelA, { opacity: show ? [0, 1] : [1, 0] }, { duration, ease })
|
|
130
|
+
}
|
|
131
|
+
case 'doors': {
|
|
132
|
+
if (!panelA || !panelB) return undefined
|
|
133
|
+
const left = animate(panelA, { x: show ? ['-100%', '0%'] : ['0%', '-100%'] }, { duration, ease })
|
|
134
|
+
const right = animate(panelB, { x: show ? ['100%', '0%'] : ['0%', '100%'] }, { duration, ease })
|
|
135
|
+
return combine([left, right])
|
|
136
|
+
}
|
|
137
|
+
case 'blinds': {
|
|
138
|
+
const els = strips.slice(0, STRIP_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
|
|
139
|
+
if (els.length === 0) return undefined
|
|
140
|
+
return animate(els, { scaleY: show ? [0, 1] : [1, 0] }, { duration, delay: stagger(0.05), ease })
|
|
141
|
+
}
|
|
142
|
+
case 'shutter': {
|
|
143
|
+
const els = strips.slice(0, STRIP_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
|
|
144
|
+
if (els.length === 0) return undefined
|
|
145
|
+
return animate(els, { scaleX: show ? [0, 1] : [1, 0] }, { duration, delay: stagger(0.05), ease })
|
|
146
|
+
}
|
|
147
|
+
case 'iris': {
|
|
148
|
+
if (!panelA) return undefined
|
|
149
|
+
return animate(
|
|
150
|
+
panelA,
|
|
151
|
+
{
|
|
152
|
+
clipPath: show
|
|
153
|
+
? ['circle(0% at 50% 50%)', 'circle(150% at 50% 50%)']
|
|
154
|
+
: ['circle(150% at 50% 50%)', 'circle(0% at 50% 50%)'],
|
|
155
|
+
},
|
|
156
|
+
{ duration, ease },
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
case 'clip': {
|
|
160
|
+
if (!panelA) return undefined
|
|
161
|
+
return animate(
|
|
162
|
+
panelA,
|
|
163
|
+
{
|
|
164
|
+
clipPath: show
|
|
165
|
+
? ['inset(0 100% 0 0)', 'inset(0 0 0 0)']
|
|
166
|
+
: ['inset(0 0 0 0)', 'inset(0 100% 0 0)'],
|
|
167
|
+
},
|
|
168
|
+
{ duration, ease },
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
case 'pixels': {
|
|
172
|
+
const els = strips.slice(0, PIXEL_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
|
|
173
|
+
if (els.length === 0) return undefined
|
|
174
|
+
return animate(
|
|
175
|
+
els,
|
|
176
|
+
{ opacity: show ? [0, 1] : [1, 0] },
|
|
177
|
+
{ duration: duration * 0.5, delay: stagger(0.01, { from: 'first' }), ease },
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Initial paint: the panels' inline styles already sit in the "uncovered"
|
|
186
|
+
// pose, so we only need to snap them to "covered" when show starts true. No
|
|
187
|
+
// animation on mount — that would flash the curtain in from its covered pose.
|
|
188
|
+
onMount(() => {
|
|
189
|
+
if (props.show) applyFinalState(props.variant ?? 'fade', true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Animate ONLY when `show` actually toggles (defer skips the mount run; variant
|
|
193
|
+
// and duration are read untracked so switching variant while idle never plays).
|
|
194
|
+
createEffect(
|
|
195
|
+
on(
|
|
196
|
+
() => props.show,
|
|
197
|
+
(show) => {
|
|
198
|
+
const variant = props.variant ?? 'fade'
|
|
199
|
+
const duration = props.duration ?? 0.6
|
|
200
|
+
|
|
201
|
+
controls?.stop()
|
|
202
|
+
controls = undefined
|
|
203
|
+
setPhase(show ? 'covering' : 'uncovering')
|
|
204
|
+
|
|
205
|
+
const settle = () => {
|
|
206
|
+
setPhase(show ? 'covered' : 'uncovered')
|
|
207
|
+
if (show) props.onCovered?.()
|
|
208
|
+
else props.onRevealed?.()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (motionReduced()) {
|
|
212
|
+
applyFinalState(variant, show)
|
|
213
|
+
queueMicrotask(settle)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const played = play(variant, show, duration)
|
|
218
|
+
if (!played) {
|
|
219
|
+
queueMicrotask(settle)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
controls = played
|
|
224
|
+
played.finished.then(settle).catch(() => {})
|
|
225
|
+
},
|
|
226
|
+
{ defer: true },
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
onCleanup(() => controls?.stop())
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
class={cn('pointer-events-none fixed inset-0 z-[9999]', props.class)}
|
|
235
|
+
aria-hidden="true"
|
|
236
|
+
data-curtain-variant={props.variant ?? 'fade'}
|
|
237
|
+
data-curtain-state={phase()}
|
|
238
|
+
>
|
|
239
|
+
{(props.variant ?? 'fade') === 'fade' && (
|
|
240
|
+
<div
|
|
241
|
+
class="absolute inset-0"
|
|
242
|
+
style={{ position: 'absolute', opacity: 0, background: color() }}
|
|
243
|
+
ref={(el) => {
|
|
244
|
+
panelA = el
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{(props.variant ?? 'fade') === 'doors' && (
|
|
250
|
+
<>
|
|
251
|
+
<div
|
|
252
|
+
class="absolute inset-y-0 left-0 w-1/2"
|
|
253
|
+
style={{ position: 'absolute', transform: 'translateX(-100%)', background: color() }}
|
|
254
|
+
ref={(el) => {
|
|
255
|
+
panelA = el
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
<div
|
|
259
|
+
class="absolute inset-y-0 right-0 w-1/2"
|
|
260
|
+
style={{ position: 'absolute', transform: 'translateX(100%)', background: color() }}
|
|
261
|
+
ref={(el) => {
|
|
262
|
+
panelB = el
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{(props.variant ?? 'fade') === 'blinds' && (
|
|
269
|
+
<Index each={STRIP_INDICES}>
|
|
270
|
+
{(_, index) => (
|
|
271
|
+
<div
|
|
272
|
+
class="absolute left-0 h-[12.5%] w-full origin-top"
|
|
273
|
+
style={{
|
|
274
|
+
position: 'absolute',
|
|
275
|
+
top: `${index * 12.5}%`,
|
|
276
|
+
transform: 'scaleY(0)',
|
|
277
|
+
background: color(),
|
|
278
|
+
}}
|
|
279
|
+
ref={(el) => {
|
|
280
|
+
strips[index] = el
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
</Index>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{(props.variant ?? 'fade') === 'shutter' && (
|
|
288
|
+
<For each={STRIP_INDICES}>
|
|
289
|
+
{(_, index) => (
|
|
290
|
+
<div
|
|
291
|
+
class="absolute top-0 h-full w-[12.5%] origin-left"
|
|
292
|
+
style={{
|
|
293
|
+
position: 'absolute',
|
|
294
|
+
left: `${index() * 12.5}%`,
|
|
295
|
+
transform: 'scaleX(0)',
|
|
296
|
+
background: color(),
|
|
297
|
+
}}
|
|
298
|
+
ref={(el) => {
|
|
299
|
+
strips[index()] = el
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
</For>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{(props.variant ?? 'fade') === 'iris' && (
|
|
307
|
+
<div
|
|
308
|
+
class="absolute inset-0"
|
|
309
|
+
style={{ position: 'absolute', 'clip-path': 'circle(0% at 50% 50%)', background: color() }}
|
|
310
|
+
ref={(el) => {
|
|
311
|
+
panelA = el
|
|
312
|
+
}}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{(props.variant ?? 'fade') === 'clip' && (
|
|
317
|
+
<div
|
|
318
|
+
class="absolute inset-0"
|
|
319
|
+
style={{ position: 'absolute', 'clip-path': 'inset(0 100% 0 0)', background: color() }}
|
|
320
|
+
ref={(el) => {
|
|
321
|
+
panelA = el
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{(props.variant ?? 'fade') === 'pixels' && (
|
|
327
|
+
<div
|
|
328
|
+
class="absolute inset-0 grid"
|
|
329
|
+
style={{
|
|
330
|
+
'grid-template-columns': `repeat(${PIXEL_COLS}, 1fr)`,
|
|
331
|
+
'grid-template-rows': `repeat(${PIXEL_ROWS}, 1fr)`,
|
|
332
|
+
}}
|
|
333
|
+
>
|
|
334
|
+
<Index each={PIXEL_INDICES}>
|
|
335
|
+
{(_, index) => (
|
|
336
|
+
<div
|
|
337
|
+
style={{ opacity: 0, background: color() }}
|
|
338
|
+
ref={(el) => {
|
|
339
|
+
strips[index] = el
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
</Index>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Shared-element transition: a compact `trigger` (a card) morphs into an
|
|
2
|
+
// expanded overlay panel and back, using a FLIP animation. We animate the
|
|
3
|
+
// panel's position and SIZE (top/left/width/height) rather than a transform
|
|
4
|
+
// scale, so its content never stretches. Covers the motion.dev "family dialog"
|
|
5
|
+
// (size="dialog") and "app store layout" (size="full") patterns with one API.
|
|
6
|
+
import { createSignal, Show, onCleanup, onMount, type JSX } from 'solid-js'
|
|
7
|
+
import { Portal } from 'solid-js/web'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
type Rect = { top: number; left: number; width: number; height: number }
|
|
13
|
+
|
|
14
|
+
export interface ExpandableProps {
|
|
15
|
+
/** Collapsed content — the card users click to expand. */
|
|
16
|
+
trigger: JSX.Element
|
|
17
|
+
/** Expanded panel content. */
|
|
18
|
+
children: JSX.Element
|
|
19
|
+
/** `dialog` = centered panel capped at `maxWidth`; `full` = near-fullscreen. @default 'dialog' */
|
|
20
|
+
size?: 'dialog' | 'full'
|
|
21
|
+
/** Max width (px) of the expanded panel when `size='dialog'`. @default 640 */
|
|
22
|
+
maxWidth?: number
|
|
23
|
+
/** Notified when the panel opens/closes. */
|
|
24
|
+
onOpenChange?: (open: boolean) => void
|
|
25
|
+
/** Class for the inline trigger wrapper. */
|
|
26
|
+
class?: string
|
|
27
|
+
/** Class for the expanded panel surface. */
|
|
28
|
+
panelClass?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PAD = 24
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Expands a card into an overlay panel with a shared-element (FLIP) transition —
|
|
35
|
+
* the card appears to grow into the dialog and shrink back on close. Animates
|
|
36
|
+
* position + size (never scale) so content stays crisp. Falls back to a plain
|
|
37
|
+
* show/hide under reduced motion. Close with the ✕, a backdrop click, or Escape.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <Expandable
|
|
42
|
+
* trigger={<Card class="cursor-pointer">Tap to expand</Card>}
|
|
43
|
+
* size="dialog"
|
|
44
|
+
* >
|
|
45
|
+
* <div class="p-6">Full details here…</div>
|
|
46
|
+
* </Expandable>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function Expandable(props: ExpandableProps): JSX.Element {
|
|
50
|
+
const [open, setOpen] = createSignal(false)
|
|
51
|
+
let triggerEl: HTMLDivElement | undefined
|
|
52
|
+
let panelEl: HTMLDivElement | undefined
|
|
53
|
+
let backdropEl: HTMLDivElement | undefined
|
|
54
|
+
let originRect: Rect | undefined
|
|
55
|
+
let controls: ReturnType<typeof animate> | undefined
|
|
56
|
+
|
|
57
|
+
const rectOf = (el: Element): Rect => {
|
|
58
|
+
const r = el.getBoundingClientRect()
|
|
59
|
+
return { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The panel's resting position/size, computed (not scaled) so text stays sharp.
|
|
63
|
+
const finalRect = (el: HTMLDivElement): Rect => {
|
|
64
|
+
const vw = window.innerWidth
|
|
65
|
+
const vh = window.innerHeight
|
|
66
|
+
if (props.size === 'full') {
|
|
67
|
+
const width = vw - PAD * 2
|
|
68
|
+
const height = vh - PAD * 2
|
|
69
|
+
return { left: PAD, top: PAD, width, height }
|
|
70
|
+
}
|
|
71
|
+
const width = Math.min(props.maxWidth ?? 640, vw - PAD * 2)
|
|
72
|
+
// Measure the natural height at the target width.
|
|
73
|
+
el.style.width = `${width}px`
|
|
74
|
+
el.style.height = 'auto'
|
|
75
|
+
el.style.maxHeight = `${vh - PAD * 2}px`
|
|
76
|
+
const natH = el.getBoundingClientRect().height
|
|
77
|
+
const height = Math.min(natH, vh - PAD * 2)
|
|
78
|
+
return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const place = (el: HTMLDivElement, r: Rect) => {
|
|
82
|
+
el.style.top = `${r.top}px`
|
|
83
|
+
el.style.left = `${r.left}px`
|
|
84
|
+
el.style.width = `${r.width}px`
|
|
85
|
+
el.style.height = `${r.height}px`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const doOpen = () => {
|
|
89
|
+
if (open() || !triggerEl) return
|
|
90
|
+
originRect = rectOf(triggerEl)
|
|
91
|
+
setOpen(true)
|
|
92
|
+
props.onOpenChange?.(true)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Runs once the portalled panel is in the DOM: FLIP from the card to the panel.
|
|
96
|
+
const runOpenFlip = () => {
|
|
97
|
+
const el = panelEl
|
|
98
|
+
if (!el) return
|
|
99
|
+
const final = finalRect(el)
|
|
100
|
+
if (motionReduced() || !originRect) {
|
|
101
|
+
place(el, final)
|
|
102
|
+
el.style.overflow = 'auto'
|
|
103
|
+
el.style.opacity = '1'
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
place(el, originRect) // first
|
|
107
|
+
el.style.opacity = '1'
|
|
108
|
+
void el.offsetWidth // force reflow
|
|
109
|
+
controls?.stop()
|
|
110
|
+
controls = animate(
|
|
111
|
+
el,
|
|
112
|
+
{
|
|
113
|
+
top: [originRect.top, final.top],
|
|
114
|
+
left: [originRect.left, final.left],
|
|
115
|
+
width: [originRect.width, final.width],
|
|
116
|
+
height: [originRect.height, final.height],
|
|
117
|
+
},
|
|
118
|
+
{ type: 'spring', stiffness: 280, damping: 32 },
|
|
119
|
+
)
|
|
120
|
+
if (backdropEl) animate(backdropEl, { opacity: [0, 1] }, { duration: 0.25, ease: 'easeOut' })
|
|
121
|
+
controls.finished
|
|
122
|
+
.then(() => {
|
|
123
|
+
el.style.overflow = 'auto'
|
|
124
|
+
})
|
|
125
|
+
.catch(() => {})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const doClose = () => {
|
|
129
|
+
if (!open()) return
|
|
130
|
+
const el = panelEl
|
|
131
|
+
const back = triggerEl ? rectOf(triggerEl) : originRect
|
|
132
|
+
if (!el || !back || motionReduced()) {
|
|
133
|
+
finishClose()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
controls?.stop()
|
|
137
|
+
el.style.overflow = 'hidden'
|
|
138
|
+
place(el, rectOf(el)) // pin current
|
|
139
|
+
void el.offsetWidth
|
|
140
|
+
controls = animate(
|
|
141
|
+
el,
|
|
142
|
+
{ top: back.top, left: back.left, width: back.width, height: back.height, opacity: [1, 0.35] },
|
|
143
|
+
{ duration: 0.3, ease: 'easeInOut' },
|
|
144
|
+
)
|
|
145
|
+
if (backdropEl) animate(backdropEl, { opacity: [1, 0] }, { duration: 0.25, ease: 'easeIn' })
|
|
146
|
+
controls.finished.then(finishClose).catch(finishClose)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const finishClose = () => {
|
|
150
|
+
setOpen(false)
|
|
151
|
+
props.onOpenChange?.(false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
155
|
+
if (e.key === 'Escape') doClose()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
onMount(() => document.addEventListener('keydown', onKeyDown))
|
|
159
|
+
onCleanup(() => {
|
|
160
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
161
|
+
controls?.stop()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<div
|
|
167
|
+
ref={triggerEl}
|
|
168
|
+
role="button"
|
|
169
|
+
tabindex={0}
|
|
170
|
+
class={cn('cursor-pointer', props.class)}
|
|
171
|
+
onClick={doOpen}
|
|
172
|
+
onKeyDown={(e) => {
|
|
173
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
174
|
+
e.preventDefault()
|
|
175
|
+
doOpen()
|
|
176
|
+
}
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
{props.trigger}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<Show when={open()}>
|
|
183
|
+
<Portal>
|
|
184
|
+
<div
|
|
185
|
+
ref={backdropEl}
|
|
186
|
+
aria-hidden="true"
|
|
187
|
+
onClick={doClose}
|
|
188
|
+
class="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-sm"
|
|
189
|
+
style={{ opacity: motionReduced() ? '1' : '0' }}
|
|
190
|
+
/>
|
|
191
|
+
<div
|
|
192
|
+
ref={(el) => {
|
|
193
|
+
panelEl = el
|
|
194
|
+
// Measure/FLIP after layout settles.
|
|
195
|
+
requestAnimationFrame(runOpenFlip)
|
|
196
|
+
}}
|
|
197
|
+
role="dialog"
|
|
198
|
+
aria-modal="true"
|
|
199
|
+
class={cn(
|
|
200
|
+
'card fixed z-[9999] overflow-hidden rounded-2xl border border-border bg-card shadow-2xl',
|
|
201
|
+
props.panelClass,
|
|
202
|
+
)}
|
|
203
|
+
style={{ opacity: '0', margin: '0' }}
|
|
204
|
+
>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
aria-label="Close"
|
|
208
|
+
onClick={doClose}
|
|
209
|
+
class="absolute right-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full bg-muted/80 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
210
|
+
>
|
|
211
|
+
<svg
|
|
212
|
+
viewBox="0 0 24 24"
|
|
213
|
+
class="h-4 w-4"
|
|
214
|
+
fill="none"
|
|
215
|
+
stroke="currentColor"
|
|
216
|
+
stroke-width="2.5"
|
|
217
|
+
stroke-linecap="round"
|
|
218
|
+
aria-hidden="true"
|
|
219
|
+
>
|
|
220
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
221
|
+
</svg>
|
|
222
|
+
</button>
|
|
223
|
+
{props.children}
|
|
224
|
+
</div>
|
|
225
|
+
</Portal>
|
|
226
|
+
</Show>
|
|
227
|
+
</>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// FillText — text with a color "fill" band sweeping left-to-right on loop,
|
|
2
|
+
// for loading states. The sweep is a background-position animation clipped
|
|
3
|
+
// to the glyphs via `background-clip: text`, driven by Motion's `animate`.
|
|
4
|
+
// Falls back to plain static text under reduced motion.
|
|
5
|
+
import { onCleanup, onMount, Show, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface FillTextProps {
|
|
11
|
+
text: string
|
|
12
|
+
/** Seconds per sweep. @default 1.6 */
|
|
13
|
+
duration?: number
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders `text` with a bright gradient band that sweeps across the glyphs
|
|
19
|
+
* repeatedly, useful as a lightweight loading indicator. Respects
|
|
20
|
+
* `prefers-reduced-motion` by rendering plain muted text instead of animating.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <FillText text="Loading results…" />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function FillText(props: FillTextProps): JSX.Element {
|
|
28
|
+
let el!: HTMLSpanElement
|
|
29
|
+
let controls: { stop: () => void } | undefined
|
|
30
|
+
|
|
31
|
+
onMount(() => {
|
|
32
|
+
if (motionReduced()) return
|
|
33
|
+
|
|
34
|
+
controls = animate(
|
|
35
|
+
el,
|
|
36
|
+
{ backgroundPosition: ['200% 0%', '-200% 0%'] },
|
|
37
|
+
{ duration: props.duration ?? 1.6, ease: 'linear', repeat: Infinity },
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
onCleanup(() => controls?.stop())
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<span role="status" class={cn('font-medium', props.class)}>
|
|
45
|
+
<Show when={!motionReduced()} fallback={<span class="text-muted-foreground">{props.text}</span>}>
|
|
46
|
+
<span
|
|
47
|
+
ref={el}
|
|
48
|
+
style={{
|
|
49
|
+
background:
|
|
50
|
+
'linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 40%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 60%, hsl(var(--muted-foreground)) 100%)',
|
|
51
|
+
'background-size': '200% 100%',
|
|
52
|
+
'-webkit-background-clip': 'text',
|
|
53
|
+
'background-clip': 'text',
|
|
54
|
+
'-webkit-text-fill-color': 'transparent',
|
|
55
|
+
color: 'transparent',
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{props.text}
|
|
59
|
+
</span>
|
|
60
|
+
</Show>
|
|
61
|
+
</span>
|
|
62
|
+
)
|
|
63
|
+
}
|