@a4ui/core 0.29.1 → 0.30.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 +9 -1
- package/dist/index.js +4181 -3400
- 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/SlideArrowButton.d.ts +30 -0
- package/dist/ui/TimeMachineStack.d.ts +34 -0
- package/package.json +2 -1
- package/src/index.ts +21 -1
- package/src/ui/CardSpread.tsx +194 -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/SlideArrowButton.tsx +108 -0
- package/src/ui/TimeMachineStack.tsx +197 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Fan shape applied when a {@link CardSpread} opens. Defaults to `'arc'`. */
|
|
3
|
+
export type CardSpreadLayout = 'arc' | 'long-arc' | 'linear' | 'corner' | 'cascade' | 'scatter' | 'wheel';
|
|
4
|
+
export interface CardSpreadProps {
|
|
5
|
+
/** Card contents; each entry becomes one card in the stack. */
|
|
6
|
+
items: JSX.Element[];
|
|
7
|
+
/** Fan shape. @default 'arc' */
|
|
8
|
+
layout?: CardSpreadLayout;
|
|
9
|
+
/** Controlled open state. Omit to spread on hover instead. */
|
|
10
|
+
open?: boolean;
|
|
11
|
+
/** Strength knob: degrees for arced/wheel layouts, px for linear/corner/cascade/scatter. Per-layout default applies when omitted. */
|
|
12
|
+
spread?: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
'aria-label'?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A stack of cards that fans out into an arc, line, corner, cascade, scatter,
|
|
18
|
+
* or wheel shape. At rest the cards sit in a near-centered pile; opening
|
|
19
|
+
* (controlled via `open`, or on hover when uncontrolled) spreads them.
|
|
20
|
+
* Animates only `transform`/`opacity` (Motion spring) so the fan stays
|
|
21
|
+
* compositor-only, and falls back to a static fanned layout under reduced
|
|
22
|
+
* motion.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <CardSpread
|
|
27
|
+
* layout="arc"
|
|
28
|
+
* aria-label="Trip itinerary cards"
|
|
29
|
+
* items={[
|
|
30
|
+
* <div class="p-4">Day 1 — Arrival</div>,
|
|
31
|
+
* <div class="p-4">Day 2 — Old Town</div>,
|
|
32
|
+
* <div class="p-4">Day 3 — Coast drive</div>,
|
|
33
|
+
* ]}
|
|
34
|
+
* />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export declare function CardSpread(props: CardSpreadProps): JSX.Element;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Layout curve for a {@link Carousel3D}. Defaults to `'coverflow'`. */
|
|
3
|
+
export type Carousel3DVariant = 'coverflow' | 'arc';
|
|
4
|
+
export interface Carousel3DProps {
|
|
5
|
+
/** Slides shown around the active one, arranged by distance from it. */
|
|
6
|
+
slides: JSX.Element[];
|
|
7
|
+
/** Layout curve. @default 'coverflow' */
|
|
8
|
+
variant?: Carousel3DVariant;
|
|
9
|
+
/** Controlled active index. */
|
|
10
|
+
active?: number;
|
|
11
|
+
/** Initial active index when uncontrolled. @default 0 */
|
|
12
|
+
defaultActive?: number;
|
|
13
|
+
/** Fires with the new index whenever the active slide changes. */
|
|
14
|
+
onChange?: (index: number) => void;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A 3D carousel that arranges slides around the active one on a coverflow or
|
|
19
|
+
* shallow arc curve — `perspective` + `preserve-3d`, each slide positioned
|
|
20
|
+
* purely by `transform`/`opacity` (Motion spring) so it stays
|
|
21
|
+
* compositor-only. Supports controlled/uncontrolled `active`, prev/next
|
|
22
|
+
* buttons, dot indicators, arrow-key navigation, and pointer-drag swiping.
|
|
23
|
+
* Falls back to a flat opacity crossfade (no 3D transforms) under reduced
|
|
24
|
+
* motion.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* <Carousel3D
|
|
29
|
+
* variant="coverflow"
|
|
30
|
+
* slides={[
|
|
31
|
+
* <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail A — Ridge Loop</div>,
|
|
32
|
+
* <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail B — Falls Overlook</div>,
|
|
33
|
+
* <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail C — Summit Pass</div>,
|
|
34
|
+
* ]}
|
|
35
|
+
* />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare function Carousel3D(props: Carousel3DProps): JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface FocusBlurGroupProps<T> {
|
|
3
|
+
/** The full data set to render. */
|
|
4
|
+
items: T[];
|
|
5
|
+
/** Row renderer. */
|
|
6
|
+
children: (item: T, index: number) => JSX.Element;
|
|
7
|
+
/** Blur (px) applied to non-focused items. @default 2 */
|
|
8
|
+
blur?: number;
|
|
9
|
+
/** Opacity (0..1) applied to non-focused items. @default 0.5 */
|
|
10
|
+
dim?: number;
|
|
11
|
+
/** Applied to the container — use it for layout (e.g. `flex gap-6`). */
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Container that spotlights whichever item is hovered or keyboard-focused:
|
|
16
|
+
* that item stays sharp and fully opaque while its siblings blur and dim.
|
|
17
|
+
* Only `opacity`/`filter` transition — never a layout property — and under
|
|
18
|
+
* `motionReduced()` the blur is skipped (dim-only, no transition) since blur
|
|
19
|
+
* motion reads as motion-sickness-adjacent.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <FocusBlurGroup items={navLinks} class="flex gap-6">
|
|
24
|
+
* {(link) => (
|
|
25
|
+
* <a href={link.href} class="text-sm font-medium text-foreground">
|
|
26
|
+
* {link.label}
|
|
27
|
+
* </a>
|
|
28
|
+
* )}
|
|
29
|
+
* </FocusBlurGroup>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function FocusBlurGroup<T>(props: FocusBlurGroupProps<T>): JSX.Element;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Visual style of an {@link IconMorphButton}. Defaults to `'ghost'`. */
|
|
3
|
+
export type IconMorphButtonVariant = 'solid' | 'ghost' | 'outline';
|
|
4
|
+
export interface IconMorphButtonProps {
|
|
5
|
+
/** Icon shown while `pressed` is `false`, e.g. `<Copy size={18} />`. */
|
|
6
|
+
inactive: JSX.Element;
|
|
7
|
+
/** Icon shown while `pressed` is `true`, e.g. `<Check size={18} />`. */
|
|
8
|
+
active: JSX.Element;
|
|
9
|
+
/** Controlled pressed state. Omit to let the button manage its own state. */
|
|
10
|
+
pressed?: boolean;
|
|
11
|
+
/** Initial pressed state when uncontrolled. Defaults to `false`. */
|
|
12
|
+
defaultPressed?: boolean;
|
|
13
|
+
/** Fired with the next pressed state, controlled or uncontrolled. */
|
|
14
|
+
onChange?: (pressed: boolean) => void;
|
|
15
|
+
/**
|
|
16
|
+
* When greater than `0`, auto-reverts to `inactive` after this many ms once
|
|
17
|
+
* pressed (e.g. copy → check → copy). The timer is cleared on cleanup and
|
|
18
|
+
* restarted on every re-press. @default 0
|
|
19
|
+
*/
|
|
20
|
+
revertAfter?: number;
|
|
21
|
+
/** Optional label shown next to the icon while `pressed` is `false`. */
|
|
22
|
+
label?: JSX.Element;
|
|
23
|
+
/** Optional label shown next to the icon while `pressed` is `true`. */
|
|
24
|
+
activeLabel?: JSX.Element;
|
|
25
|
+
/** Visual style. Defaults to `'ghost'`. */
|
|
26
|
+
variant?: IconMorphButtonVariant;
|
|
27
|
+
class?: string;
|
|
28
|
+
'aria-label'?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Two-state icon toggle button whose icon morphs (spring scale + rotate +
|
|
32
|
+
* fade) between `inactive` and `active` glyphs, stacked in a fixed-size box.
|
|
33
|
+
* Supports controlled (`pressed`/`onChange`) or uncontrolled
|
|
34
|
+
* (`defaultPressed`) use, an optional auto-revert timer, and an optional
|
|
35
|
+
* crossfading label. Falls back to an instant swap under reduced motion.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <IconMorphButton
|
|
40
|
+
* inactive={<Copy size={18} />}
|
|
41
|
+
* active={<Check size={18} />}
|
|
42
|
+
* aria-label="Copy to clipboard"
|
|
43
|
+
* revertAfter={1500}
|
|
44
|
+
* onChange={(pressed) => {
|
|
45
|
+
* if (pressed) void navigator.clipboard?.writeText('npm install @a4ui/core')
|
|
46
|
+
* }}
|
|
47
|
+
* />
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export declare function IconMorphButton(props: IconMorphButtonProps): JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Which icon a {@link LikeButton} renders — picks the semantic use (like/favorite/save). */
|
|
3
|
+
export type LikeButtonIcon = 'heart' | 'star' | 'bookmark';
|
|
4
|
+
export interface LikeButtonProps {
|
|
5
|
+
/** Controlled pressed state. When set, wins over internal state — pair with `onChange`. */
|
|
6
|
+
pressed?: boolean;
|
|
7
|
+
/** Initial pressed state for uncontrolled use. @default false */
|
|
8
|
+
defaultPressed?: boolean;
|
|
9
|
+
onChange?: (pressed: boolean) => void;
|
|
10
|
+
/** Which icon to render. @default 'heart' */
|
|
11
|
+
icon?: LikeButtonIcon;
|
|
12
|
+
/** Count shown next to the icon (e.g. total likes). */
|
|
13
|
+
count?: number;
|
|
14
|
+
/** Show `count`. @default false */
|
|
15
|
+
showCount?: boolean;
|
|
16
|
+
/** Class applied to the icon/count when pressed. @default 'text-primary' */
|
|
17
|
+
activeClass?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
'aria-label'?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Like/favorite/save-later toggle button. One component covers all three via
|
|
23
|
+
* `icon` ('heart' | 'star' | 'bookmark') rather than three near-identical
|
|
24
|
+
* toggles. On becoming pressed the icon fills, morphs to `activeClass`,
|
|
25
|
+
* springs a scale pop, and bursts a handful of tiny icon copies outward —
|
|
26
|
+
* skipped under reduced motion, which just flips the filled/unfilled state
|
|
27
|
+
* instantly. Works controlled (`pressed` + `onChange`) or uncontrolled
|
|
28
|
+
* (`defaultPressed`).
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <LikeButton defaultPressed count={128} showCount aria-label="Like this post" />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function LikeButton(props: LikeButtonProps): JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Feedback micro-animation a {@link MicroButton} plays. Defaults to `'ring'`. */
|
|
3
|
+
export type MicroButtonEffect = 'spin' | 'shake' | 'pulse' | 'ring' | 'sparkle' | 'glare';
|
|
4
|
+
/** Visual style of a {@link MicroButton}. Defaults to `'solid'`. */
|
|
5
|
+
export type MicroButtonVariant = 'solid' | 'ghost' | 'outline';
|
|
6
|
+
export interface MicroButtonProps {
|
|
7
|
+
children?: JSX.Element;
|
|
8
|
+
/** Feedback micro-animation. `'glare'` plays on hover; the rest play on click. @default 'ring' */
|
|
9
|
+
effect?: MicroButtonEffect;
|
|
10
|
+
onClick?: (e: MouseEvent) => void;
|
|
11
|
+
/** Visual style. @default 'solid' */
|
|
12
|
+
variant?: MicroButtonVariant;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
class?: string;
|
|
15
|
+
'aria-label'?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Small icon/label button that plays a short feedback micro-animation on
|
|
19
|
+
* interaction — spin, shake, pulse, ring, or sparkle on click, or a diagonal
|
|
20
|
+
* glare sweep on hover. Every effect animates only `transform`/`opacity`, so
|
|
21
|
+
* it stays cheap even on low-end devices; decorative particles are spawned
|
|
22
|
+
* and removed on completion, never left in the DOM. Renders a plain static
|
|
23
|
+
* `<button>` with no effect under reduced motion.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <MicroButton effect="sparkle" aria-label="Like">
|
|
28
|
+
* <Heart size={16} />
|
|
29
|
+
* </MicroButton>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function MicroButton(props: MicroButtonProps): JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { JSX, ParentProps } from 'solid-js';
|
|
2
|
+
/** Direction the label/arrow slide toward on hover. Defaults to `'right'`. */
|
|
3
|
+
export type SlideArrowDirection = 'right' | 'left';
|
|
4
|
+
/** Visual style of a {@link SlideArrowButton}. Defaults to `'solid'`. */
|
|
5
|
+
export type SlideArrowButtonVariant = 'solid' | 'outline';
|
|
6
|
+
export interface SlideArrowButtonProps extends ParentProps {
|
|
7
|
+
onClick?: (e: MouseEvent) => void;
|
|
8
|
+
/** Direction the label/arrow slide toward on hover. Defaults to `'right'`. */
|
|
9
|
+
direction?: SlideArrowDirection;
|
|
10
|
+
/** Custom icon; defaults to `ArrowRight`/`ArrowLeft` per `direction`. */
|
|
11
|
+
icon?: JSX.Element;
|
|
12
|
+
/** Visual style. Defaults to `'solid'`. */
|
|
13
|
+
variant?: SlideArrowButtonVariant;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
class?: string;
|
|
16
|
+
/** Accessible label, forwarded to the underlying `<button>`. */
|
|
17
|
+
'aria-label'?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* CTA button: at rest the label sits centered; on hover it slides toward
|
|
21
|
+
* `direction` while an arrow slides in from the opposite edge. Pure CSS
|
|
22
|
+
* transform/opacity transitions — no JS animation engine.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <SlideArrowButton onClick={() => go()}>Get started</SlideArrowButton>
|
|
27
|
+
* <SlideArrowButton direction="left" variant="outline">Back</SlideArrowButton>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function SlideArrowButton(props: SlideArrowButtonProps): JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface TimeMachineStackProps {
|
|
3
|
+
/** Slide contents, one per stack position. */
|
|
4
|
+
slides: JSX.Element[];
|
|
5
|
+
/** Controlled active index. Omit to manage it internally. */
|
|
6
|
+
active?: number;
|
|
7
|
+
/** Initial active index when uncontrolled. @default 0 */
|
|
8
|
+
defaultActive?: number;
|
|
9
|
+
/** Fired whenever the active index changes, via click or keyboard. */
|
|
10
|
+
onChange?: (index: number) => void;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Apple-style "time machine" depth stack: the active slide is front and
|
|
15
|
+
* full-size, and the rest recede into the screen behind it — each further
|
|
16
|
+
* slide translated up, pushed back on Z, scaled down, and dimmed. A vertical
|
|
17
|
+
* scrubber of real buttons (one per slide), pinned to the right edge, jumps
|
|
18
|
+
* to any index; the stage handles ArrowUp/ArrowDown (and Left/Right) when
|
|
19
|
+
* focused. Animates only `transform`/`opacity` (Motion spring); reduced
|
|
20
|
+
* motion collapses to a flat view of just the active slide.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <TimeMachineStack
|
|
25
|
+
* defaultActive={0}
|
|
26
|
+
* slides={[
|
|
27
|
+
* <div class="flex h-full items-center justify-center p-6">Sunset over the bay</div>,
|
|
28
|
+
* <div class="flex h-full items-center justify-center p-6">Empty beach at dawn</div>,
|
|
29
|
+
* <div class="flex h-full items-center justify-center p-6">Fog through the pines</div>,
|
|
30
|
+
* ]}
|
|
31
|
+
* />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function TimeMachineStack(props: TimeMachineStackProps): JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a4ui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
"test:visual": "playwright test --config playwright.visual.config.ts",
|
|
102
102
|
"test:report": "playwright show-report",
|
|
103
103
|
"test:package": "node scripts/test-package.mjs",
|
|
104
|
+
"test:smoke": "node scripts/smoke-console.mjs",
|
|
104
105
|
"validate": "npm run typecheck && npm run lint && npm run format:check && npm run test:unit && npm run build && npm run test:package",
|
|
105
106
|
"prepublishOnly": "npm run validate",
|
|
106
107
|
"prepare": "husky"
|
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.30.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -160,6 +160,26 @@ export { FloatingToolbar, type FloatingToolbarProps } from './ui/FloatingToolbar
|
|
|
160
160
|
export { PageTransition, type PageTransitionProps } from './ui/PageTransition'
|
|
161
161
|
export { InlineSelect, type InlineSelectProps, type InlineSelectOption } from './ui/InlineSelect'
|
|
162
162
|
|
|
163
|
+
// Micro-interactions — animated buttons + card/3D transitions (Motion-driven).
|
|
164
|
+
export {
|
|
165
|
+
MicroButton,
|
|
166
|
+
type MicroButtonProps,
|
|
167
|
+
type MicroButtonEffect,
|
|
168
|
+
type MicroButtonVariant,
|
|
169
|
+
} from './ui/MicroButton'
|
|
170
|
+
export { IconMorphButton, type IconMorphButtonProps, type IconMorphButtonVariant } from './ui/IconMorphButton'
|
|
171
|
+
export { LikeButton, type LikeButtonProps, type LikeButtonIcon } from './ui/LikeButton'
|
|
172
|
+
export {
|
|
173
|
+
SlideArrowButton,
|
|
174
|
+
type SlideArrowButtonProps,
|
|
175
|
+
type SlideArrowButtonVariant,
|
|
176
|
+
type SlideArrowDirection,
|
|
177
|
+
} from './ui/SlideArrowButton'
|
|
178
|
+
export { FocusBlurGroup, type FocusBlurGroupProps } from './ui/FocusBlurGroup'
|
|
179
|
+
export { CardSpread, type CardSpreadProps, type CardSpreadLayout } from './ui/CardSpread'
|
|
180
|
+
export { Carousel3D, type Carousel3DProps, type Carousel3DVariant } from './ui/Carousel3D'
|
|
181
|
+
export { TimeMachineStack, type TimeMachineStackProps } from './ui/TimeMachineStack'
|
|
182
|
+
|
|
163
183
|
// Phase 2 — code, search, navigation, master-detail.
|
|
164
184
|
export { CodeTabs, type CodeTabsProps, type CodeTab } from './ui/CodeTabs'
|
|
165
185
|
export { PillSearch, type PillSearchProps, type PillField } from './ui/PillSearch'
|
|
@@ -0,0 +1,194 @@
|
|
|
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): CardTransform {
|
|
52
|
+
// Tight near-centered stack: a hair of offset/rotation per card so the
|
|
53
|
+
// pile still reads as separate cards.
|
|
54
|
+
return { x: i * 1.5, y: -i * 1, rotate: (i % 2 === 0 ? 1 : -1) * 1.5, origin: 'bottom center' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function openTransform(layout: CardSpreadLayout, i: number, n: number, spread: number): CardTransform {
|
|
58
|
+
const mid = (n - 1) / 2
|
|
59
|
+
const d = i - mid
|
|
60
|
+
|
|
61
|
+
switch (layout) {
|
|
62
|
+
case 'arc': {
|
|
63
|
+
const gap = 44
|
|
64
|
+
return { x: d * gap, y: Math.abs(d) ** 2 * 6, rotate: d * spread, origin: 'bottom center' }
|
|
65
|
+
}
|
|
66
|
+
case 'long-arc': {
|
|
67
|
+
const gap = 68
|
|
68
|
+
return { x: d * gap, y: Math.abs(d) ** 2 * 4, rotate: d * spread, origin: 'bottom center' }
|
|
69
|
+
}
|
|
70
|
+
case 'linear':
|
|
71
|
+
return { x: d * spread, y: 0, rotate: 0, origin: 'bottom center' }
|
|
72
|
+
case 'corner':
|
|
73
|
+
return { x: i * (spread * 0.6), y: -i * (spread * 0.15), rotate: i * spread, origin: 'bottom left' }
|
|
74
|
+
case 'cascade':
|
|
75
|
+
return {
|
|
76
|
+
x: i * spread,
|
|
77
|
+
y: i * (spread * 0.55),
|
|
78
|
+
rotate: (i % 2 === 0 ? 1 : -1) * 3,
|
|
79
|
+
origin: 'bottom center',
|
|
80
|
+
}
|
|
81
|
+
case 'scatter': {
|
|
82
|
+
const r = pseudoRandom(i)
|
|
83
|
+
const r2 = pseudoRandom(i + 100)
|
|
84
|
+
return { x: r * spread * 3, y: r2 * spread * 1.5, rotate: r * spread, origin: 'center center' }
|
|
85
|
+
}
|
|
86
|
+
case 'wheel':
|
|
87
|
+
return { x: d * 18, y: Math.abs(d) * 10, rotate: d * spread, origin: 'bottom center' }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function transformString(t: CardTransform): string {
|
|
92
|
+
return `translate(${t.x}px, ${t.y}px) rotate(${t.rotate}deg)`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A stack of cards that fans out into an arc, line, corner, cascade, scatter,
|
|
97
|
+
* or wheel shape. At rest the cards sit in a near-centered pile; opening
|
|
98
|
+
* (controlled via `open`, or on hover when uncontrolled) spreads them.
|
|
99
|
+
* Animates only `transform`/`opacity` (Motion spring) so the fan stays
|
|
100
|
+
* compositor-only, and falls back to a static fanned layout under reduced
|
|
101
|
+
* motion.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```tsx
|
|
105
|
+
* <CardSpread
|
|
106
|
+
* layout="arc"
|
|
107
|
+
* aria-label="Trip itinerary cards"
|
|
108
|
+
* items={[
|
|
109
|
+
* <div class="p-4">Day 1 — Arrival</div>,
|
|
110
|
+
* <div class="p-4">Day 2 — Old Town</div>,
|
|
111
|
+
* <div class="p-4">Day 3 — Coast drive</div>,
|
|
112
|
+
* ]}
|
|
113
|
+
* />
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export function CardSpread(props: CardSpreadProps): JSX.Element {
|
|
117
|
+
const cardRefs: (HTMLElement | undefined)[] = []
|
|
118
|
+
const controls: (ReturnType<typeof animate> | undefined)[] = []
|
|
119
|
+
const [hovered, setHovered] = createSignal(false)
|
|
120
|
+
|
|
121
|
+
const layout = () => props.layout ?? 'arc'
|
|
122
|
+
const spread = () => props.spread ?? DEFAULT_SPREAD[layout()]
|
|
123
|
+
const isOpen = () => (props.open !== undefined ? props.open : hovered())
|
|
124
|
+
|
|
125
|
+
// Reduced motion always renders the fanned layout, statically, regardless
|
|
126
|
+
// of open/hover state — so `open`/`hover` never trigger visible motion.
|
|
127
|
+
const applyTransforms = (animated: boolean) => {
|
|
128
|
+
const n = props.items.length
|
|
129
|
+
const reduced = motionReduced()
|
|
130
|
+
const open = reduced ? true : isOpen()
|
|
131
|
+
|
|
132
|
+
cardRefs.forEach((card, i) => {
|
|
133
|
+
if (!card) return
|
|
134
|
+
const t = open ? openTransform(layout(), i, n, spread()) : restTransform(i)
|
|
135
|
+
card.style.transformOrigin = t.origin
|
|
136
|
+
card.style.zIndex = String(open ? i : n - i)
|
|
137
|
+
|
|
138
|
+
controls[i]?.stop()
|
|
139
|
+
if (reduced || !animated) {
|
|
140
|
+
card.style.transform = transformString(t)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
controls[i] = animate(
|
|
144
|
+
card,
|
|
145
|
+
{ x: t.x, y: t.y, rotate: t.rotate },
|
|
146
|
+
{ type: 'spring', stiffness: 260, damping: 24 },
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Initial paint only: snap to the correct state (no animation) so a controlled
|
|
152
|
+
// `open` or a reduced-motion preference doesn't flash the closed stack first.
|
|
153
|
+
// Deliberately untracked (onMount, not createEffect) — it must run exactly
|
|
154
|
+
// once, otherwise it would re-fire on every hover/open change and snap the
|
|
155
|
+
// cards straight to their target, leaving the animated effect below nothing
|
|
156
|
+
// to spring from.
|
|
157
|
+
onMount(() => applyTransforms(false))
|
|
158
|
+
// Subsequent changes to open/hover/layout/spread animate in.
|
|
159
|
+
createEffect(
|
|
160
|
+
on([() => props.open, hovered, layout, spread, () => props.items.length], () => applyTransforms(true), {
|
|
161
|
+
defer: true,
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
onCleanup(() => controls.forEach((c) => c?.stop()))
|
|
165
|
+
|
|
166
|
+
const handleEnter = () => {
|
|
167
|
+
if (props.open === undefined && !motionReduced()) setHovered(true)
|
|
168
|
+
}
|
|
169
|
+
const handleLeave = () => {
|
|
170
|
+
if (props.open === undefined && !motionReduced()) setHovered(false)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
role="group"
|
|
176
|
+
aria-label={props['aria-label']}
|
|
177
|
+
class={cn('relative inline-block h-56 w-40', props.class)}
|
|
178
|
+
onPointerEnter={handleEnter}
|
|
179
|
+
onPointerLeave={handleLeave}
|
|
180
|
+
>
|
|
181
|
+
<For each={props.items}>
|
|
182
|
+
{(item, i) => (
|
|
183
|
+
<div
|
|
184
|
+
ref={(el) => (cardRefs[i()] = el)}
|
|
185
|
+
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"
|
|
186
|
+
style={{ transform: transformString(restTransform(i())) }}
|
|
187
|
+
>
|
|
188
|
+
{item}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</For>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|