@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,25 @@
|
|
|
1
|
+
export interface FlyToCartOptions {
|
|
2
|
+
/** Image URL to fly; if omitted, the source element is cloned. */
|
|
3
|
+
image?: string;
|
|
4
|
+
/** Flight duration in seconds. @default 0.6 */
|
|
5
|
+
duration?: number;
|
|
6
|
+
/** Called when the flight finishes (e.g. to increment the cart count). */
|
|
7
|
+
onArrive?: () => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Animate a small ghost from `source` to `target` (e.g. product → cart icon),
|
|
11
|
+
* then a bump on the target. No-op under reduced motion (still calls onArrive).
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* function onAddToCart(e: MouseEvent) {
|
|
16
|
+
* const button = e.currentTarget as HTMLElement
|
|
17
|
+
* const cartIcon = document.getElementById('cart-icon')!
|
|
18
|
+
* flyToCart(button, cartIcon, {
|
|
19
|
+
* image: product.thumbnailUrl,
|
|
20
|
+
* onArrive: () => setCartCount((n) => n + 1),
|
|
21
|
+
* })
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function flyToCart(source: Element, target: Element, opts?: FlyToCartOptions): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export type CurtainVariant = 'fade' | 'doors' | 'blinds' | 'iris' | 'shutter' | 'clip' | 'pixels';
|
|
3
|
+
export interface CurtainProps {
|
|
4
|
+
/** true = cover the screen; false = uncover (reveal content). */
|
|
5
|
+
show: boolean;
|
|
6
|
+
/** Which wipe style. @default 'fade' */
|
|
7
|
+
variant?: CurtainVariant;
|
|
8
|
+
/** Overlay color (CSS color / var). @default 'hsl(var(--background))' */
|
|
9
|
+
color?: string;
|
|
10
|
+
/** Seconds for one direction. @default 0.6 */
|
|
11
|
+
duration?: number;
|
|
12
|
+
/** Called once the screen is fully covered (show went true→covered). */
|
|
13
|
+
onCovered?: () => void;
|
|
14
|
+
/** Called once fully uncovered (show went false→revealed). */
|
|
15
|
+
onRevealed?: () => void;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A controlled full-screen curtain for page/route transitions. Set `show` to
|
|
20
|
+
* `true` to cover the viewport, `false` to uncover it, and swap your route
|
|
21
|
+
* content while covered. `variant` picks the wipe style — fade, sliding
|
|
22
|
+
* doors, blinds, shutter (vertical blinds), iris/circle wipe, a left-to-right
|
|
23
|
+
* clip wipe, or a pixel-grid cascade —
|
|
24
|
+
* `onCovered` / `onRevealed` fire once each transition finishes so you can
|
|
25
|
+
* swap content at exactly the right moment. Skips the animation (and still
|
|
26
|
+
* fires the callback on the next microtask) under reduced motion.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const [covered, setCovered] = createSignal(false)
|
|
31
|
+
* const goTo = (path: string) => setCovered(true)
|
|
32
|
+
* return (
|
|
33
|
+
* <>
|
|
34
|
+
* <Curtain
|
|
35
|
+
* show={covered()}
|
|
36
|
+
* variant="iris"
|
|
37
|
+
* onCovered={() => { loadRoute(); setCovered(false) }}
|
|
38
|
+
* />
|
|
39
|
+
* <Router />
|
|
40
|
+
* </>
|
|
41
|
+
* )
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function Curtain(props: CurtainProps): JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface ExpandableProps {
|
|
3
|
+
/** Collapsed content — the card users click to expand. */
|
|
4
|
+
trigger: JSX.Element;
|
|
5
|
+
/** Expanded panel content. */
|
|
6
|
+
children: JSX.Element;
|
|
7
|
+
/** `dialog` = centered panel capped at `maxWidth`; `full` = near-fullscreen. @default 'dialog' */
|
|
8
|
+
size?: 'dialog' | 'full';
|
|
9
|
+
/** Max width (px) of the expanded panel when `size='dialog'`. @default 640 */
|
|
10
|
+
maxWidth?: number;
|
|
11
|
+
/** Notified when the panel opens/closes. */
|
|
12
|
+
onOpenChange?: (open: boolean) => void;
|
|
13
|
+
/** Class for the inline trigger wrapper. */
|
|
14
|
+
class?: string;
|
|
15
|
+
/** Class for the expanded panel surface. */
|
|
16
|
+
panelClass?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Expands a card into an overlay panel with a shared-element (FLIP) transition —
|
|
20
|
+
* the card appears to grow into the dialog and shrink back on close. Animates
|
|
21
|
+
* position + size (never scale) so content stays crisp. Falls back to a plain
|
|
22
|
+
* show/hide under reduced motion. Close with the ✕, a backdrop click, or Escape.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <Expandable
|
|
27
|
+
* trigger={<Card class="cursor-pointer">Tap to expand</Card>}
|
|
28
|
+
* size="dialog"
|
|
29
|
+
* >
|
|
30
|
+
* <div class="p-6">Full details here…</div>
|
|
31
|
+
* </Expandable>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function Expandable(props: ExpandableProps): JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface FillTextProps {
|
|
3
|
+
text: string;
|
|
4
|
+
/** Seconds per sweep. @default 1.6 */
|
|
5
|
+
duration?: number;
|
|
6
|
+
class?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Renders `text` with a bright gradient band that sweeps across the glyphs
|
|
10
|
+
* repeatedly, useful as a lightweight loading indicator. Respects
|
|
11
|
+
* `prefers-reduced-motion` by rendering plain muted text instead of animating.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <FillText text="Loading results…" />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function FillText(props: FillTextProps): JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface HoldToConfirmProps {
|
|
3
|
+
onConfirm: () => void;
|
|
4
|
+
/** Button label. @default 'Hold to confirm' */
|
|
5
|
+
label?: string;
|
|
6
|
+
/** How long to hold, in ms. @default 1200 */
|
|
7
|
+
holdMs?: number;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Press-and-hold button for destructive/important actions: `onConfirm` only
|
|
13
|
+
* fires once the pointer (or Space/Enter) has been held down for `holdMs`.
|
|
14
|
+
* A fill layer sweeps left-to-right as visual progress; releasing early stops
|
|
15
|
+
* and resets it. Under reduced motion the hold is still required (gated by a
|
|
16
|
+
* plain timer instead of the tracked animation), but the fill jumps instead
|
|
17
|
+
* of sweeping and the success flourish is skipped.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <HoldToConfirm label="Hold to delete" holdMs={1500} onConfirm={() => deleteItem(id)} />
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function HoldToConfirm(props: HoldToConfirmProps): JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface LoadingDotsProps {
|
|
3
|
+
/** Dot diameter in px. @default 8 */
|
|
4
|
+
size?: number;
|
|
5
|
+
/** Optional accessible label. @default 'Loading' */
|
|
6
|
+
label?: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Three dots bouncing in a staggered wave — an inline, indeterminate loading
|
|
11
|
+
* indicator. Dots use `bg-current`, so color/size follow the ancestor's text
|
|
12
|
+
* color; diameter is set via `size`. Falls back to a gentle opacity pulse
|
|
13
|
+
* under reduced motion.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <LoadingDots label="Loading results" class="text-primary" />
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function LoadingDots(props: LoadingDotsProps): JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** The lifecycle state a {@link MultiStateBadge} can be in. */
|
|
3
|
+
export type BadgeState = 'idle' | 'loading' | 'success' | 'error';
|
|
4
|
+
export interface MultiStateBadgeProps {
|
|
5
|
+
/** Current lifecycle state; drives color, icon, and label. */
|
|
6
|
+
state: BadgeState;
|
|
7
|
+
/** Override the per-state text. Defaults: idle→'Ready', loading→'Working…', success→'Done', error→'Failed'. */
|
|
8
|
+
labels?: Partial<Record<BadgeState, string>>;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Status badge that animates between `idle` → `loading` → `success` → `error`,
|
|
13
|
+
* morphing its color and swapping its icon/label on each transition. Colors
|
|
14
|
+
* tween via a CSS `transition-colors` class; the pill also gets a spring
|
|
15
|
+
* "pop" and the icon/label cross-fade via Motion's `animate`, both skipped
|
|
16
|
+
* when {@link motionReduced} is true.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <MultiStateBadge state={saveState()} labels={{ error: 'Save failed' }} />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function MultiStateBadge(props: MultiStateBadgeProps): JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface StackNotification {
|
|
3
|
+
id: string | number;
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
icon?: JSX.Element;
|
|
7
|
+
}
|
|
8
|
+
export interface NotificationStackProps {
|
|
9
|
+
/** Newest first. The parent owns this array. */
|
|
10
|
+
items: StackNotification[];
|
|
11
|
+
/** How many cards are visible before the rest collapse behind. @default 3 */
|
|
12
|
+
max?: number;
|
|
13
|
+
/** Fired when a card is dismissed; the parent should drop it from `items`. */
|
|
14
|
+
onDismiss?: (id: string | number) => void;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Stacked notifications. Cards behind the front one are offset down and scaled,
|
|
19
|
+
* so they peek from the bottom edge; removing the front card promotes the rest
|
|
20
|
+
* with a smooth transition (skipped under reduced motion). New cards fade/slide
|
|
21
|
+
* in via `animateIn`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* const [items, setItems] = createStore<StackNotification[]>([])
|
|
26
|
+
* <NotificationStack
|
|
27
|
+
* items={items}
|
|
28
|
+
* onDismiss={(id) => setItems((xs) => xs.filter((x) => x.id !== id))}
|
|
29
|
+
* />
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function NotificationStack(props: NotificationStackProps): JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface NowPlayingProps {
|
|
3
|
+
title: string;
|
|
4
|
+
artist?: string;
|
|
5
|
+
/** Cover art URL. */
|
|
6
|
+
cover?: string;
|
|
7
|
+
/** Whether the equalizer bars are animating. @default true */
|
|
8
|
+
playing?: boolean;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Compact "now playing" music widget with an animated equalizer. Bars bounce
|
|
13
|
+
* via CSS keyframes while `playing` is true; they freeze to a low static
|
|
14
|
+
* height when paused or under reduced motion.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <NowPlaying title="Random Access Memories" artist="Daft Punk" cover="/covers/ram.jpg" playing />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function NowPlaying(props: NowPlayingProps): JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface ParallaxProps {
|
|
3
|
+
children: JSX.Element;
|
|
4
|
+
/** Parallax strength; positive moves slower/opposite. Pixels of travel across the scroll range. @default 80 */
|
|
5
|
+
amount?: number;
|
|
6
|
+
class?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Wraps `children` in an element that drifts vertically as the page scrolls,
|
|
10
|
+
* creating a depth effect. Tracks scroll progress of the element itself
|
|
11
|
+
* against the viewport via Motion's `scroll`; respects `prefers-reduced-motion`
|
|
12
|
+
* (renders static).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <Parallax amount={120}>
|
|
17
|
+
* <img src="/hero.png" alt="" />
|
|
18
|
+
* </Parallax>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function Parallax(props: ParallaxProps): JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface ScrambleTextProps {
|
|
3
|
+
text: string;
|
|
4
|
+
/** When to run: on mount, or on hover. @default 'mount' */
|
|
5
|
+
trigger?: 'mount' | 'hover';
|
|
6
|
+
/** Total scramble duration in ms. @default 800 */
|
|
7
|
+
duration?: number;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Text that scrambles through random glyphs before "decoding" into the final
|
|
12
|
+
* string, left to right, over `duration` ms. Runs once on mount by default;
|
|
13
|
+
* pass `trigger="hover"` to (re)run it — cleanly restarting if it's still
|
|
14
|
+
* mid-scramble — on pointer hover instead. Renders the final text as-is, with
|
|
15
|
+
* no scramble, when the user prefers reduced motion.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <ScrambleText text="ACCESS GRANTED" trigger="hover" duration={600} />
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function ScrambleText(props: ScrambleTextProps): JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface TextRevealProps {
|
|
3
|
+
text: string;
|
|
4
|
+
/** Split unit. @default 'word' */
|
|
5
|
+
by?: 'word' | 'char';
|
|
6
|
+
/** Reveal when scrolled into view instead of on mount. @default false */
|
|
7
|
+
onView?: boolean;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reveals `text`, split into words or characters, with a staggered
|
|
12
|
+
* fade + slide-up (via Motion's `animate` + `stagger`). Runs once on mount by
|
|
13
|
+
* default; pass `onView` to instead reveal the first time the component
|
|
14
|
+
* scrolls into view. Whitespace between words renders as plain text, not an
|
|
15
|
+
* animated unit. Under reduced motion, everything stays visible and no
|
|
16
|
+
* animation runs.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <TextReveal text="Ship it." by="char" onView />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function TextReveal(props: TextRevealProps): JSX.Element;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// import '@a4ui/core/styles.css'
|
|
9
9
|
// import { Button, Card, Modal } from '@a4ui/core'
|
|
10
10
|
|
|
11
|
-
export const A4UI_VERSION = '0.
|
|
11
|
+
export const A4UI_VERSION = '0.13.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -131,6 +131,26 @@ export { Portal, type PortalProps } from './ui/Portal'
|
|
|
131
131
|
export { Sortable, type SortableProps } from './ui/Sortable'
|
|
132
132
|
export { Clock, type ClockProps } from './ui/Clock'
|
|
133
133
|
|
|
134
|
+
// Motion components (src/ui) — animation primitives built on the `motion` engine
|
|
135
|
+
// (adapted from motion.dev examples). All tree-shakeable and reduced-motion aware;
|
|
136
|
+
// `motion` is external, so importing one of these is the only thing that pulls it.
|
|
137
|
+
export { ScrambleText, type ScrambleTextProps } from './ui/ScrambleText'
|
|
138
|
+
export { TextReveal, type TextRevealProps } from './ui/TextReveal'
|
|
139
|
+
export { HoldToConfirm, type HoldToConfirmProps } from './ui/HoldToConfirm'
|
|
140
|
+
export { LoadingDots, type LoadingDotsProps } from './ui/LoadingDots'
|
|
141
|
+
export { Curtain, type CurtainProps, type CurtainVariant } from './ui/Curtain'
|
|
142
|
+
export { Parallax, type ParallaxProps } from './ui/Parallax'
|
|
143
|
+
export { FillText, type FillTextProps } from './ui/FillText'
|
|
144
|
+
export {
|
|
145
|
+
NotificationStack,
|
|
146
|
+
type NotificationStackProps,
|
|
147
|
+
type StackNotification,
|
|
148
|
+
} from './ui/NotificationStack'
|
|
149
|
+
export { MultiStateBadge, type MultiStateBadgeProps, type BadgeState } from './ui/MultiStateBadge'
|
|
150
|
+
export { NowPlaying, type NowPlayingProps } from './ui/NowPlaying'
|
|
151
|
+
export { Expandable, type ExpandableProps } from './ui/Expandable'
|
|
152
|
+
export { flyToCart, type FlyToCartOptions } from './lib/flyToCart'
|
|
153
|
+
|
|
134
154
|
// Layout (src/layout) — generic shell + backdrop + toggles. App-coupled pieces
|
|
135
155
|
// (Sidebar, Topbar, CompanySwitcher, DemoBanner, CommandPalette) stay in the app.
|
|
136
156
|
export { AppShell } from './layout/AppShell'
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The classic "add to basket" fly animation: a ghost of the product image
|
|
2
|
+
// arcs from a source element (button/card) to a target element (the cart
|
|
3
|
+
// icon), then the target bumps to acknowledge the drop. Pure JS/DOM (a
|
|
4
|
+
// throwaway ghost node driven by Motion's `animate`), so it works from any
|
|
5
|
+
// click handler without touching component state. Reduced motion skips the
|
|
6
|
+
// flight entirely but still fires `onArrive` so cart counts stay correct.
|
|
7
|
+
import { animate, motionReduced } from './motion'
|
|
8
|
+
|
|
9
|
+
export interface FlyToCartOptions {
|
|
10
|
+
/** Image URL to fly; if omitted, the source element is cloned. */
|
|
11
|
+
image?: string
|
|
12
|
+
/** Flight duration in seconds. @default 0.6 */
|
|
13
|
+
duration?: number
|
|
14
|
+
/** Called when the flight finishes (e.g. to increment the cart count). */
|
|
15
|
+
onArrive?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Animate a small ghost from `source` to `target` (e.g. product → cart icon),
|
|
20
|
+
* then a bump on the target. No-op under reduced motion (still calls onArrive).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* function onAddToCart(e: MouseEvent) {
|
|
25
|
+
* const button = e.currentTarget as HTMLElement
|
|
26
|
+
* const cartIcon = document.getElementById('cart-icon')!
|
|
27
|
+
* flyToCart(button, cartIcon, {
|
|
28
|
+
* image: product.thumbnailUrl,
|
|
29
|
+
* onArrive: () => setCartCount((n) => n + 1),
|
|
30
|
+
* })
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function flyToCart(source: Element, target: Element, opts?: FlyToCartOptions): void {
|
|
35
|
+
if (typeof document === 'undefined') {
|
|
36
|
+
opts?.onArrive?.()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (motionReduced()) {
|
|
41
|
+
opts?.onArrive?.()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const sourceRect = source.getBoundingClientRect()
|
|
47
|
+
const targetRect = target.getBoundingClientRect()
|
|
48
|
+
|
|
49
|
+
const size = 48
|
|
50
|
+
const sourceCenterX = sourceRect.left + sourceRect.width / 2
|
|
51
|
+
const sourceCenterY = sourceRect.top + sourceRect.height / 2
|
|
52
|
+
const targetCenterX = targetRect.left + targetRect.width / 2
|
|
53
|
+
const targetCenterY = targetRect.top + targetRect.height / 2
|
|
54
|
+
|
|
55
|
+
const dx = targetCenterX - sourceCenterX
|
|
56
|
+
const dy = targetCenterY - sourceCenterY
|
|
57
|
+
|
|
58
|
+
const ghost: HTMLElement = opts?.image ? document.createElement('img') : document.createElement('div')
|
|
59
|
+
if (opts?.image) (ghost as HTMLImageElement).src = opts.image
|
|
60
|
+
|
|
61
|
+
ghost.style.position = 'fixed'
|
|
62
|
+
ghost.style.left = `${sourceCenterX - size / 2}px`
|
|
63
|
+
ghost.style.top = `${sourceCenterY - size / 2}px`
|
|
64
|
+
ghost.style.width = `${size}px`
|
|
65
|
+
ghost.style.height = `${size}px`
|
|
66
|
+
ghost.style.borderRadius = '9999px'
|
|
67
|
+
ghost.style.objectFit = 'cover'
|
|
68
|
+
ghost.style.pointerEvents = 'none'
|
|
69
|
+
ghost.style.zIndex = '9999'
|
|
70
|
+
if (!opts?.image) ghost.style.background = 'var(--a4-primary, #6366f1)'
|
|
71
|
+
|
|
72
|
+
document.body.appendChild(ghost)
|
|
73
|
+
|
|
74
|
+
const duration = opts?.duration ?? 0.6
|
|
75
|
+
|
|
76
|
+
const controls = animate(
|
|
77
|
+
ghost,
|
|
78
|
+
{ x: [0, dx], y: [0, dy], scale: [1, 0.3], opacity: [1, 0.5] },
|
|
79
|
+
{ duration, ease: 'easeInOut' },
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
controls.finished
|
|
83
|
+
.catch(() => {
|
|
84
|
+
/* stopped early — still clean up and settle below */
|
|
85
|
+
})
|
|
86
|
+
.then(() => {
|
|
87
|
+
ghost.remove()
|
|
88
|
+
opts?.onArrive?.()
|
|
89
|
+
try {
|
|
90
|
+
animate(target, { scale: [1, 1.25, 1] }, { duration: 0.3, ease: 'easeOut' })
|
|
91
|
+
} catch {
|
|
92
|
+
/* target no longer animatable — ignore */
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
/* onArrive threw — nothing more we can do here */
|
|
97
|
+
})
|
|
98
|
+
} catch {
|
|
99
|
+
// Rect reads / DOM writes can fail (detached nodes, hostile environments) —
|
|
100
|
+
// bail gracefully rather than throwing out of an event handler.
|
|
101
|
+
opts?.onArrive?.()
|
|
102
|
+
}
|
|
103
|
+
}
|