@flamingo-stack/openframe-frontend-core 0.0.449 → 0.0.450
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/{chunk-DJP5HYFG.cjs → chunk-26JSJMMZ.cjs} +7 -7
- package/dist/{chunk-DJP5HYFG.cjs.map → chunk-26JSJMMZ.cjs.map} +1 -1
- package/dist/{chunk-NPBTNWRA.js → chunk-3ITY2LBM.js} +4 -4
- package/dist/{chunk-MWRFPLNB.js → chunk-4SZDO3TE.js} +3 -3
- package/dist/{chunk-LWT6G75Q.js → chunk-4VSTLJDO.js} +2 -2
- package/dist/{chunk-DRKQNXSZ.cjs → chunk-5HW5HMNF.cjs} +5 -5
- package/dist/{chunk-DRKQNXSZ.cjs.map → chunk-5HW5HMNF.cjs.map} +1 -1
- package/dist/{chunk-KKJRBMG3.cjs → chunk-5OY7HYII.cjs} +73 -73
- package/dist/{chunk-KKJRBMG3.cjs.map → chunk-5OY7HYII.cjs.map} +1 -1
- package/dist/{chunk-WYPROXL4.cjs → chunk-6GJKSYM2.cjs} +73 -73
- package/dist/{chunk-WYPROXL4.cjs.map → chunk-6GJKSYM2.cjs.map} +1 -1
- package/dist/{chunk-6TS4AIDX.cjs → chunk-6RA4NRBA.cjs} +15 -15
- package/dist/{chunk-6TS4AIDX.cjs.map → chunk-6RA4NRBA.cjs.map} +1 -1
- package/dist/{chunk-X754WDWO.cjs → chunk-76EN3GEH.cjs} +40 -40
- package/dist/{chunk-X754WDWO.cjs.map → chunk-76EN3GEH.cjs.map} +1 -1
- package/dist/{chunk-YSNMV53O.js → chunk-CM5XKHHS.js} +6 -6
- package/dist/{chunk-47AMTR2S.cjs → chunk-DQ5U6DCR.cjs} +18 -18
- package/dist/{chunk-47AMTR2S.cjs.map → chunk-DQ5U6DCR.cjs.map} +1 -1
- package/dist/{chunk-YHNBJ3F3.js → chunk-EQWE64RJ.js} +2 -2
- package/dist/{chunk-5SJGFLL2.js → chunk-HXCQHMS5.js} +6386 -6032
- package/dist/chunk-HXCQHMS5.js.map +1 -0
- package/dist/{chunk-VSWFYFIG.js → chunk-HYIHXXF3.js} +5 -5
- package/dist/{chunk-AZKVNRZP.cjs → chunk-J5BYYZVM.cjs} +32 -32
- package/dist/{chunk-AZKVNRZP.cjs.map → chunk-J5BYYZVM.cjs.map} +1 -1
- package/dist/{chunk-JAASDJOF.js → chunk-K3GXIGN6.js} +5 -5
- package/dist/{chunk-UNQVTGVG.js → chunk-M3AKWFAO.js} +2 -2
- package/dist/{chunk-TKGCTDQY.js → chunk-MLFFBQ5B.js} +180 -87
- package/dist/chunk-MLFFBQ5B.js.map +1 -0
- package/dist/{chunk-XLFAFH2B.js → chunk-N53FC732.js} +2 -2
- package/dist/{chunk-H56AEHFC.cjs → chunk-N5M72LJX.cjs} +1620 -1266
- package/dist/chunk-N5M72LJX.cjs.map +1 -0
- package/dist/{chunk-4IMXFDRI.js → chunk-PCW2TRZ4.js} +3 -3
- package/dist/{chunk-AN7MCJTV.cjs → chunk-QF3NQW7C.cjs} +122 -29
- package/dist/chunk-QF3NQW7C.cjs.map +1 -0
- package/dist/{chunk-EOFENGSZ.cjs → chunk-ROBGJH52.cjs} +12 -12
- package/dist/{chunk-EOFENGSZ.cjs.map → chunk-ROBGJH52.cjs.map} +1 -1
- package/dist/{chunk-HQ7S6JG5.js → chunk-RQHHACP2.js} +3 -3
- package/dist/{chunk-ROCJMSJI.cjs → chunk-UPRY4I63.cjs} +3 -3
- package/dist/{chunk-ROCJMSJI.cjs.map → chunk-UPRY4I63.cjs.map} +1 -1
- package/dist/{chunk-LQTX7EJS.cjs → chunk-VLDZ4363.cjs} +29 -29
- package/dist/{chunk-LQTX7EJS.cjs.map → chunk-VLDZ4363.cjs.map} +1 -1
- package/dist/{chunk-BSBWBQ7M.js → chunk-VUDZOCPW.js} +6 -6
- package/dist/{chunk-4S4RR7XC.cjs → chunk-XITG5ICQ.cjs} +40 -40
- package/dist/{chunk-4S4RR7XC.cjs.map → chunk-XITG5ICQ.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/chat-quick-action-row.d.ts +26 -1
- package/dist/components/chat/chat-quick-action-row.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +15 -3
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +14 -2
- package/dist/components/chat/quick-action-chip.d.ts +45 -4
- package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
- package/dist/components/chat/quick-action-marquee.d.ts +24 -9
- package/dist/components/chat/quick-action-marquee.d.ts.map +1 -1
- package/dist/components/chat/quick-action-wall.d.ts +96 -0
- package/dist/components/chat/quick-action-wall.d.ts.map +1 -0
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/content-loading-container.d.ts +1 -1
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/form.d.ts +1 -1
- package/dist/components/form.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +159 -135
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +35 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +27 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +26 -2
- package/dist/components/ui/marquee-wall.d.ts +159 -0
- package/dist/components/ui/marquee-wall.d.ts.map +1 -0
- package/dist/hooks/index.cjs +6 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +5 -1
- package/dist/hooks/ui/index.d.ts +2 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-marquee-engine.d.ts +73 -0
- package/dist/hooks/ui/use-marquee-engine.d.ts.map +1 -0
- package/dist/hooks/ui/use-suppress-clone-focus.d.ts +24 -0
- package/dist/hooks/ui/use-suppress-clone-focus.d.ts.map +1 -0
- package/dist/index.cjs +31 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +30 -2
- package/dist/tailwind.config.cjs +1 -8
- package/dist/tailwind.config.cjs.map +1 -1
- package/dist/tailwind.config.js +1 -8
- package/dist/tailwind.config.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-quick-action-row.tsx +47 -3
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/quick-action-chip.tsx +81 -13
- package/src/components/chat/quick-action-marquee.tsx +54 -47
- package/src/components/chat/quick-action-wall.tsx +247 -0
- package/src/components/features/cards-strip.tsx +88 -158
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/marquee-wall.tsx +522 -0
- package/src/hooks/ui/index.ts +2 -0
- package/src/hooks/ui/use-marquee-engine.ts +158 -0
- package/src/hooks/ui/use-suppress-clone-focus.ts +61 -0
- package/src/stories/MarqueeWall.stories.tsx +132 -0
- package/src/stories/QuickActionMarquee.stories.tsx +8 -7
- package/src/stories/QuickActionWall.stories.tsx +116 -0
- package/tailwind.config.ts +0 -7
- package/dist/chunk-5SJGFLL2.js.map +0 -1
- package/dist/chunk-AN7MCJTV.cjs.map +0 -1
- package/dist/chunk-H56AEHFC.cjs.map +0 -1
- package/dist/chunk-TKGCTDQY.js.map +0 -1
- /package/dist/{chunk-NPBTNWRA.js.map → chunk-3ITY2LBM.js.map} +0 -0
- /package/dist/{chunk-MWRFPLNB.js.map → chunk-4SZDO3TE.js.map} +0 -0
- /package/dist/{chunk-LWT6G75Q.js.map → chunk-4VSTLJDO.js.map} +0 -0
- /package/dist/{chunk-YSNMV53O.js.map → chunk-CM5XKHHS.js.map} +0 -0
- /package/dist/{chunk-YHNBJ3F3.js.map → chunk-EQWE64RJ.js.map} +0 -0
- /package/dist/{chunk-VSWFYFIG.js.map → chunk-HYIHXXF3.js.map} +0 -0
- /package/dist/{chunk-JAASDJOF.js.map → chunk-K3GXIGN6.js.map} +0 -0
- /package/dist/{chunk-UNQVTGVG.js.map → chunk-M3AKWFAO.js.map} +0 -0
- /package/dist/{chunk-XLFAFH2B.js.map → chunk-N53FC732.js.map} +0 -0
- /package/dist/{chunk-4IMXFDRI.js.map → chunk-PCW2TRZ4.js.map} +0 -0
- /package/dist/{chunk-HQ7S6JG5.js.map → chunk-RQHHACP2.js.map} +0 -0
- /package/dist/{chunk-BSBWBQ7M.js.map → chunk-VUDZOCPW.js.map} +0 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../utils/cn'
|
|
5
|
+
import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine'
|
|
6
|
+
import { useMediaQuery } from '../../hooks/ui/use-media-query'
|
|
7
|
+
import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus'
|
|
8
|
+
import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport'
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export type MarqueeWallFadeEdge = 'top' | 'bottom' | 'left' | 'right'
|
|
15
|
+
|
|
16
|
+
/** One spelling of the `fade` prop's edge-list normalization. */
|
|
17
|
+
function normalizeFades(
|
|
18
|
+
fade: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge> | undefined,
|
|
19
|
+
): ReadonlyArray<MarqueeWallFadeEdge> {
|
|
20
|
+
return fade ? (Array.isArray(fade) ? fade : [fade as MarqueeWallFadeEdge]) : []
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @internal per-instance sync membership (transform writer + driver-role
|
|
24
|
+
* promotion callback, so a surviving member can be re-elected). */
|
|
25
|
+
interface MarqueeSyncMember {
|
|
26
|
+
apply: (pos: number) => void
|
|
27
|
+
makeDriver: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pairs two (or more) <MarqueeWall> instances to ONE position driver so their
|
|
32
|
+
* tracks stay pixel-locked (the FreeTrialCTA resolve strips: a pending and a
|
|
33
|
+
* resolved copy of the same chips, clip-path'd at the avatar line). The first
|
|
34
|
+
* mounted instance claims the driver role and runs the shared rAF; the others
|
|
35
|
+
* only register their `apply` transforms. If the driver unmounts while
|
|
36
|
+
* members survive, the next member is promoted — the pair must merely be
|
|
37
|
+
* geometry-identical while mounted together.
|
|
38
|
+
*/
|
|
39
|
+
export interface MarqueeSyncController {
|
|
40
|
+
/** @internal registered members in mount order. */
|
|
41
|
+
members: Set<MarqueeSyncMember>
|
|
42
|
+
/** @internal current driver member. */
|
|
43
|
+
driver: MarqueeSyncMember | null
|
|
44
|
+
/** @internal last driver-written position — a promoted driver's engine
|
|
45
|
+
* seeds from it, so re-election never snaps the pair back to 0. */
|
|
46
|
+
position: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useMarqueeSync(): MarqueeSyncController {
|
|
50
|
+
return React.useRef<MarqueeSyncController>({ members: new Set(), driver: null, position: 0 }).current
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// FLIP coordination — live track offsets + loop-clone helpers
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
export interface MarqueeTrackOffset {
|
|
58
|
+
/** Applied translation (px) along each axis. */
|
|
59
|
+
x: number
|
|
60
|
+
y: number
|
|
61
|
+
/** Loop wrap size (single copy + seam gap) along each axis; 0 on the
|
|
62
|
+
* non-scroll axis. Lets morph engines treat a chip and its loop clone as
|
|
63
|
+
* ONE identity `wrap` apart (the Swiper loop-clone model) and fly the
|
|
64
|
+
* copy the viewer actually sees. */
|
|
65
|
+
wrapX: number
|
|
66
|
+
wrapY: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Registry of each named wall's CURRENT applied translation, written every
|
|
71
|
+
* engine frame and deliberately kept after unmount: a FLIP-across-remounts
|
|
72
|
+
* morph (deck `useFlipMorph`) captures element positions at COMMIT time, so
|
|
73
|
+
* chips inside a drifting marquee would fly from stale origins. With a
|
|
74
|
+
* `trackId` the wall stamps `data-marquee-track` on its container and
|
|
75
|
+
* publishes its offset here; the morph engine corrects captured points by
|
|
76
|
+
* the wall's drift since capture (including drift accrued right up to the
|
|
77
|
+
* source wall's unmount — hence the survive-unmount map).
|
|
78
|
+
*/
|
|
79
|
+
const MARQUEE_TRACK_OFFSETS = new Map<string, MarqueeTrackOffset>()
|
|
80
|
+
|
|
81
|
+
const ZERO_OFFSET: MarqueeTrackOffset = { x: 0, y: 0, wrapX: 0, wrapY: 0 }
|
|
82
|
+
|
|
83
|
+
/** Last known translation + wrap of the wall registered under `trackId`
|
|
84
|
+
* (live or already unmounted). Zeros for unknown ids / static walls. */
|
|
85
|
+
export function getMarqueeTrackOffset(trackId: string): MarqueeTrackOffset {
|
|
86
|
+
return MARQUEE_TRACK_OFFSETS.get(trackId) ?? ZERO_OFFSET
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The translation ACTUALLY applied to a wall's track right now, read from the
|
|
91
|
+
* DOM of the `data-marquee-track` container. Morph engines use this on the
|
|
92
|
+
* CAPTURE side (never the registry): a same-id wall freshly remounted in the
|
|
93
|
+
* current commit has not applied any transform yet, while the registry still
|
|
94
|
+
* holds its predecessor's final drift for the flight-side lookup.
|
|
95
|
+
*/
|
|
96
|
+
export function getMarqueeAppliedOffset(trackContainer: Element): { x: number; y: number } {
|
|
97
|
+
const track = trackContainer.firstElementChild
|
|
98
|
+
const t = track ? getComputedStyle(track).transform : 'none'
|
|
99
|
+
if (!t || t === 'none') return { x: 0, y: 0 }
|
|
100
|
+
const m = new DOMMatrixReadOnly(t)
|
|
101
|
+
return { x: m.e, y: m.f }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The loop-clone twin of an element inside a wall's PRIMARY copy (same index
|
|
106
|
+
* in the `data-marquee-copy="clone"` copy), or null when there is no clone /
|
|
107
|
+
* the element isn't in the primary copy. Owns the wall's internal DOM shape
|
|
108
|
+
* so morph engines don't hardcode it; `selector` scopes which descendants
|
|
109
|
+
* count as items (default: direct structure-preserving lookup by class is the
|
|
110
|
+
* caller's job — pass the item selector, e.g. `'.flipchip'`).
|
|
111
|
+
*/
|
|
112
|
+
export function getMarqueeCloneTwin(
|
|
113
|
+
trackContainer: Element,
|
|
114
|
+
el: Element,
|
|
115
|
+
selector: string,
|
|
116
|
+
): HTMLElement | null {
|
|
117
|
+
const primary = trackContainer.querySelector('[data-marquee-copy="primary"]')
|
|
118
|
+
const clone = trackContainer.querySelector('[data-marquee-copy="clone"]')
|
|
119
|
+
if (!primary || !clone || !primary.contains(el)) return null
|
|
120
|
+
const idx = Array.prototype.indexOf.call(primary.querySelectorAll(selector), el)
|
|
121
|
+
if (idx < 0) return null
|
|
122
|
+
return (clone.querySelectorAll(selector)[idx] as HTMLElement | undefined) ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface MarqueeWallProps {
|
|
126
|
+
/**
|
|
127
|
+
* Presentation mode — the consumer's one switch for "do I want the
|
|
128
|
+
* blur + marquee here at all":
|
|
129
|
+
* - `'animated'` (default): endless marquee when content overflows, with
|
|
130
|
+
* the overflow fades.
|
|
131
|
+
* - `'plain'`: no motion, no fades — a plain clipped wall. All marquee
|
|
132
|
+
* machinery (engine, clone copy, fades) is skipped entirely.
|
|
133
|
+
*/
|
|
134
|
+
mode?: 'animated' | 'plain'
|
|
135
|
+
/**
|
|
136
|
+
* Marquee axis. Defaults to where the fade sits — the animation's job is to
|
|
137
|
+
* reveal what the fade hides: a `bottom`/`top` fade clips rows → vertical
|
|
138
|
+
* (content travels bottom→top); a `left`/`right`-only fade clips columns →
|
|
139
|
+
* horizontal (content travels right→left). No fade → horizontal.
|
|
140
|
+
*/
|
|
141
|
+
axis?: 'x' | 'y'
|
|
142
|
+
/** Reverse travel: `x` reversed moves content right, `y` reversed moves it
|
|
143
|
+
* down. Default false (left / up). */
|
|
144
|
+
reverse?: boolean
|
|
145
|
+
/** Scroll speed in px/s (the CardsStrip unit — one speed language across
|
|
146
|
+
* every marquee). Default 40: calm chip-wall drift, slower than the 60 the
|
|
147
|
+
* card strips cruise at. */
|
|
148
|
+
speed?: number
|
|
149
|
+
/** Master switch. Even when true the marquee auto-disables while the content
|
|
150
|
+
* fits the container and under `prefers-reduced-motion` (static wall). */
|
|
151
|
+
animate?: boolean
|
|
152
|
+
/** Freeze while a hover-capable pointer is anywhere over the wall — content
|
|
153
|
+
* must never move under a pointing cursor (required for interactive chip
|
|
154
|
+
* walls). Default true. */
|
|
155
|
+
pauseOnHover?: boolean
|
|
156
|
+
/** Edge fade(s) — the "there's more" affordance. ONE spelling of the
|
|
157
|
+
* clip-and-fade across every wall surface. */
|
|
158
|
+
fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
|
|
159
|
+
/** The color the fades blend INTO — must match the surface behind the wall
|
|
160
|
+
* or the gradient paints a visible box. Default the page background. */
|
|
161
|
+
fadeColor?: string
|
|
162
|
+
/** Fade thickness: one value for all edges or a per-edge map. Defaults:
|
|
163
|
+
* 40px vertical edges, 112px horizontal edges (the shipped hero-wall
|
|
164
|
+
* geometry). */
|
|
165
|
+
fadeSize?: number | string | Partial<Record<MarqueeWallFadeEdge, number | string>>
|
|
166
|
+
/** Gap between the two track copies — match the content's own item gap so
|
|
167
|
+
* the seam is invisible. A CSS length string (e.g. `var(--tlcl-chipgap)`)
|
|
168
|
+
* lets responsive CSS own the value; the true seam distance is MEASURED
|
|
169
|
+
* from the rendered copies either way. Default 8. */
|
|
170
|
+
copyGap?: number | string
|
|
171
|
+
/** Outer container class — sizing lives here (height cap for `y`, width for
|
|
172
|
+
* `x`). The container is always `relative overflow-hidden`. */
|
|
173
|
+
className?: string
|
|
174
|
+
/** Class on EACH track copy — the wall's own layout (e.g. a wrapping chip
|
|
175
|
+
* row `flex flex-wrap gap-2`, or a fixed-width grid). */
|
|
176
|
+
contentClassName?: string
|
|
177
|
+
/** Inline style on EACH track copy — for values classes can't carry (e.g.
|
|
178
|
+
* an item gap driven by the same variable as `copyGap`, so chip pitch and
|
|
179
|
+
* seam pitch stay one value by construction). */
|
|
180
|
+
contentStyle?: React.CSSProperties
|
|
181
|
+
/**
|
|
182
|
+
* Wall content — ONE copy; the wall clones it for the endless loop (clone
|
|
183
|
+
* `aria-hidden` + focus-suppressed but still clickable). Pass a render
|
|
184
|
+
* callback to vary the clone copy (e.g. omit `data-flip-id`s / render
|
|
185
|
+
* decorative chips). Children must be clone-safe: ref-free, unique-DOM-id-
|
|
186
|
+
* free, mount-effect-free (same contract as <CardsStrip> children mode).
|
|
187
|
+
*/
|
|
188
|
+
children: React.ReactNode | ((ctx: { isClone: boolean }) => React.ReactNode)
|
|
189
|
+
/** Pair this wall with sibling walls under one shared position driver. */
|
|
190
|
+
sync?: MarqueeSyncController
|
|
191
|
+
/** Stable id for FLIP coordination: publishes the wall's live offset via
|
|
192
|
+
* {@link getMarqueeTrackOffset} and stamps `data-marquee-track` on the
|
|
193
|
+
* container so morph engines can drift-correct captured positions. */
|
|
194
|
+
trackId?: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// =============================================================================
|
|
198
|
+
// Internals
|
|
199
|
+
// =============================================================================
|
|
200
|
+
|
|
201
|
+
const DEFAULT_FADE_SIZE: Record<MarqueeWallFadeEdge, string> = {
|
|
202
|
+
top: '40px',
|
|
203
|
+
bottom: '40px',
|
|
204
|
+
left: '112px',
|
|
205
|
+
right: '112px',
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const FADE_POSITION: Record<MarqueeWallFadeEdge, string> = {
|
|
209
|
+
top: 'inset-x-0 top-0',
|
|
210
|
+
bottom: 'inset-x-0 bottom-0',
|
|
211
|
+
left: 'inset-y-0 left-0',
|
|
212
|
+
right: 'inset-y-0 right-0',
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The wall's edge-fade overlays — exported so composite walls (stacked row
|
|
216
|
+
* marquees) can draw ONE set of fades over the whole stack instead of
|
|
217
|
+
* per-row copies. Parent must be `relative`. Overlays sit at z-1, ABOVE
|
|
218
|
+
* FLIP flight ghosts (z-0 by contract): an in-flight chip dissolves under
|
|
219
|
+
* the blur exactly like a resting chip. */
|
|
220
|
+
export function MarqueeWallFades({
|
|
221
|
+
fade,
|
|
222
|
+
fadeColor = 'var(--color-bg)',
|
|
223
|
+
fadeSize,
|
|
224
|
+
}: {
|
|
225
|
+
fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
|
|
226
|
+
fadeColor?: string
|
|
227
|
+
fadeSize?: MarqueeWallProps['fadeSize']
|
|
228
|
+
}) {
|
|
229
|
+
return (
|
|
230
|
+
<>
|
|
231
|
+
{normalizeFades(fade).map(edge => {
|
|
232
|
+
const size =
|
|
233
|
+
(typeof fadeSize === 'object' && fadeSize !== null ? fadeSize[edge] : fadeSize) ??
|
|
234
|
+
DEFAULT_FADE_SIZE[edge]
|
|
235
|
+
const px = typeof size === 'number' ? `${size}px` : size
|
|
236
|
+
const isY = edge === 'top' || edge === 'bottom'
|
|
237
|
+
return (
|
|
238
|
+
<div
|
|
239
|
+
key={edge}
|
|
240
|
+
aria-hidden
|
|
241
|
+
// Edge marker: lets morph engines measure the fade bands when
|
|
242
|
+
// they need band-aware behavior.
|
|
243
|
+
data-marquee-fade={edge}
|
|
244
|
+
className={cn('pointer-events-none absolute z-[1]', FADE_POSITION[edge])}
|
|
245
|
+
style={{
|
|
246
|
+
[isY ? 'height' : 'width']: px,
|
|
247
|
+
backgroundImage: `linear-gradient(to ${edge}, transparent, ${fadeColor})`,
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
251
|
+
})}
|
|
252
|
+
</>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Clone track copy — aria-hidden with focusable descendants suppressed (but
|
|
257
|
+
* clickable, the shared loop-clone treatment). */
|
|
258
|
+
function CloneCopy({
|
|
259
|
+
className,
|
|
260
|
+
style,
|
|
261
|
+
children,
|
|
262
|
+
}: {
|
|
263
|
+
className?: string
|
|
264
|
+
style?: React.CSSProperties
|
|
265
|
+
children: React.ReactNode
|
|
266
|
+
}) {
|
|
267
|
+
const ref = useSuppressCloneFocus(true)
|
|
268
|
+
return (
|
|
269
|
+
<div ref={ref} aria-hidden data-marquee-copy="clone" className={className} style={style}>
|
|
270
|
+
{children}
|
|
271
|
+
</div>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// =============================================================================
|
|
276
|
+
// Component
|
|
277
|
+
// =============================================================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* <MarqueeWall> — THE endless-scroll wall container: a clipped area whose
|
|
281
|
+
* content loops as a marquee along either axis, fading out where the clip
|
|
282
|
+
* cuts. Built on the same `useMarqueeEngine` core as <CardsStrip> (the news /
|
|
283
|
+
* logo / bites strips) with a TRANSFORM driver instead of a scroller — no
|
|
284
|
+
* inner scrollable area is created, so it is safe inside surfaces that forbid
|
|
285
|
+
* nested scrollers (the deck's mobile single-fold panels).
|
|
286
|
+
*
|
|
287
|
+
* Static by circumstance, not by variant: when the content fits the container
|
|
288
|
+
* (or `prefers-reduced-motion`) the wall renders a single static copy with
|
|
289
|
+
* its fades — byte-identical to the old non-animated walls.
|
|
290
|
+
*/
|
|
291
|
+
export function MarqueeWall({
|
|
292
|
+
mode = 'animated',
|
|
293
|
+
axis: axisProp,
|
|
294
|
+
reverse = false,
|
|
295
|
+
speed = 40,
|
|
296
|
+
animate = true,
|
|
297
|
+
pauseOnHover = true,
|
|
298
|
+
fade,
|
|
299
|
+
fadeColor = 'var(--color-bg)',
|
|
300
|
+
fadeSize,
|
|
301
|
+
copyGap = 8,
|
|
302
|
+
className,
|
|
303
|
+
contentClassName,
|
|
304
|
+
contentStyle,
|
|
305
|
+
children,
|
|
306
|
+
sync,
|
|
307
|
+
trackId,
|
|
308
|
+
}: MarqueeWallProps) {
|
|
309
|
+
const fades = React.useMemo(() => normalizeFades(fade), [fade])
|
|
310
|
+
const axis = axisProp ?? (fades.includes('top') || fades.includes('bottom') ? 'y' : 'x')
|
|
311
|
+
|
|
312
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
|
313
|
+
const trackRef = React.useRef<HTMLDivElement | null>(null)
|
|
314
|
+
const copyRef = React.useRef<HTMLDivElement | null>(null)
|
|
315
|
+
|
|
316
|
+
// ---- environment -----------------------------------------------------------
|
|
317
|
+
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') ?? false
|
|
318
|
+
|
|
319
|
+
// ---- overflow + seam measurement -------------------------------------------
|
|
320
|
+
// One copy vs the container along the axis decides overflow (no overflow →
|
|
321
|
+
// nothing hidden → no marquee, no clone). The WRAP size — the loop's seam
|
|
322
|
+
// distance — is MEASURED as the offset delta between the two rendered
|
|
323
|
+
// copies, so it is exact whatever produced the gap (numeric prop or a
|
|
324
|
+
// responsive CSS variable); before the clone mounts it falls back to
|
|
325
|
+
// copy size + numeric gap, corrected on the next measure.
|
|
326
|
+
const [overflows, setOverflows] = React.useState(false)
|
|
327
|
+
const copySizeRef = React.useRef(0)
|
|
328
|
+
const wrapSizeRef = React.useRef(0)
|
|
329
|
+
const measure = React.useCallback(() => {
|
|
330
|
+
const container = containerRef.current
|
|
331
|
+
const copy = copyRef.current
|
|
332
|
+
if (!container || !copy) return
|
|
333
|
+
const size = axis === 'y' ? copy.offsetHeight : copy.offsetWidth
|
|
334
|
+
copySizeRef.current = size
|
|
335
|
+
const clone = trackRef.current?.querySelector<HTMLElement>('[data-marquee-copy="clone"]')
|
|
336
|
+
wrapSizeRef.current = clone
|
|
337
|
+
? axis === 'y'
|
|
338
|
+
? clone.offsetTop - copy.offsetTop
|
|
339
|
+
: clone.offsetLeft - copy.offsetLeft
|
|
340
|
+
: size + (typeof copyGap === 'number' ? copyGap : 0)
|
|
341
|
+
const avail = axis === 'y' ? container.clientHeight : container.clientWidth
|
|
342
|
+
setOverflows(size > avail + 1)
|
|
343
|
+
}, [axis, copyGap])
|
|
344
|
+
const marqueeActive = mode === 'animated' && animate && !reducedMotion && overflows
|
|
345
|
+
React.useEffect(() => {
|
|
346
|
+
measure()
|
|
347
|
+
const container = containerRef.current
|
|
348
|
+
const copy = copyRef.current
|
|
349
|
+
if (!container || !copy || typeof ResizeObserver === 'undefined') return
|
|
350
|
+
const ro = new ResizeObserver(measure)
|
|
351
|
+
ro.observe(container)
|
|
352
|
+
ro.observe(copy)
|
|
353
|
+
return () => ro.disconnect()
|
|
354
|
+
// marqueeActive: the clone mounts with it — re-measure for the true seam.
|
|
355
|
+
}, [measure, marqueeActive])
|
|
356
|
+
|
|
357
|
+
// Viewport gates `active` (rAF fully stops off-screen) instead of being a
|
|
358
|
+
// pause REASON: the velocity envelope persists across engine restarts, so
|
|
359
|
+
// scrolling a wall back into view resumes at cruise speed instantly — a
|
|
360
|
+
// pause-reason resume would ramp 0→speed over ~250ms, a visible
|
|
361
|
+
// stopped-then-start on every slide/section entry.
|
|
362
|
+
const [nearViewport, setNearViewport] = React.useState(true)
|
|
363
|
+
React.useEffect(() => {
|
|
364
|
+
const el = containerRef.current
|
|
365
|
+
if (!el || typeof IntersectionObserver === 'undefined') return
|
|
366
|
+
const io = new IntersectionObserver(
|
|
367
|
+
entries => setNearViewport(entries[0]?.isIntersecting ?? true),
|
|
368
|
+
{ rootMargin: NEAR_VIEWPORT_ROOT_MARGIN },
|
|
369
|
+
)
|
|
370
|
+
io.observe(el)
|
|
371
|
+
return () => io.disconnect()
|
|
372
|
+
}, [])
|
|
373
|
+
|
|
374
|
+
// ---- pause-reason set ------------------------------------------------------
|
|
375
|
+
const pointerInsideRef = React.useRef(false)
|
|
376
|
+
const onPointerEnter = React.useCallback((e: React.PointerEvent) => {
|
|
377
|
+
if (e.pointerType === 'touch') return
|
|
378
|
+
pointerInsideRef.current = true
|
|
379
|
+
}, [])
|
|
380
|
+
const onPointerLeave = React.useCallback((e: React.PointerEvent) => {
|
|
381
|
+
if (e.pointerType === 'touch') return
|
|
382
|
+
pointerInsideRef.current = false
|
|
383
|
+
}, [])
|
|
384
|
+
|
|
385
|
+
// ---- transform driver ------------------------------------------------------
|
|
386
|
+
const applyTransform = React.useCallback(
|
|
387
|
+
(pos: number) => {
|
|
388
|
+
const track = trackRef.current
|
|
389
|
+
if (!track) return
|
|
390
|
+
const wrapSize = wrapSizeRef.current
|
|
391
|
+
const t = reverse ? pos - wrapSize : -pos
|
|
392
|
+
track.style.transform = axis === 'y' ? `translate3d(0, ${t}px, 0)` : `translate3d(${t}px, 0, 0)`
|
|
393
|
+
if (trackId) {
|
|
394
|
+
MARQUEE_TRACK_OFFSETS.set(
|
|
395
|
+
trackId,
|
|
396
|
+
axis === 'y'
|
|
397
|
+
? { x: 0, y: t, wrapX: 0, wrapY: wrapSize }
|
|
398
|
+
: { x: t, y: 0, wrapX: wrapSize, wrapY: 0 },
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
[axis, reverse, trackId],
|
|
403
|
+
)
|
|
404
|
+
// Clear any residual offset when the marquee turns off (content shrank,
|
|
405
|
+
// reduced-motion flipped) — the static wall must sit at its natural origin.
|
|
406
|
+
// The engine position resets with it (below the engine call) so a later
|
|
407
|
+
// re-activation re-seeds consistently.
|
|
408
|
+
React.useEffect(() => {
|
|
409
|
+
if (marqueeActive) return
|
|
410
|
+
const track = trackRef.current
|
|
411
|
+
if (track) track.style.transform = ''
|
|
412
|
+
if (trackId) MARQUEE_TRACK_OFFSETS.set(trackId, ZERO_OFFSET)
|
|
413
|
+
}, [marqueeActive, trackId])
|
|
414
|
+
// NOTE deliberately no reset of the registry entry on mount: when a layout
|
|
415
|
+
// change remounts the SAME trackId in one commit, the morph engine must
|
|
416
|
+
// still read the OLD wall's final drift for that commit's flight origins.
|
|
417
|
+
// The new wall's first applied frame (rAF, after all layout effects)
|
|
418
|
+
// overwrites it. Capture-side reads take the translation from the DOM
|
|
419
|
+
// ({@link getMarqueeAppliedOffset}), so a stale registry value can never
|
|
420
|
+
// leak into captured points.
|
|
421
|
+
|
|
422
|
+
// ---- sync pairing (driver election by mount order, with re-election) -------
|
|
423
|
+
const [isDriver, setIsDriver] = React.useState(!sync)
|
|
424
|
+
React.useEffect(() => {
|
|
425
|
+
if (!sync) {
|
|
426
|
+
setIsDriver(true)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
const member: MarqueeSyncMember = { apply: applyTransform, makeDriver: () => setIsDriver(true) }
|
|
430
|
+
sync.members.add(member)
|
|
431
|
+
if (sync.driver === null) sync.driver = member
|
|
432
|
+
setIsDriver(sync.driver === member)
|
|
433
|
+
return () => {
|
|
434
|
+
sync.members.delete(member)
|
|
435
|
+
if (sync.driver === member) {
|
|
436
|
+
// Promote the next surviving member so the pair never goes driverless
|
|
437
|
+
// (a driverless pair would freeze — nothing runs the shared rAF).
|
|
438
|
+
const next = sync.members.values().next()
|
|
439
|
+
sync.driver = next.done ? null : next.value
|
|
440
|
+
sync.driver?.makeDriver()
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}, [sync, applyTransform])
|
|
444
|
+
|
|
445
|
+
const { posRef } = useMarqueeEngine({
|
|
446
|
+
active: marqueeActive && nearViewport && isDriver,
|
|
447
|
+
speed,
|
|
448
|
+
isPaused: () =>
|
|
449
|
+
(pauseOnHover && pointerInsideRef.current) || document.visibilityState === 'hidden',
|
|
450
|
+
getWrapSize: () => wrapSizeRef.current,
|
|
451
|
+
apply: sync
|
|
452
|
+
? pos => {
|
|
453
|
+
sync.position = pos
|
|
454
|
+
sync.members.forEach(m => m.apply(pos))
|
|
455
|
+
}
|
|
456
|
+
: applyTransform,
|
|
457
|
+
// Synced pairs: a re-elected driver resumes from the pair's live position
|
|
458
|
+
// (engine seeds from readBack on activation) instead of restarting at 0.
|
|
459
|
+
readBack: sync ? () => sync.position : undefined,
|
|
460
|
+
})
|
|
461
|
+
// REVERSE-travel continuity: an unstyled track (t = 0) corresponds to
|
|
462
|
+
// pos = wrapSize, not pos = 0 — seed the position on activation so the
|
|
463
|
+
// engine's first frame continues from the freshly-painted state instead of
|
|
464
|
+
// teleporting the track a full wrap upward. Without this, a FLIP morph
|
|
465
|
+
// capturing against the mount paint saw every chip (and every in-flight
|
|
466
|
+
// ghost target) jump a copy-height on the next frame — the "chips pour in
|
|
467
|
+
// from the top instead of flying between panels" breakage.
|
|
468
|
+
React.useLayoutEffect(() => {
|
|
469
|
+
if (!marqueeActive) {
|
|
470
|
+
posRef.current = 0
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
if (reverse && posRef.current === 0) {
|
|
474
|
+
posRef.current = Math.max(0, wrapSizeRef.current - 0.01)
|
|
475
|
+
}
|
|
476
|
+
}, [marqueeActive, reverse, posRef])
|
|
477
|
+
|
|
478
|
+
// ---- render ----------------------------------------------------------------
|
|
479
|
+
const renderContent = (isClone: boolean) =>
|
|
480
|
+
typeof children === 'function' ? children({ isClone }) : children
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<div
|
|
484
|
+
ref={containerRef}
|
|
485
|
+
className={cn('relative overflow-hidden', className)}
|
|
486
|
+
data-marquee-track={trackId}
|
|
487
|
+
onPointerEnter={onPointerEnter}
|
|
488
|
+
onPointerLeave={onPointerLeave}
|
|
489
|
+
>
|
|
490
|
+
<div
|
|
491
|
+
ref={trackRef}
|
|
492
|
+
className={cn(
|
|
493
|
+
axis === 'y' ? 'flex w-full flex-col' : 'flex w-max items-stretch',
|
|
494
|
+
marqueeActive && 'will-change-transform',
|
|
495
|
+
)}
|
|
496
|
+
style={marqueeActive ? { gap: copyGap } : undefined}
|
|
497
|
+
>
|
|
498
|
+
<div
|
|
499
|
+
ref={copyRef}
|
|
500
|
+
data-marquee-copy="primary"
|
|
501
|
+
className={cn(axis === 'x' && 'w-max shrink-0', contentClassName)}
|
|
502
|
+
style={contentStyle}
|
|
503
|
+
>
|
|
504
|
+
{renderContent(false)}
|
|
505
|
+
</div>
|
|
506
|
+
{marqueeActive && (
|
|
507
|
+
<CloneCopy className={cn(axis === 'x' && 'w-max shrink-0', contentClassName)} style={contentStyle}>
|
|
508
|
+
{renderContent(true)}
|
|
509
|
+
</CloneCopy>
|
|
510
|
+
)}
|
|
511
|
+
</div>
|
|
512
|
+
{/* A fade means "there's more": it only paints while the content
|
|
513
|
+
actually overflows the container. A wall taller than its content
|
|
514
|
+
(e.g. a flex-filled panel) must show every chip crisp — a veil over
|
|
515
|
+
real rows and empty surface reads as a rendering bug. Plain mode
|
|
516
|
+
skips fades entirely (the consumer opted out of blur + marquee). */}
|
|
517
|
+
{mode === 'animated' && overflows && (
|
|
518
|
+
<MarqueeWallFades fade={fade} fadeColor={fadeColor} fadeSize={fadeSize} />
|
|
519
|
+
)}
|
|
520
|
+
</div>
|
|
521
|
+
)
|
|
522
|
+
}
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -7,11 +7,13 @@ export * from './use-header-height'
|
|
|
7
7
|
export * from './use-horizontal-scrollbar'
|
|
8
8
|
export * from './use-image-edge-color'
|
|
9
9
|
export * from './use-local-storage'
|
|
10
|
+
export * from './use-marquee-engine'
|
|
10
11
|
export * from './use-media-query'
|
|
11
12
|
export * from './use-memoized-callback'
|
|
12
13
|
export * from './use-notification-permission'
|
|
13
14
|
export * from './use-onboarding-state'
|
|
14
15
|
export * from './use-search'
|
|
16
|
+
export * from './use-suppress-clone-focus'
|
|
15
17
|
export * from './use-table-pagination'
|
|
16
18
|
export * from './use-throttle'
|
|
17
19
|
export * from './use-window-size'
|