diff --git a/.gitignore b/.gitignore index e3017c4..ae0d9fb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ demo/.astro # Local source material (screen recordings, GIFs) videos +SOCIAL.md # Environment .env diff --git a/demo/src/components/Nav.astro b/demo/src/components/Nav.astro index 56c3331..642333f 100644 --- a/demo/src/components/Nav.astro +++ b/demo/src/components/Nav.astro @@ -2,7 +2,7 @@ import ComponentDropdown from './islands/ComponentDropdown'; export interface Props { - activeComponent?: 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens' | null; + activeComponent?: 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens' | 'flickdeck' | null; } const { activeComponent = null } = Astro.props; diff --git a/demo/src/components/islands/ComponentDropdown.tsx b/demo/src/components/islands/ComponentDropdown.tsx index e6eced1..fd54fd2 100644 --- a/demo/src/components/islands/ComponentDropdown.tsx +++ b/demo/src/components/islands/ComponentDropdown.tsx @@ -1,15 +1,16 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { MoveIcon, DockGlyphIcon, SheetIcon, SplitterIcon, InspectorIcon, ZoomLensIcon, ChevronDownIcon } from './Icons'; +import { MoveIcon, DockGlyphIcon, SheetIcon, SplitterIcon, InspectorIcon, ZoomLensIcon, FlickDeckIcon, ChevronDownIcon } from './Icons'; -export type ComponentKey = 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens'; +export type ComponentKey = 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens' | 'flickdeck'; const items: { key: ComponentKey; label: string; href: string; icon: ReactNode }[] = [ + { key: 'inspector', label: 'InspectorBubble', href: '/inspector-bubble', icon: }, + { key: 'zoomlens', label: 'ZoomLens', href: '/zoom-lens', icon: }, + { key: 'flickdeck', label: 'FlickDeck', href: '/flick-deck', icon: }, { key: 'launcher', label: 'MovableLauncher', href: '/movable-launcher', icon: }, { key: 'dock', label: 'SnapDock', href: '/snap-dock', icon: }, { key: 'sheet', label: 'DraggableSheet', href: '/draggable-sheet', icon: }, { key: 'splitter', label: 'ResizableSplitPane', href: '/resizable-split-pane', icon: }, - { key: 'inspector', label: 'InspectorBubble', href: '/inspector-bubble', icon: }, - { key: 'zoomlens', label: 'ZoomLens', href: '/zoom-lens', icon: }, ]; export default function ComponentDropdown({ active }: { active: ComponentKey | null }) { diff --git a/demo/src/components/islands/FlickDeckDemo.tsx b/demo/src/components/islands/FlickDeckDemo.tsx new file mode 100644 index 0000000..bef23b9 --- /dev/null +++ b/demo/src/components/islands/FlickDeckDemo.tsx @@ -0,0 +1,262 @@ +import { useState } from 'react'; +import { FlickDeck, type FlickDeckPeek } from 'react-driftkit'; + +type Tip = { id: string; title: string; body: string; color: string }; + +const INITIAL_TIPS: Tip[] = [ + { + id: 'overview', + title: 'Overview', + body: + 'Stack 2–N cards where each back card peeks out by a configurable amount. Click the peek to flick that card to the front.', + color: '#111827', + }, + { + id: 'details', + title: 'Details', + body: + 'Peek from any edge — top, bottom, left, or right. Peek size and animation duration/easing are all configurable.', + color: '#1e3a8a', + }, + { + id: 'stats', + title: 'Stats', + body: + 'Cards are React children keyed by id. Controlled or uncontrolled front card, plus optional swipe-to-dismiss for tip-style flows.', + color: '#065f46', + }, + { + id: 'credits', + title: 'Credits', + body: + "Built on a single CSS grid cell with transforms — no runtime deps, no layout thrash. Style the cards however you'd like.", + color: '#7c2d12', + }, +]; + +export default function FlickDeckDemo() { + const [peek, setPeek] = useState('bottom'); + const [peekSize, setPeekSize] = useState(28); + const [depthFade, setDepthFade] = useState(0.1); + const [hoverPeek, setHoverPeek] = useState(12); + const [swipe, setSwipe] = useState(false); + const [duration, setDuration] = useState(320); + const [tips, setTips] = useState(INITIAL_TIPS); + const [frontId, setFrontId] = useState('overview'); + + const reset = () => { + setTips(INITIAL_TIPS); + setFrontId('overview'); + }; + + return ( + <> +
+ Peek edge +
+ {(['top', 'bottom', 'left', 'right'] as FlickDeckPeek[]).map((edge) => ( + + ))} +
+
+ +
+ Peek size +
+ setPeekSize(parseInt(e.target.value, 10))} + /> + + {peekSize}px + +
+
+ +
+ Depth fade +
+ setDepthFade(parseFloat(e.target.value))} + /> + + {depthFade.toFixed(2)}/depth + +
+
+ +
+ Hover peek +
+ setHoverPeek(parseInt(e.target.value, 10))} + /> + + {hoverPeek}px + +
+
+ +
+ Animation +
+ setDuration(parseInt(e.target.value, 10))} + /> + + {duration}ms + +
+
+ +
+ Swipe to dismiss +
+ + )} +
+
+ +
+ {tips.length === 0 ? ( +
+ Deck empty. . +
+ ) : ( + t.id === frontId) ? frontId : tips[0]?.id} + peek={peek} + peekSize={peekSize} + depthFade={depthFade} + hoverPeek={hoverPeek} + swipeToDismiss={swipe} + animation={{ duration }} + style={{ width: 300 }} + on={{ + frontChange: setFrontId, + dismiss: (id) => setTips((cs) => cs.filter((c) => c.id !== id)), + }} + > + {tips.map((tip) => ( +
+
+
+ {tip.id} +
+
+ {tip.title} +
+
+
+ {tip.body} +
+
+ ))} +
+ )} +
+ +
+ Hover a peeking card — it nudges out a little further and lifts to full + opacity as a click affordance. Click it to flick it forward. Flip the + peek edge and size above to reshape the deck: top/bottom cards recede + into depth, left/right cards fan out at an angle, and further-back + cards fade. Toggle swipe-to-dismiss and drag the front card in the + direction opposite the peek to remove it past the threshold. +
+ + ); +} diff --git a/demo/src/components/islands/Icons.tsx b/demo/src/components/islands/Icons.tsx index 05665da..4ca21ef 100644 --- a/demo/src/components/islands/Icons.tsx +++ b/demo/src/components/islands/Icons.tsx @@ -70,6 +70,18 @@ export function ZoomLensIcon({ size = 16, strokeWidth = 2 }: IconProps) { ); } +export function FlickDeckIcon({ size = 16, strokeWidth = 2 }: IconProps) { + return ( + + ); +} + export function ChevronDownIcon({ size = 14 }: { size?: number }) { return (
...
+
...
+ + +// Swipe the front card off to dismiss it. The component fires +// on.dismiss(id) — the consumer removes that child from children. +const [cards, setCards] = useState([ + { id: 'a', body: 'Tip one' }, + { id: 'b', body: 'Tip two' }, + { id: 'c', body: 'Tip three' }, +]); + + setCards((cs) => cs.filter((c) => c.id !== id)) }} +> + {cards.map((c) => ( + {c.body} + ))} + + +// Snappier motion: + + ... + + +// Kill the depth cues and get a flat stack (every card same size, no fade): + + ... + + +// Dial the affordance up — deeper fade, bigger hover-peek: + + ... +`; + +const flickDeckTypes = `type FlickDeckPeek = 'top' | 'bottom' | 'left' | 'right'; + +interface FlickDeckProps { + // Controlled / uncontrolled front card. The id is the child's React \`key\`. + frontId?: string; + defaultFrontId?: string; + + // Which edge the back cards peek from, and how much of each is visible. + peek?: FlickDeckPeek; // default 'bottom' + peekSize?: number; // default 24 (px) + + // Back cards shrink along the peek axis (top/bottom), fan out at an angle + // on the peek axis (left/right). Set either to 0 for a flat stack. + depthScale?: number; // default 0.05 (5% smaller per depth level) + fanAngle?: number; // default 4 (degrees per depth level) + + // Further-back cards fade. Hovered/focused back card peeks out a little + // more and snaps to full opacity as a click affordance. Set to 0 to disable. + depthFade?: number; // default 0.08 (opacity per depth level) + hoverPeek?: number; // default 8 (extra px on hover/focus) + + // When true, the front card can be dragged off in the direction opposite + // of \`peek\` to fire \`on.dismiss\`. Off by default. + swipeToDismiss?: boolean; + dismissThreshold?: number; // fraction of card axis — default 0.3 + + animation?: { + duration?: number; // default 320 (ms) + easing?: string; // default 'cubic-bezier(0.22, 1, 0.36, 1)' + }; + + on?: { + frontChange?: (id: string) => void; + dismiss?: (id: string) => void; + }; + + className?: string; + style?: CSSProperties; + cardClassName?: string; + cardStyle?: CSSProperties; + + // Each child must have a unique \`key\` — that key is the card's id. + children?: ReactNode; +}`; + +export const flickDeckMeta: ComponentMeta = { + key: 'flickdeck', + slug: 'flick-deck', + title: 'FlickDeck', + tagline: + 'A stack of cards where each back card peeks from one edge — receding into depth for top/bottom peek, fanning out at an angle for left/right. Click the peek to flick that card to the front, or optionally swipe the front card off to dismiss it. Useful for toggles between views, tip stacks, and side-by-side comparisons.', + metaDescription: + 'FlickDeck — a stacked card component for React. Back cards peek from a configurable edge, click the peek to flick it forward with a smooth transition, optional swipe-to-dismiss. Controlled or uncontrolled, unstyled, zero runtime deps.', + apiRows: [ + { prop: 'frontId', typeHtml: 'string', defaultHtml: '—', descriptionHtml: "Controlled id of the front card — matches a child's React key. Omit for uncontrolled." }, + { prop: 'defaultFrontId', typeHtml: 'string', defaultHtml: '—', descriptionHtml: "Uncontrolled initial front card id. Falls back to the first child's key if unset." }, + { prop: 'peek', typeHtml: "'top' | 'bottom' | 'left' | 'right'", defaultHtml: "'bottom'", descriptionHtml: 'Which edge the back cards peek from.' }, + { prop: 'peekSize', typeHtml: 'number', defaultHtml: '24', descriptionHtml: 'Pixels of each back card that remain visible behind the card in front of it.' }, + { prop: 'depthScale', typeHtml: 'number', defaultHtml: '0.05', descriptionHtml: 'How much each back card shrinks per depth level, for top/bottom peek — makes the stack feel recessed. Set to 0 for a flat stack.' }, + { prop: 'fanAngle', typeHtml: 'number', defaultHtml: '4', descriptionHtml: 'Degrees each back card rotates per depth level, for left/right peek — makes the stack fan out at an angle. Set to 0 for a flat stack.' }, + { prop: 'depthFade', typeHtml: 'number', defaultHtml: '0.08', descriptionHtml: 'Opacity subtracted per depth level — further-back cards look more distant. Clamped so no card drops below 0.25. Set to 0 to disable.' }, + { prop: 'hoverPeek', typeHtml: 'number', defaultHtml: '8', descriptionHtml: 'Extra pixels a back card translates out along the peek axis when it is hovered or keyboard-focused. Opacity also snaps back to 1 during the hover to signal clickability. Set to 0 to disable.' }, + { prop: 'swipeToDismiss', typeHtml: 'boolean', defaultHtml: 'false', descriptionHtml: 'When true, the front card can be pointer-dragged off in the direction opposite the peek to fire on.dismiss.' }, + { prop: 'dismissThreshold', typeHtml: 'number', defaultHtml: '0.3', descriptionHtml: "Fraction of the card's axis size the drag must cross to count as a dismiss. Values are clamped to [0, 1]." }, + { prop: 'animation', typeHtml: 'FlickDeckAnimation', defaultHtml: '—', descriptionHtml: 'Override the transition used for the flick and swipe animations — duration (ms) and easing (CSS easing).' }, + { prop: 'animation.duration', typeHtml: 'number', defaultHtml: '320', descriptionHtml: 'Transition duration in milliseconds.' }, + { prop: 'animation.easing', typeHtml: 'string', defaultHtml: "'cubic-bezier(0.22, 1, 0.36, 1)'", descriptionHtml: 'CSS easing function applied to transform and opacity transitions.' }, + { prop: 'on', typeHtml: 'FlickDeckEvents', defaultHtml: '—', descriptionHtml: 'Event handlers: frontChange, dismiss. Both optional.' }, + { prop: 'on.frontChange', typeHtml: '(id: string) => void', defaultHtml: '—', descriptionHtml: 'Fires whenever the front card changes — click, keyboard activation, or setter.' }, + { prop: 'on.dismiss', typeHtml: '(id: string) => void', defaultHtml: '—', descriptionHtml: 'Fires when the front card is swiped past dismissThreshold. Consumer is expected to remove that child from children.' }, + { prop: 'className', typeHtml: 'string', defaultHtml: "''", descriptionHtml: 'CSS class added to the deck container.' }, + { prop: 'style', typeHtml: 'CSSProperties', defaultHtml: '—', descriptionHtml: 'Inline styles merged onto the deck container.' }, + { prop: 'cardClassName', typeHtml: 'string', defaultHtml: "''", descriptionHtml: 'CSS class added to every card wrapper.' }, + { prop: 'cardStyle', typeHtml: 'CSSProperties', defaultHtml: '—', descriptionHtml: 'Inline styles merged onto every card wrapper.' }, + { prop: 'children', typeHtml: 'ReactNode', defaultHtml: '—', descriptionHtml: "Each child must have a unique key — that key is the card's id." }, + ], + apiFootnoteHtml: + 'The deck lays its cards out in a single CSS grid cell and offsets back cards via transform, so the container auto-sizes to the largest card plus padding for the peek. Each card wrapper exposes data-flick-deck-card, data-flick-deck-front, data-flick-deck-active (hovered/focused back card), and data-flick-deck-depth so you can drive styles from CSS without re-rendering.', + codeExamples: [ + { label: 'Basic Usage', code: flickDeckBasic }, + { label: 'Configurable', code: flickDeckConfigurable }, + ], + typesCode: flickDeckTypes, +}; diff --git a/demo/src/data/types.ts b/demo/src/data/types.ts index 02641b4..1db0383 100644 --- a/demo/src/data/types.ts +++ b/demo/src/data/types.ts @@ -17,7 +17,7 @@ export type CodeExample = { }; export type ComponentMeta = { - key: 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens'; + key: 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens' | 'flickdeck'; slug: string; title: string; tagline: string; diff --git a/demo/src/layouts/Layout.astro b/demo/src/layouts/Layout.astro index 8bfbcd6..0fe25c8 100644 --- a/demo/src/layouts/Layout.astro +++ b/demo/src/layouts/Layout.astro @@ -9,7 +9,7 @@ export interface Props { path?: string; jsonLd?: Record | Record[]; /** Component to highlight in the dropdown, if any. */ - activeComponent?: 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens' | null; + activeComponent?: 'launcher' | 'dock' | 'sheet' | 'splitter' | 'inspector' | 'zoomlens' | 'flickdeck' | null; /** Per-page OG image slug under /og/ (e.g. "movable-launcher"). Defaults to "home". */ ogSlug?: string; } diff --git a/demo/src/pages/flick-deck.astro b/demo/src/pages/flick-deck.astro new file mode 100644 index 0000000..b9eba95 --- /dev/null +++ b/demo/src/pages/flick-deck.astro @@ -0,0 +1,58 @@ +--- +import Layout from '../layouts/Layout.astro'; +import WidgetHeader from '../components/WidgetHeader.astro'; +import InstallSection from '../components/InstallSection.astro'; +import ApiTable from '../components/ApiTable.astro'; +import CodeExamplesSection from '../components/CodeExamplesSection.astro'; +import GitHubCTA from '../components/GitHubCTA.astro'; +import { FlickDeckIcon } from '../components/islands/Icons'; +import FlickDeckDemo from '../components/islands/FlickDeckDemo'; +import { flickDeckMeta } from '../data/flickdeck'; + +const jsonLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareSourceCode', + name: 'react-driftkit / FlickDeck', + description: flickDeckMeta.tagline, + codeRepository: 'https://github.com/shakcho/react-drift', + programmingLanguage: 'TypeScript', + runtimePlatform: 'React 18, React 19', + url: 'https://react-driftkit.saktichourasia.dev/flick-deck', +}; +--- + + + + + + +
+ + +
+ +
+ + +
+ + + + +
diff --git a/demo/src/pages/index.astro b/demo/src/pages/index.astro index dec1729..b7ae7e8 100644 --- a/demo/src/pages/index.astro +++ b/demo/src/pages/index.astro @@ -3,7 +3,7 @@ import Layout from '../layouts/Layout.astro'; import Hero from '../components/Hero.astro'; import InstallSection from '../components/InstallSection.astro'; import GitHubCTA from '../components/GitHubCTA.astro'; -import { MoveIcon, DockGlyphIcon, SheetIcon, SplitterIcon, InspectorIcon, ZoomLensIcon } from '../components/islands/Icons'; +import { MoveIcon, DockGlyphIcon, SheetIcon, SplitterIcon, InspectorIcon, ZoomLensIcon, FlickDeckIcon } from '../components/islands/Icons'; import { allComponents } from '../data/components'; const title = 'react-driftkit — Floating UI primitives for React'; @@ -17,6 +17,7 @@ const icons: Record = { splitter: SplitterIcon, inspector: InspectorIcon, zoomlens: ZoomLensIcon, + flickdeck: FlickDeckIcon, }; const softwareJsonLd = { diff --git a/src/FlickDeck.tsx b/src/FlickDeck.tsx new file mode 100644 index 0000000..249ba42 --- /dev/null +++ b/src/FlickDeck.tsx @@ -0,0 +1,380 @@ +import { + Children, + isValidElement, + useCallback, + useMemo, + useRef, + useState, + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from 'react'; + +export type FlickDeckPeek = 'top' | 'bottom' | 'left' | 'right'; + +export interface FlickDeckEvents { + /** Fires when the front card changes (click, keyboard, or setter). */ + frontChange?: (id: string) => void; + /** Fires when the front card is swiped past the dismiss threshold. + * Consumer is expected to remove the matching child in response. */ + dismiss?: (id: string) => void; +} + +export interface FlickDeckAnimation { + /** Transition duration in ms. Default `320`. */ + duration?: number; + /** CSS easing function. Default `cubic-bezier(0.22, 1, 0.36, 1)`. */ + easing?: string; +} + +export interface FlickDeckProps { + /** Controlled id of the front card. Omit for uncontrolled. */ + frontId?: string; + /** Uncontrolled initial front card id. Defaults to the first child's key. */ + defaultFrontId?: string; + + /** Which edge the back cards peek from. Default `'bottom'`. */ + peek?: FlickDeckPeek; + /** Pixels of each back card visible behind the one in front of it. Default `24`. */ + peekSize?: number; + + /** + * How much each back card shrinks per depth level, for top/bottom peek. + * Makes the stack feel recessed. `0` disables (flat stack). Default `0.05`. + */ + depthScale?: number; + /** + * Degrees each back card rotates per depth level, for left/right peek. + * Makes the stack fan out at an angle. `0` disables (flat stack). Default `4`. + */ + fanAngle?: number; + /** + * Opacity to subtract per depth level. The front card is always fully + * opaque. Clamped so cards never fall below `0.25` opacity. `0` disables. + * Default `0.08`. + */ + depthFade?: number; + /** + * Extra pixels a back card translates out along the peek axis when it is + * hovered or keyboard-focused — a visual hint that it's clickable. Opacity + * snaps back to `1` during the hover. `0` disables. Default `8`. + */ + hoverPeek?: number; + + /** Enable pointer-drag on the front card to fire `on.dismiss`. Default `false`. */ + swipeToDismiss?: boolean; + /** Fraction of the card's axis size the drag must cross to count as a dismiss. Default `0.3`. */ + dismissThreshold?: number; + + /** Override the transition used for the flick and swipe animations. */ + animation?: FlickDeckAnimation; + + on?: FlickDeckEvents; + + /** CSS class on the deck container. */ + className?: string; + /** Inline styles merged onto the deck container. */ + style?: CSSProperties; + /** CSS class on every card wrapper. */ + cardClassName?: string; + /** Inline styles merged onto every card wrapper. */ + cardStyle?: CSSProperties; + + /** Each child must have a unique `key` — that key is the card's id. */ + children?: ReactNode; +} + +const DISMISS_DIR: Record = { + bottom: { x: 0, y: -1 }, + top: { x: 0, y: 1 }, + right: { x: -1, y: 0 }, + left: { x: 1, y: 0 }, +}; + +const PEEK_PADDING_KEY: Record< + FlickDeckPeek, + 'paddingBottom' | 'paddingTop' | 'paddingLeft' | 'paddingRight' +> = { + bottom: 'paddingBottom', + top: 'paddingTop', + left: 'paddingLeft', + right: 'paddingRight', +}; + +// Anchor each card's transform to the edge opposite the peek. For top/bottom +// this makes `scale` shrink *away from* the peek edge so the peek strip still +// equals `peekSize * depth`. For left/right it pins the rotation pivot to the +// attached side so cards fan outward. +const TRANSFORM_ORIGIN: Record = { + bottom: '50% 100%', + top: '50% 0%', + left: '100% 50%', + right: '0% 50%', +}; + +function clamp(v: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, v)); +} + +export function FlickDeck({ + frontId: frontIdProp, + defaultFrontId, + peek = 'bottom', + peekSize = 24, + depthScale = 0.05, + fanAngle = 4, + depthFade = 0.08, + hoverPeek = 8, + swipeToDismiss = false, + dismissThreshold = 0.3, + animation, + on, + className = '', + style, + cardClassName = '', + cardStyle, + children, +}: FlickDeckProps) { + const duration = animation?.duration ?? 320; + const easing = animation?.easing ?? 'cubic-bezier(0.22, 1, 0.36, 1)'; + const { frontChange: onFrontChange, dismiss: onDismiss } = on ?? {}; + + // Collect children that carry a stable key — the key is the card id. + const cards = useMemo(() => { + const out: { id: string; node: ReactNode }[] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + if (child.key == null) return; + out.push({ id: String(child.key), node: child }); + }); + return out; + }, [children]); + + const isControlled = frontIdProp !== undefined; + const [uncontrolledFrontId, setUncontrolledFrontId] = useState( + defaultFrontId + ); + + const effectiveFrontId = useMemo(() => { + const candidate = isControlled ? frontIdProp : uncontrolledFrontId; + if (candidate && cards.some((c) => c.id === candidate)) return candidate; + return cards[0]?.id; + }, [isControlled, frontIdProp, uncontrolledFrontId, cards]); + + const setFront = useCallback( + (id: string) => { + if (!isControlled) setUncontrolledFrontId(id); + onFrontChange?.(id); + }, + [isControlled, onFrontChange] + ); + + // Depths: front → 0, others numbered 1..N-1 in source order. + const depthById = useMemo(() => { + const map = new Map(); + let next = 1; + for (const c of cards) { + if (c.id === effectiveFrontId) map.set(c.id, 0); + else map.set(c.id, next++); + } + return map; + }, [cards, effectiveFrontId]); + + // Swipe-to-dismiss state. + const [dragProgress, setDragProgress] = useState(0); + const [dragging, setDragging] = useState(false); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const [dismissingId, setDismissingId] = useState(null); + + // Which back card is currently hovered/focused — used to drive the + // hover-peek affordance. Only non-front cards ever get set here. + const [activeId, setActiveId] = useState(null); + const activateBack = (id: string) => setActiveId(id); + const deactivateBack = (id: string) => + setActiveId((curr) => (curr === id ? null : curr)); + + const dismissDir = DISMISS_DIR[peek]; + + const handleBackCardActivate = (id: string) => { + if (id !== effectiveFrontId) setFront(id); + }; + + const handleBackKeyDown = (e: ReactKeyboardEvent, id: string) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setFront(id); + } + }; + + const handleFrontPointerDown = (e: ReactPointerEvent) => { + if (!swipeToDismiss) return; + if (e.pointerType === 'mouse' && e.button !== 0) return; + e.currentTarget.setPointerCapture(e.pointerId); + dragStartRef.current = { x: e.clientX, y: e.clientY }; + setDragging(true); + }; + + const handleFrontPointerMove = (e: ReactPointerEvent) => { + const start = dragStartRef.current; + if (!start) return; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + const projected = dx * dismissDir.x + dy * dismissDir.y; + setDragProgress(Math.max(0, projected)); + }; + + const handleFrontPointerUp = (e: ReactPointerEvent) => { + const start = dragStartRef.current; + if (!start) return; + dragStartRef.current = null; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + // pointer already released — ignore + } + + const el = e.currentTarget; + const axisSize = dismissDir.x !== 0 ? el.offsetWidth : el.offsetHeight; + const thresholdPx = axisSize * clamp(dismissThreshold, 0, 1); + + setDragging(false); + + if (dragProgress > thresholdPx && effectiveFrontId != null) { + const id = effectiveFrontId; + setDismissingId(id); + window.setTimeout(() => { + onDismiss?.(id); + setDismissingId(null); + setDragProgress(0); + }, duration); + } else { + setDragProgress(0); + } + }; + + const peekPad = Math.max(0, (cards.length - 1) * peekSize); + const paddingStyle: CSSProperties = {}; + paddingStyle[PEEK_PADDING_KEY[peek]] = peekPad; + + return ( +
+ {cards.map((card) => { + const depth = depthById.get(card.id) ?? 0; + const isFront = depth === 0; + const isDismissing = dismissingId === card.id; + const isActive = !isFront && !isDismissing && activeId === card.id; + + let tx = 0; + let ty = 0; + const baseOffset = depth * peekSize; + if (peek === 'bottom') ty = baseOffset; + else if (peek === 'top') ty = -baseOffset; + else if (peek === 'right') tx = baseOffset; + else if (peek === 'left') tx = -baseOffset; + + // Hover/focus "peek-a-little-more" nudge along the peek axis. + if (isActive && hoverPeek > 0) { + if (peek === 'bottom') ty += hoverPeek; + else if (peek === 'top') ty -= hoverPeek; + else if (peek === 'right') tx += hoverPeek; + else if (peek === 'left') tx -= hoverPeek; + } + + // Depth treatment: top/bottom peek shrinks back cards (receding), while + // left/right peek rotates them (fanning). Only applied to non-front, + // non-dismissing cards. + const onVerticalAxis = peek === 'top' || peek === 'bottom'; + const scale = onVerticalAxis ? Math.max(0.3, 1 - depth * depthScale) : 1; + const rotateSign = peek === 'right' ? 1 : peek === 'left' ? -1 : 0; + const rotate = rotateSign * depth * fanAngle; + + if (isFront && swipeToDismiss) { + if (isDismissing) { + const offScreen = 720; + tx += dismissDir.x * offScreen; + ty += dismissDir.y * offScreen; + } else if (dragProgress > 0) { + tx += dismissDir.x * dragProgress; + ty += dismissDir.y * dragProgress; + } + } + + const transformParts = [`translate3d(${tx}px, ${ty}px, 0)`]; + if (scale !== 1) transformParts.push(`scale(${scale})`); + if (rotate !== 0) transformParts.push(`rotate(${rotate}deg)`); + const transform = transformParts.join(' '); + + // While the front card is actively being dragged, remove transition so + // it tracks the pointer 1:1. Everything else (flick to front, release, + // dismiss animation) keeps the transition on. + const tracking = isFront && dragging && !isDismissing; + const transition = tracking + ? 'none' + : `transform ${duration}ms ${easing}, opacity ${duration}ms ${easing}`; + + const zIndex = cards.length - depth; + const depthOpacity = isFront ? 1 : Math.max(0.25, 1 - depth * depthFade); + const opacity = isDismissing ? 0 : isActive ? 1 : depthOpacity; + + const interactive = !isFront; + const swipeable = isFront && swipeToDismiss; + + return ( +
handleBackCardActivate(card.id) : undefined} + onKeyDown={interactive ? (e) => handleBackKeyDown(e, card.id) : undefined} + onMouseEnter={interactive ? () => activateBack(card.id) : undefined} + onMouseLeave={interactive ? () => deactivateBack(card.id) : undefined} + onFocus={interactive ? () => activateBack(card.id) : undefined} + onBlur={interactive ? () => deactivateBack(card.id) : undefined} + onPointerDown={swipeable ? handleFrontPointerDown : undefined} + onPointerMove={swipeable ? handleFrontPointerMove : undefined} + onPointerUp={swipeable ? handleFrontPointerUp : undefined} + onPointerCancel={swipeable ? handleFrontPointerUp : undefined} + style={{ + gridArea: 'stack', + transform, + transformOrigin: TRANSFORM_ORIGIN[peek], + transition, + zIndex, + opacity, + willChange: 'transform', + cursor: interactive + ? 'pointer' + : swipeable + ? dragging + ? 'grabbing' + : 'grab' + : undefined, + touchAction: swipeable ? 'none' : undefined, + userSelect: swipeable ? 'none' : undefined, + ...cardStyle, + }} + > + {card.node} +
+ ); + })} +
+ ); +} diff --git a/src/index.ts b/src/index.ts index 9848e06..30fcb7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,10 @@ export { InspectorBubble } from './InspectorBubble'; export type { InspectorBubbleProps, InspectorBubbleColors, ElementInfo } from './InspectorBubble'; export { ZoomLens } from './ZoomLens'; export type { ZoomLensProps, ZoomLensBehavior, ZoomLensEvents, ZoomLensTarget } from './ZoomLens'; +export { FlickDeck } from './FlickDeck'; +export type { + FlickDeckProps, + FlickDeckPeek, + FlickDeckEvents, + FlickDeckAnimation, +} from './FlickDeck';