@flamingo-stack/openframe-frontend-core 0.0.448 → 0.0.449-snapshot.20260718143201
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-P62PIQSY.cjs → chunk-26JSJMMZ.cjs} +7 -7
- package/dist/{chunk-P62PIQSY.cjs.map → chunk-26JSJMMZ.cjs.map} +1 -1
- package/dist/{chunk-PHH7M3CF.js → chunk-3ITY2LBM.js} +4 -4
- package/dist/{chunk-AEPR6AXO.js → chunk-4SZDO3TE.js} +3 -3
- package/dist/{chunk-AMS55EES.js → chunk-4VSTLJDO.js} +2 -2
- package/dist/{chunk-7YSVW2JG.cjs → chunk-5HW5HMNF.cjs} +5 -5
- package/dist/{chunk-7YSVW2JG.cjs.map → chunk-5HW5HMNF.cjs.map} +1 -1
- package/dist/{chunk-3RM5Z2SH.cjs → chunk-5OY7HYII.cjs} +73 -73
- package/dist/{chunk-3RM5Z2SH.cjs.map → chunk-5OY7HYII.cjs.map} +1 -1
- package/dist/{chunk-X62WEO5Z.cjs → chunk-6GJKSYM2.cjs} +73 -73
- package/dist/{chunk-X62WEO5Z.cjs.map → chunk-6GJKSYM2.cjs.map} +1 -1
- package/dist/{chunk-JM5VSYQB.cjs → chunk-6RA4NRBA.cjs} +15 -15
- package/dist/{chunk-JM5VSYQB.cjs.map → chunk-6RA4NRBA.cjs.map} +1 -1
- package/dist/{chunk-TGEAT3G4.cjs → chunk-76EN3GEH.cjs} +40 -40
- package/dist/{chunk-TGEAT3G4.cjs.map → chunk-76EN3GEH.cjs.map} +1 -1
- package/dist/{chunk-CAEQ5BQO.js → chunk-CM5XKHHS.js} +6 -6
- package/dist/{chunk-HKMQ3LHY.cjs → chunk-DQ5U6DCR.cjs} +18 -18
- package/dist/{chunk-HKMQ3LHY.cjs.map → chunk-DQ5U6DCR.cjs.map} +1 -1
- package/dist/{chunk-YHNBJ3F3.js → chunk-EQWE64RJ.js} +2 -2
- package/dist/{chunk-QJN4XSP4.js → chunk-HXCQHMS5.js} +6424 -6035
- package/dist/chunk-HXCQHMS5.js.map +1 -0
- package/dist/{chunk-UAOR2SRY.js → chunk-HYIHXXF3.js} +5 -5
- package/dist/{chunk-ZGERD4OP.cjs → chunk-J5BYYZVM.cjs} +32 -32
- package/dist/{chunk-ZGERD4OP.cjs.map → chunk-J5BYYZVM.cjs.map} +1 -1
- package/dist/{chunk-KP2E22VP.js → chunk-K3GXIGN6.js} +5 -5
- package/dist/{chunk-PJ27XJRQ.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-ZRYCV52Q.js → chunk-N53FC732.js} +2 -2
- package/dist/{chunk-M2U5KN4Q.cjs → chunk-N5M72LJX.cjs} +1660 -1271
- package/dist/chunk-N5M72LJX.cjs.map +1 -0
- package/dist/{chunk-PFRUS5M4.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-NQTASSKI.cjs → chunk-ROBGJH52.cjs} +12 -12
- package/dist/{chunk-NQTASSKI.cjs.map → chunk-ROBGJH52.cjs.map} +1 -1
- package/dist/{chunk-T245XRIS.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-CCSCZSXS.cjs → chunk-VLDZ4363.cjs} +29 -29
- package/dist/{chunk-CCSCZSXS.cjs.map → chunk-VLDZ4363.cjs.map} +1 -1
- package/dist/{chunk-ANJ56NCM.js → chunk-VUDZOCPW.js} +6 -6
- package/dist/{chunk-5Z3YOBRE.cjs → chunk-XITG5ICQ.cjs} +40 -40
- package/dist/{chunk-5Z3YOBRE.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 +2 -1
- 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 +160 -144
- 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/CardsStrip.stories.tsx +5 -3
- 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-AN7MCJTV.cjs.map +0 -1
- package/dist/chunk-M2U5KN4Q.cjs.map +0 -1
- package/dist/chunk-QJN4XSP4.js.map +0 -1
- package/dist/chunk-TKGCTDQY.js.map +0 -1
- /package/dist/{chunk-PHH7M3CF.js.map → chunk-3ITY2LBM.js.map} +0 -0
- /package/dist/{chunk-AEPR6AXO.js.map → chunk-4SZDO3TE.js.map} +0 -0
- /package/dist/{chunk-AMS55EES.js.map → chunk-4VSTLJDO.js.map} +0 -0
- /package/dist/{chunk-CAEQ5BQO.js.map → chunk-CM5XKHHS.js.map} +0 -0
- /package/dist/{chunk-YHNBJ3F3.js.map → chunk-EQWE64RJ.js.map} +0 -0
- /package/dist/{chunk-UAOR2SRY.js.map → chunk-HYIHXXF3.js.map} +0 -0
- /package/dist/{chunk-KP2E22VP.js.map → chunk-K3GXIGN6.js.map} +0 -0
- /package/dist/{chunk-PJ27XJRQ.js.map → chunk-M3AKWFAO.js.map} +0 -0
- /package/dist/{chunk-ZRYCV52Q.js.map → chunk-N53FC732.js.map} +0 -0
- /package/dist/{chunk-PFRUS5M4.js.map → chunk-PCW2TRZ4.js.map} +0 -0
- /package/dist/{chunk-T245XRIS.js.map → chunk-RQHHACP2.js.map} +0 -0
- /package/dist/{chunk-ANJ56NCM.js.map → chunk-VUDZOCPW.js.map} +0 -0
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*
|
|
18
18
|
* MODE B — children (organic): ANY card components as plain JSX children.
|
|
19
19
|
* Each child is auto-wrapped in a managed cell (fixed width, hover/keyboard
|
|
20
|
-
* marquee-pause, clone `aria-hidden` +
|
|
20
|
+
* marquee-pause, clone `aria-hidden` + focus-suppressed but still CLICKABLE,
|
|
21
|
+
* row-height stretch).
|
|
21
22
|
* Adding a new card type requires ZERO strip code. Contract:
|
|
22
23
|
* (a) pass an ARRAY of children (`{rows.map(...)}`) — `Children.toArray`
|
|
23
24
|
* does NOT flatten through a single <Fragment> child (it would become
|
|
@@ -43,6 +44,8 @@
|
|
|
43
44
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
44
45
|
import { cn } from '../../utils/cn';
|
|
45
46
|
import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
|
|
47
|
+
import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine';
|
|
48
|
+
import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus';
|
|
46
49
|
import { Button } from '../ui/button';
|
|
47
50
|
import { SECTION_HEADING_CLASS } from '../layout/page-heading';
|
|
48
51
|
import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
|
|
@@ -146,6 +149,70 @@ export const STRIP_CELL_MAX_WIDTH = '90vw';
|
|
|
146
149
|
|
|
147
150
|
const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
|
|
148
151
|
|
|
152
|
+
// =============================================================================
|
|
153
|
+
// Children-mode managed cell
|
|
154
|
+
// =============================================================================
|
|
155
|
+
|
|
156
|
+
// (Clone focus suppression lives in hooks/ui/use-suppress-clone-focus — shared
|
|
157
|
+
// with <MarqueeWall>, which renders the same 2-copy endless-loop structure.)
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Children-mode cell: fixed width + hover/keyboard activation (wired here, never
|
|
161
|
+
* on the card — the single-place activation invariant). The OUTER cell is never
|
|
162
|
+
* inert/aria-hidden — an inert cell would retarget its pointer events to the
|
|
163
|
+
* track and defeat the clone hover-pause; the INNER wrapper carries the clone
|
|
164
|
+
* a11y treatment.
|
|
165
|
+
*/
|
|
166
|
+
function ManagedCell({
|
|
167
|
+
item,
|
|
168
|
+
ctx,
|
|
169
|
+
cardWidthMobile,
|
|
170
|
+
cardWidthDesktop,
|
|
171
|
+
}: {
|
|
172
|
+
item: unknown;
|
|
173
|
+
ctx: CardStripRenderCtx;
|
|
174
|
+
cardWidthMobile: number;
|
|
175
|
+
cardWidthDesktop: number;
|
|
176
|
+
}) {
|
|
177
|
+
// Per-child DESKTOP cell-width hint: heterogeneous card designs need
|
|
178
|
+
// heterogeneous cells (wide horizontal cards vs 400px vertical cards —
|
|
179
|
+
// variable widths are native to the engine, exactly like bite ratios). Read
|
|
180
|
+
// from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps the
|
|
181
|
+
// strip-level width (wide cards stack vertically below md anyway).
|
|
182
|
+
const hintedWidth = React.isValidElement(item)
|
|
183
|
+
? (item.props as Record<string, unknown>)['data-strip-cell-width']
|
|
184
|
+
: undefined;
|
|
185
|
+
const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
|
|
186
|
+
const cloneRef = useSuppressCloneFocus(ctx.isClone);
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
className="shrink-0 self-stretch"
|
|
190
|
+
style={{ width: ctx.isMobile ? cardWidthMobile : desktopWidth, maxWidth: STRIP_CELL_MAX_WIDTH }}
|
|
191
|
+
data-strip-card-key={ctx.cardKey}
|
|
192
|
+
onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
|
|
193
|
+
onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
|
|
194
|
+
// Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
|
|
195
|
+
onFocus={() => ctx.onActivate(ctx.cardKey)}
|
|
196
|
+
onBlur={e => {
|
|
197
|
+
// Intra-cell focus moves must not blip the pause off.
|
|
198
|
+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
199
|
+
ctx.onDeactivate(ctx.cardKey);
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{/* INNER: clone a11y — `aria-hidden` + focusable descendants forced out of
|
|
203
|
+
the tab order (useSuppressCloneFocus), but pointer events preserved so
|
|
204
|
+
a visible clone card stays clickable. Cards untouched. */}
|
|
205
|
+
<div
|
|
206
|
+
ref={cloneRef}
|
|
207
|
+
className="h-full [&>*]:h-full [&>a>*]:h-full"
|
|
208
|
+
aria-hidden={ctx.isClone || undefined}
|
|
209
|
+
>
|
|
210
|
+
{item as React.ReactNode}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
149
216
|
// =============================================================================
|
|
150
217
|
// Component
|
|
151
218
|
// =============================================================================
|
|
@@ -225,10 +292,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
225
292
|
}, []);
|
|
226
293
|
|
|
227
294
|
// ---- overflow measurement ---------------------------------------------------
|
|
228
|
-
//
|
|
295
|
+
// TWO overflow states on purpose: `overflows` (any real overflow) gates
|
|
296
|
+
// chevrons — a strip clipping even a few px must stay navigable —
|
|
297
|
+
// while `marqueeEligible` (overflow beyond the joining gap) additionally
|
|
298
|
+
// gates cloning + rAF: the cloned track's physical maxScroll is
|
|
299
|
+
// 2·copy − gap − viewport, so a copy overflowing by less than the gap
|
|
300
|
+
// makes even the zero-buffer wrap range [0, copy) unreachable — the
|
|
301
|
+
// engine would clamp at the track end and freeze. Razor-thin overflow
|
|
302
|
+
// (≤16px) stays a static, chevron-navigable row instead.
|
|
229
303
|
const [overflows, setOverflows] = useState(false);
|
|
304
|
+
const [marqueeEligible, setMarqueeEligible] = useState(false);
|
|
230
305
|
const singleCopyWidthRef = useRef(0);
|
|
231
|
-
const marqueeActive = autoScroll && !reducedMotion &&
|
|
306
|
+
const marqueeActive = autoScroll && !reducedMotion && marqueeEligible && items.length > 0;
|
|
232
307
|
|
|
233
308
|
const measure = useCallback(() => {
|
|
234
309
|
const scroller = scrollerRef.current;
|
|
@@ -239,6 +314,7 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
239
314
|
const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
|
|
240
315
|
singleCopyWidthRef.current = singleCopy;
|
|
241
316
|
setOverflows(singleCopy > scroller.clientWidth + 1);
|
|
317
|
+
setMarqueeEligible(singleCopy > scroller.clientWidth + TRACK_GAP_PX);
|
|
242
318
|
}, []);
|
|
243
319
|
|
|
244
320
|
useEffect(() => {
|
|
@@ -254,108 +330,26 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
254
330
|
|
|
255
331
|
// ---- pause-reason set + suppress timestamps ---------------------------------
|
|
256
332
|
// (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
|
|
257
|
-
const nearViewportRef = useRef(true);
|
|
258
333
|
const chevronSuppressUntilRef = useRef(0);
|
|
259
334
|
const userScrollSuppressUntilRef = useRef(0);
|
|
260
335
|
|
|
261
|
-
// Two-way viewport gate (unlike the fire-once `useNearViewport`):
|
|
262
|
-
//
|
|
336
|
+
// Two-way viewport gate (unlike the fire-once `useNearViewport`): STATE,
|
|
337
|
+
// not a pause reason — it gates the engine's `active` so the rAF fully
|
|
338
|
+
// stops while the strip is far off-screen (a paused engine would keep
|
|
339
|
+
// scheduling frames forever), and because the velocity envelope persists
|
|
340
|
+
// across engine restarts, scrolling back resumes at cruise speed instantly.
|
|
341
|
+
const [nearViewport, setNearViewport] = useState(true);
|
|
263
342
|
useEffect(() => {
|
|
264
343
|
const el = wrapperRef.current;
|
|
265
344
|
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
266
345
|
const io = new IntersectionObserver(
|
|
267
|
-
entries =>
|
|
346
|
+
entries => setNearViewport(entries[0]?.isIntersecting ?? true),
|
|
268
347
|
{ rootMargin: '200px' },
|
|
269
348
|
);
|
|
270
349
|
io.observe(el);
|
|
271
350
|
return () => io.disconnect();
|
|
272
351
|
}, []);
|
|
273
352
|
|
|
274
|
-
// ---- marquee rAF engine ------------------------------------------------------
|
|
275
|
-
// Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
|
|
276
|
-
// scrollLeft back each frame returns a rounded value, so sub-pixel increments
|
|
277
|
-
// (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
|
|
278
|
-
// ran measurably slower than the configured speed because of it.
|
|
279
|
-
//
|
|
280
|
-
// Chevron GLIDE also runs through this engine (glideRemainingRef): browser
|
|
281
|
-
// `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
|
|
282
|
-
// seam warp mid-animation made it lunge a full copy-width to catch up, warp
|
|
283
|
-
// again, and oscillate — rapid chevron clicks left every card flickering.
|
|
284
|
-
// The rAF tween consumes a signed remaining-distance instead, wrapping
|
|
285
|
-
// modulo the copy width each frame, so warps are invisible to it.
|
|
286
|
-
const marqueePosRef = useRef(0);
|
|
287
|
-
const glideRemainingRef = useRef(0);
|
|
288
|
-
// Velocity envelope (px/s): the marquee EASES between 0 and autoScrollSpeed
|
|
289
|
-
// (~250ms time constant) instead of binary stop/start — pause decelerates,
|
|
290
|
-
// resume accelerates from the current position (GSAP-marquee behavior;
|
|
291
|
-
// hard cuts read as jank).
|
|
292
|
-
const speedEnvRef = useRef(0);
|
|
293
|
-
useEffect(() => {
|
|
294
|
-
if (!marqueeActive) return;
|
|
295
|
-
const scroller = scrollerRef.current;
|
|
296
|
-
if (!scroller) return;
|
|
297
|
-
let raf = 0;
|
|
298
|
-
let last = performance.now();
|
|
299
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
300
|
-
const wrap = (pos: number) => {
|
|
301
|
-
const half = singleCopyWidthRef.current;
|
|
302
|
-
if (half <= 0) return pos;
|
|
303
|
-
while (pos >= half) pos -= half;
|
|
304
|
-
while (pos < 0) pos += half;
|
|
305
|
-
return pos;
|
|
306
|
-
};
|
|
307
|
-
const tick = (now: number) => {
|
|
308
|
-
const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
|
|
309
|
-
last = now;
|
|
310
|
-
// Container-level pause (industry-standard marquee behavior): the
|
|
311
|
-
// marquee freezes whenever a hover-capable pointer is ANYWHERE over the
|
|
312
|
-
// cards row — content must never move under a pointing cursor. Per-card
|
|
313
|
-
// activeKey still pauses for keyboard focus (no pointer involved).
|
|
314
|
-
const paused =
|
|
315
|
-
(pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
|
|
316
|
-
!nearViewportRef.current ||
|
|
317
|
-
document.visibilityState === 'hidden' ||
|
|
318
|
-
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
|
|
319
|
-
// Ease the marquee velocity toward its target (0 when paused) — smooth
|
|
320
|
-
// decel on hover, smooth accel on leave, always from the CURRENT position.
|
|
321
|
-
const targetSpeed = paused ? 0 : autoScrollSpeed;
|
|
322
|
-
speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25);
|
|
323
|
-
if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0;
|
|
324
|
-
|
|
325
|
-
const glide = glideRemainingRef.current;
|
|
326
|
-
if (glide !== 0) {
|
|
327
|
-
// Ease-out toward the chevron target; runs even while "paused"
|
|
328
|
-
// (the chevron click itself sets the suppress window).
|
|
329
|
-
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
330
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
331
|
-
}
|
|
332
|
-
const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
|
|
333
|
-
const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
|
|
334
|
-
glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
|
|
335
|
-
marqueePosRef.current = wrap(marqueePosRef.current + step);
|
|
336
|
-
scroller.scrollLeft = marqueePosRef.current;
|
|
337
|
-
} else if (speedEnvRef.current > 0) {
|
|
338
|
-
// Resync after external movement (user scroll, seam warp).
|
|
339
|
-
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
340
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
341
|
-
}
|
|
342
|
-
marqueePosRef.current = wrap(marqueePosRef.current + speedEnvRef.current * dt);
|
|
343
|
-
scroller.scrollLeft = marqueePosRef.current;
|
|
344
|
-
} else {
|
|
345
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
346
|
-
}
|
|
347
|
-
// Track moved under a possibly-stationary pointer → re-resolve which
|
|
348
|
-
// card is hovered (declared below; stable useCallback identity, called
|
|
349
|
-
// lazily per frame so source order is irrelevant — deliberately NOT a
|
|
350
|
-
// dep, same as the activeKeyRef read above).
|
|
351
|
-
syncHoverIfScrolled();
|
|
352
|
-
raf = requestAnimationFrame(tick);
|
|
353
|
-
};
|
|
354
|
-
raf = requestAnimationFrame(tick);
|
|
355
|
-
return () => cancelAnimationFrame(raf);
|
|
356
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- syncHoverIfScrolled is identity-stable
|
|
357
|
-
}, [marqueeActive, autoScrollSpeed, pauseOnHover]);
|
|
358
|
-
|
|
359
353
|
// ---- hover / overlay state -----------------------------------------------------
|
|
360
354
|
// activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
|
|
361
355
|
// a clone near the wrap seam works too). It only drives per-card `active`
|
|
@@ -445,6 +439,54 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
445
439
|
syncHoverToPointer();
|
|
446
440
|
}, [syncHoverToPointer]);
|
|
447
441
|
|
|
442
|
+
// ---- marquee rAF engine (shared core) ---------------------------------------
|
|
443
|
+
// Position/envelope/wrap/glide live in `useMarqueeEngine` — THE marquee
|
|
444
|
+
// animation, shared with <MarqueeWall> (quick-action walls, chip marquees).
|
|
445
|
+
// CardsStrip contributes the scroller driver (apply/readBack on scrollLeft:
|
|
446
|
+
// the user can also drag this surface) and its pause-reason set: container-
|
|
447
|
+
// level hover (industry-standard — content must never move under a pointing
|
|
448
|
+
// cursor; per-card activeKey still pauses for keyboard focus), hidden tab,
|
|
449
|
+
// and the chevron / user-scroll suppress windows. Chevron GLIDE
|
|
450
|
+
// runs through the engine too: browser `scrollBy({behavior:'smooth'})`
|
|
451
|
+
// animates toward an ABSOLUTE target, so a seam warp mid-animation made it
|
|
452
|
+
// lunge a full copy-width to catch up, warp again, and oscillate.
|
|
453
|
+
// Seam-warp BUFFER: the re-center zones sit this far inside the physical
|
|
454
|
+
// track edges. An exact-edge check (`scrollLeft <= 0`) is racy against the
|
|
455
|
+
// engine — the rAF nudges the scroller off 0 before the scroll handler
|
|
456
|
+
// samples it, so a user scrolling left "sticks" 1–2px from the edge and
|
|
457
|
+
// the loop never wraps. The engine's wrap range is aligned to
|
|
458
|
+
// [buffer, buffer + copy) so engine-written positions can never enter a
|
|
459
|
+
// zone and ping-pong with the handler. The buffer is ALSO capped by the
|
|
460
|
+
// physical slack (copy − viewport − gap): the range's upper end
|
|
461
|
+
// buffer + copy must stay reachable below maxScroll = 2·copy − gap −
|
|
462
|
+
// viewport, or a barely-overflowing strip clamps at the track end and the
|
|
463
|
+
// wrap threshold is never crossed — a permanently frozen marquee.
|
|
464
|
+
const seamBuffer = useCallback(() => {
|
|
465
|
+
const half = singleCopyWidthRef.current;
|
|
466
|
+
const viewport = scrollerRef.current?.clientWidth ?? 0;
|
|
467
|
+
return Math.max(0, Math.min(200, half / 4, half - viewport - TRACK_GAP_PX - 1));
|
|
468
|
+
}, []);
|
|
469
|
+
|
|
470
|
+
const { posRef: marqueePosRef, glideBy } = useMarqueeEngine({
|
|
471
|
+
// Viewport gates `active` (the rAF fully stops off-screen — a paused
|
|
472
|
+
// engine would keep scheduling frames forever), same treatment as
|
|
473
|
+
// <MarqueeWall>; the envelope persists, so re-entry resumes at cruise.
|
|
474
|
+
active: marqueeActive && nearViewport,
|
|
475
|
+
speed: autoScrollSpeed,
|
|
476
|
+
isPaused: now =>
|
|
477
|
+
(pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
|
|
478
|
+
document.visibilityState === 'hidden' ||
|
|
479
|
+
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current),
|
|
480
|
+
getWrapSize: () => singleCopyWidthRef.current,
|
|
481
|
+
getWrapMin: seamBuffer,
|
|
482
|
+
apply: pos => {
|
|
483
|
+
const scroller = scrollerRef.current;
|
|
484
|
+
if (scroller) scroller.scrollLeft = pos;
|
|
485
|
+
},
|
|
486
|
+
readBack: () => scrollerRef.current?.scrollLeft ?? 0,
|
|
487
|
+
onAfterFrame: syncHoverIfScrolled,
|
|
488
|
+
});
|
|
489
|
+
|
|
448
490
|
// ---- shared card-mount gate (by ITEM index, across both copies) -------------
|
|
449
491
|
// A card and its clone show identical content half a copy-width apart. If
|
|
450
492
|
// each card gated its own expensive content (e.g. video players), a seam warp
|
|
@@ -511,12 +553,12 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
511
553
|
if (marqueeActiveRef.current) {
|
|
512
554
|
// Wrap-aware glide via the rAF engine (see engine comment) — clicks
|
|
513
555
|
// accumulate distance instead of racing browser smooth-scroll targets.
|
|
514
|
-
|
|
556
|
+
glideBy(dir * step);
|
|
515
557
|
} else {
|
|
516
558
|
// No clones/seam without the marquee — native smooth scroll is safe.
|
|
517
559
|
scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
|
|
518
560
|
}
|
|
519
|
-
}, []);
|
|
561
|
+
}, [glideBy]);
|
|
520
562
|
|
|
521
563
|
const onUserScrollIntent = useCallback(() => {
|
|
522
564
|
userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
|
|
@@ -531,13 +573,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
531
573
|
if (e.pointerType === 'touch') onUserScrollIntent();
|
|
532
574
|
}, [onUserScrollIntent]);
|
|
533
575
|
|
|
534
|
-
// Never-ending strip:
|
|
535
|
-
// wheel/drag/chevron included — the rAF only wraps while the
|
|
536
|
-
// so without this a user scrolling during a pause/suppress
|
|
537
|
-
// physical track edges). Both copies render identical
|
|
538
|
-
// jump is pixel-invisible.
|
|
539
|
-
//
|
|
540
|
-
|
|
576
|
+
// Never-ending strip: re-center across the clone seam on EVERY scroll
|
|
577
|
+
// (manual wheel/drag/chevron included — the rAF only wraps while the
|
|
578
|
+
// marquee runs, so without this a user scrolling during a pause/suppress
|
|
579
|
+
// window hits the physical track edges). Both copies render identical
|
|
580
|
+
// content, so a ±half jump is pixel-invisible. ZONES, not exact edges:
|
|
581
|
+
// below `buffer` warp forward (+half, lands under half+buffer — outside
|
|
582
|
+
// the other zone, so the pair is hysteresis-stable), at/above
|
|
583
|
+
// half+buffer warp back (−half, lands at ≥buffer). The user can never
|
|
584
|
+
// reach a physical edge in either direction, and the racy "observe
|
|
585
|
+
// exactly 0" check that stuck leftward scrolling is gone. Initial rest at
|
|
586
|
+
// 0 warps once to `half` — pixel-identical, and it buys leftward headroom
|
|
587
|
+
// immediately.
|
|
541
588
|
const marqueeActiveRef = useRef(false);
|
|
542
589
|
marqueeActiveRef.current = marqueeActive;
|
|
543
590
|
const onSeamWarp = useCallback(() => {
|
|
@@ -550,18 +597,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
550
597
|
if (!marqueeActiveRef.current) return;
|
|
551
598
|
const half = singleCopyWidthRef.current;
|
|
552
599
|
if (half <= 0) return;
|
|
600
|
+
const buffer = seamBuffer();
|
|
553
601
|
let sl = scroller.scrollLeft;
|
|
554
|
-
if (sl >= half) {
|
|
602
|
+
if (sl >= half + buffer) {
|
|
555
603
|
sl -= half;
|
|
556
604
|
scroller.scrollLeft = sl;
|
|
557
605
|
marqueePosRef.current = sl;
|
|
558
|
-
} else if (sl
|
|
606
|
+
} else if (sl < buffer) {
|
|
559
607
|
sl += half;
|
|
560
608
|
scroller.scrollLeft = sl;
|
|
561
609
|
marqueePosRef.current = sl;
|
|
562
610
|
}
|
|
563
|
-
|
|
564
|
-
}, [syncHoverIfScrolled]);
|
|
611
|
+
}, [syncHoverIfScrolled, seamBuffer]);
|
|
565
612
|
|
|
566
613
|
// ---- render ----------------------------------------------------------------
|
|
567
614
|
|
|
@@ -570,47 +617,16 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
570
617
|
// Seam assertion (see normalization comment above).
|
|
571
618
|
return (props.renderCard as (item: unknown, ctx: CardStripRenderCtx) => React.ReactNode)(item, ctx);
|
|
572
619
|
}
|
|
573
|
-
// Per-child DESKTOP cell-width hint: heterogeneous card designs need
|
|
574
|
-
// heterogeneous cells (wide horizontal cards vs 400px vertical cards —
|
|
575
|
-
// variable widths are native to the engine, exactly like bite ratios).
|
|
576
|
-
// Read from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps
|
|
577
|
-
// the strip-level width (wide cards stack vertically below md anyway).
|
|
578
|
-
const hintedWidth = React.isValidElement(item)
|
|
579
|
-
? (item.props as Record<string, unknown>)['data-strip-cell-width']
|
|
580
|
-
: undefined;
|
|
581
|
-
const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
|
|
582
620
|
// Managed cell — the ONLY place cell width / activation / clone a11y are
|
|
583
|
-
// encoded for children mode
|
|
621
|
+
// encoded for children mode (extracted to a component so the clone
|
|
622
|
+
// focus-suppression hook can run per cell; see ManagedCell).
|
|
584
623
|
return (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
data-strip-card-key={ctx.cardKey}
|
|
592
|
-
onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
|
|
593
|
-
onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
|
|
594
|
-
// Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
|
|
595
|
-
onFocus={() => ctx.onActivate(ctx.cardKey)}
|
|
596
|
-
onBlur={e => {
|
|
597
|
-
// Intra-cell focus moves must not blip the pause off.
|
|
598
|
-
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
599
|
-
ctx.onDeactivate(ctx.cardKey);
|
|
600
|
-
}}
|
|
601
|
-
>
|
|
602
|
-
{/* INNER: clone a11y + uniform stretch — cards untouched. `inert` on
|
|
603
|
-
the clone strips anchor focusability without modifying any card
|
|
604
|
-
(WCAG aria-hidden-focus); clone content is pointer/focus-dead by
|
|
605
|
-
design while the outer cell still hover-pauses. */}
|
|
606
|
-
<div
|
|
607
|
-
className="h-full [&>*]:h-full [&>a>*]:h-full"
|
|
608
|
-
aria-hidden={ctx.isClone || undefined}
|
|
609
|
-
inert={ctx.isClone || undefined}
|
|
610
|
-
>
|
|
611
|
-
{item as React.ReactNode}
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
624
|
+
<ManagedCell
|
|
625
|
+
item={item}
|
|
626
|
+
ctx={ctx}
|
|
627
|
+
cardWidthMobile={cardWidthMobile}
|
|
628
|
+
cardWidthDesktop={cardWidthDesktop}
|
|
629
|
+
/>
|
|
614
630
|
);
|
|
615
631
|
};
|
|
616
632
|
|
|
@@ -172,6 +172,7 @@ export * from './ticket-detail-section'
|
|
|
172
172
|
export * from './ticket-attachments-list'
|
|
173
173
|
export * from './ticket-note-card'
|
|
174
174
|
export * from './ticket-notes-section'
|
|
175
|
+
export * from './marquee-wall'
|
|
175
176
|
export * from './simple-markdown-renderer'
|
|
176
177
|
export { RichMarkdownRenderer, type RichMarkdownRendererProps } from './rich-markdown-renderer'
|
|
177
178
|
export * from './filter-pill-row'
|