@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
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
45
45
|
import { cn } from '../../utils/cn';
|
|
46
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';
|
|
47
49
|
import { Button } from '../ui/button';
|
|
48
50
|
import { SECTION_HEADING_CLASS } from '../layout/page-heading';
|
|
49
51
|
import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
|
|
@@ -151,60 +153,8 @@ const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
|
|
|
151
153
|
// Children-mode managed cell
|
|
152
154
|
// =============================================================================
|
|
153
155
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// [contenteditable="false"])` catches the empty/`"true"`/`"plaintext-only"`
|
|
157
|
-
// editable forms; `summary` is focusable inside a <details>.
|
|
158
|
-
const CLONE_FOCUSABLE_SELECTOR =
|
|
159
|
-
'a[href],button,input,select,textarea,iframe,summary,audio[controls],video[controls],[tabindex],[contenteditable]:not([contenteditable="false"])';
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Keep a clone cell's content pointer-CLICKABLE while taking it out of the
|
|
163
|
-
* accessibility tree and the keyboard tab order.
|
|
164
|
-
*
|
|
165
|
-
* `inert` (the previous approach) does all three at once: a11y-hidden,
|
|
166
|
-
* un-focusable AND un-clickable. That third effect was the "news marquee stops
|
|
167
|
-
* being clickable after you scroll it for a while" bug — the 2-copy endless
|
|
168
|
-
* loop ALWAYS paints clone cards inside the viewport near the copy seam, so a
|
|
169
|
-
* user who manually scrolls and parks there is looking at real-looking cards
|
|
170
|
-
* that silently swallow every click. Clones must therefore stay clickable (a
|
|
171
|
-
* click on ANY visible card must open its link — mouse, ⌘-click, middle-click
|
|
172
|
-
* and context-menu all handled natively by the clone's own anchor).
|
|
173
|
-
*
|
|
174
|
-
* The a11y contract is still met without `inert`: the cell keeps `aria-hidden`
|
|
175
|
-
* (out of the a11y tree) and every focusable descendant is forced to
|
|
176
|
-
* `tabindex="-1"` (out of the tab order → no duplicate tab stops, no
|
|
177
|
-
* aria-hidden-focus violation). Re-applied on subtree mutation. This is the
|
|
178
|
-
* standard loop-clone treatment used by Swiper/Splide.
|
|
179
|
-
*/
|
|
180
|
-
function useSuppressCloneFocus(active: boolean) {
|
|
181
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
const root = ref.current;
|
|
184
|
-
if (!root || !active) return;
|
|
185
|
-
const suppress = () => {
|
|
186
|
-
root.querySelectorAll<HTMLElement>(CLONE_FOCUSABLE_SELECTOR).forEach(el => {
|
|
187
|
-
// Only stamp the ones not already stamped — avoids touching the DOM (and
|
|
188
|
-
// clobbering an intentional tabindex) on every mutation.
|
|
189
|
-
if (el.getAttribute('tabindex') !== '-1') el.setAttribute('tabindex', '-1');
|
|
190
|
-
});
|
|
191
|
-
};
|
|
192
|
-
suppress();
|
|
193
|
-
// Watch structure AND the attributes that can turn a descendant tabbable
|
|
194
|
-
// (an anchor gaining `href`, React restoring `tabindex="0"`, an element
|
|
195
|
-
// becoming editable, media gaining `controls`). The `!== '-1'` guard above
|
|
196
|
-
// makes our own tabindex writes a no-op, so the observer never loops.
|
|
197
|
-
const mo = new MutationObserver(suppress);
|
|
198
|
-
mo.observe(root, {
|
|
199
|
-
childList: true,
|
|
200
|
-
subtree: true,
|
|
201
|
-
attributes: true,
|
|
202
|
-
attributeFilter: ['href', 'tabindex', 'contenteditable', 'controls'],
|
|
203
|
-
});
|
|
204
|
-
return () => mo.disconnect();
|
|
205
|
-
}, [active]);
|
|
206
|
-
return ref;
|
|
207
|
-
}
|
|
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.)
|
|
208
158
|
|
|
209
159
|
/**
|
|
210
160
|
* Children-mode cell: fixed width + hover/keyboard activation (wired here, never
|
|
@@ -342,10 +292,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
342
292
|
}, []);
|
|
343
293
|
|
|
344
294
|
// ---- overflow measurement ---------------------------------------------------
|
|
345
|
-
//
|
|
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.
|
|
346
303
|
const [overflows, setOverflows] = useState(false);
|
|
304
|
+
const [marqueeEligible, setMarqueeEligible] = useState(false);
|
|
347
305
|
const singleCopyWidthRef = useRef(0);
|
|
348
|
-
const marqueeActive = autoScroll && !reducedMotion &&
|
|
306
|
+
const marqueeActive = autoScroll && !reducedMotion && marqueeEligible && items.length > 0;
|
|
349
307
|
|
|
350
308
|
const measure = useCallback(() => {
|
|
351
309
|
const scroller = scrollerRef.current;
|
|
@@ -356,6 +314,7 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
356
314
|
const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
|
|
357
315
|
singleCopyWidthRef.current = singleCopy;
|
|
358
316
|
setOverflows(singleCopy > scroller.clientWidth + 1);
|
|
317
|
+
setMarqueeEligible(singleCopy > scroller.clientWidth + TRACK_GAP_PX);
|
|
359
318
|
}, []);
|
|
360
319
|
|
|
361
320
|
useEffect(() => {
|
|
@@ -371,108 +330,26 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
371
330
|
|
|
372
331
|
// ---- pause-reason set + suppress timestamps ---------------------------------
|
|
373
332
|
// (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
|
|
374
|
-
const nearViewportRef = useRef(true);
|
|
375
333
|
const chevronSuppressUntilRef = useRef(0);
|
|
376
334
|
const userScrollSuppressUntilRef = useRef(0);
|
|
377
335
|
|
|
378
|
-
// Two-way viewport gate (unlike the fire-once `useNearViewport`):
|
|
379
|
-
//
|
|
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);
|
|
380
342
|
useEffect(() => {
|
|
381
343
|
const el = wrapperRef.current;
|
|
382
344
|
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
383
345
|
const io = new IntersectionObserver(
|
|
384
|
-
entries =>
|
|
346
|
+
entries => setNearViewport(entries[0]?.isIntersecting ?? true),
|
|
385
347
|
{ rootMargin: '200px' },
|
|
386
348
|
);
|
|
387
349
|
io.observe(el);
|
|
388
350
|
return () => io.disconnect();
|
|
389
351
|
}, []);
|
|
390
352
|
|
|
391
|
-
// ---- marquee rAF engine ------------------------------------------------------
|
|
392
|
-
// Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
|
|
393
|
-
// scrollLeft back each frame returns a rounded value, so sub-pixel increments
|
|
394
|
-
// (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
|
|
395
|
-
// ran measurably slower than the configured speed because of it.
|
|
396
|
-
//
|
|
397
|
-
// Chevron GLIDE also runs through this engine (glideRemainingRef): browser
|
|
398
|
-
// `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
|
|
399
|
-
// seam warp mid-animation made it lunge a full copy-width to catch up, warp
|
|
400
|
-
// again, and oscillate — rapid chevron clicks left every card flickering.
|
|
401
|
-
// The rAF tween consumes a signed remaining-distance instead, wrapping
|
|
402
|
-
// modulo the copy width each frame, so warps are invisible to it.
|
|
403
|
-
const marqueePosRef = useRef(0);
|
|
404
|
-
const glideRemainingRef = useRef(0);
|
|
405
|
-
// Velocity envelope (px/s): the marquee EASES between 0 and autoScrollSpeed
|
|
406
|
-
// (~250ms time constant) instead of binary stop/start — pause decelerates,
|
|
407
|
-
// resume accelerates from the current position (GSAP-marquee behavior;
|
|
408
|
-
// hard cuts read as jank).
|
|
409
|
-
const speedEnvRef = useRef(0);
|
|
410
|
-
useEffect(() => {
|
|
411
|
-
if (!marqueeActive) return;
|
|
412
|
-
const scroller = scrollerRef.current;
|
|
413
|
-
if (!scroller) return;
|
|
414
|
-
let raf = 0;
|
|
415
|
-
let last = performance.now();
|
|
416
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
417
|
-
const wrap = (pos: number) => {
|
|
418
|
-
const half = singleCopyWidthRef.current;
|
|
419
|
-
if (half <= 0) return pos;
|
|
420
|
-
while (pos >= half) pos -= half;
|
|
421
|
-
while (pos < 0) pos += half;
|
|
422
|
-
return pos;
|
|
423
|
-
};
|
|
424
|
-
const tick = (now: number) => {
|
|
425
|
-
const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
|
|
426
|
-
last = now;
|
|
427
|
-
// Container-level pause (industry-standard marquee behavior): the
|
|
428
|
-
// marquee freezes whenever a hover-capable pointer is ANYWHERE over the
|
|
429
|
-
// cards row — content must never move under a pointing cursor. Per-card
|
|
430
|
-
// activeKey still pauses for keyboard focus (no pointer involved).
|
|
431
|
-
const paused =
|
|
432
|
-
(pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
|
|
433
|
-
!nearViewportRef.current ||
|
|
434
|
-
document.visibilityState === 'hidden' ||
|
|
435
|
-
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
|
|
436
|
-
// Ease the marquee velocity toward its target (0 when paused) — smooth
|
|
437
|
-
// decel on hover, smooth accel on leave, always from the CURRENT position.
|
|
438
|
-
const targetSpeed = paused ? 0 : autoScrollSpeed;
|
|
439
|
-
speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25);
|
|
440
|
-
if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0;
|
|
441
|
-
|
|
442
|
-
const glide = glideRemainingRef.current;
|
|
443
|
-
if (glide !== 0) {
|
|
444
|
-
// Ease-out toward the chevron target; runs even while "paused"
|
|
445
|
-
// (the chevron click itself sets the suppress window).
|
|
446
|
-
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
447
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
448
|
-
}
|
|
449
|
-
const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
|
|
450
|
-
const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
|
|
451
|
-
glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
|
|
452
|
-
marqueePosRef.current = wrap(marqueePosRef.current + step);
|
|
453
|
-
scroller.scrollLeft = marqueePosRef.current;
|
|
454
|
-
} else if (speedEnvRef.current > 0) {
|
|
455
|
-
// Resync after external movement (user scroll, seam warp).
|
|
456
|
-
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
457
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
458
|
-
}
|
|
459
|
-
marqueePosRef.current = wrap(marqueePosRef.current + speedEnvRef.current * dt);
|
|
460
|
-
scroller.scrollLeft = marqueePosRef.current;
|
|
461
|
-
} else {
|
|
462
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
463
|
-
}
|
|
464
|
-
// Track moved under a possibly-stationary pointer → re-resolve which
|
|
465
|
-
// card is hovered (declared below; stable useCallback identity, called
|
|
466
|
-
// lazily per frame so source order is irrelevant — deliberately NOT a
|
|
467
|
-
// dep, same as the activeKeyRef read above).
|
|
468
|
-
syncHoverIfScrolled();
|
|
469
|
-
raf = requestAnimationFrame(tick);
|
|
470
|
-
};
|
|
471
|
-
raf = requestAnimationFrame(tick);
|
|
472
|
-
return () => cancelAnimationFrame(raf);
|
|
473
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- syncHoverIfScrolled is identity-stable
|
|
474
|
-
}, [marqueeActive, autoScrollSpeed, pauseOnHover]);
|
|
475
|
-
|
|
476
353
|
// ---- hover / overlay state -----------------------------------------------------
|
|
477
354
|
// activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
|
|
478
355
|
// a clone near the wrap seam works too). It only drives per-card `active`
|
|
@@ -562,6 +439,54 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
562
439
|
syncHoverToPointer();
|
|
563
440
|
}, [syncHoverToPointer]);
|
|
564
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
|
+
|
|
565
490
|
// ---- shared card-mount gate (by ITEM index, across both copies) -------------
|
|
566
491
|
// A card and its clone show identical content half a copy-width apart. If
|
|
567
492
|
// each card gated its own expensive content (e.g. video players), a seam warp
|
|
@@ -628,12 +553,12 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
628
553
|
if (marqueeActiveRef.current) {
|
|
629
554
|
// Wrap-aware glide via the rAF engine (see engine comment) — clicks
|
|
630
555
|
// accumulate distance instead of racing browser smooth-scroll targets.
|
|
631
|
-
|
|
556
|
+
glideBy(dir * step);
|
|
632
557
|
} else {
|
|
633
558
|
// No clones/seam without the marquee — native smooth scroll is safe.
|
|
634
559
|
scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
|
|
635
560
|
}
|
|
636
|
-
}, []);
|
|
561
|
+
}, [glideBy]);
|
|
637
562
|
|
|
638
563
|
const onUserScrollIntent = useCallback(() => {
|
|
639
564
|
userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
|
|
@@ -648,13 +573,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
648
573
|
if (e.pointerType === 'touch') onUserScrollIntent();
|
|
649
574
|
}, [onUserScrollIntent]);
|
|
650
575
|
|
|
651
|
-
// Never-ending strip:
|
|
652
|
-
// wheel/drag/chevron included — the rAF only wraps while the
|
|
653
|
-
// so without this a user scrolling during a pause/suppress
|
|
654
|
-
// physical track edges). Both copies render identical
|
|
655
|
-
// jump is pixel-invisible.
|
|
656
|
-
//
|
|
657
|
-
|
|
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.
|
|
658
588
|
const marqueeActiveRef = useRef(false);
|
|
659
589
|
marqueeActiveRef.current = marqueeActive;
|
|
660
590
|
const onSeamWarp = useCallback(() => {
|
|
@@ -667,18 +597,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
667
597
|
if (!marqueeActiveRef.current) return;
|
|
668
598
|
const half = singleCopyWidthRef.current;
|
|
669
599
|
if (half <= 0) return;
|
|
600
|
+
const buffer = seamBuffer();
|
|
670
601
|
let sl = scroller.scrollLeft;
|
|
671
|
-
if (sl >= half) {
|
|
602
|
+
if (sl >= half + buffer) {
|
|
672
603
|
sl -= half;
|
|
673
604
|
scroller.scrollLeft = sl;
|
|
674
605
|
marqueePosRef.current = sl;
|
|
675
|
-
} else if (sl
|
|
606
|
+
} else if (sl < buffer) {
|
|
676
607
|
sl += half;
|
|
677
608
|
scroller.scrollLeft = sl;
|
|
678
609
|
marqueePosRef.current = sl;
|
|
679
610
|
}
|
|
680
|
-
|
|
681
|
-
}, [syncHoverIfScrolled]);
|
|
611
|
+
}, [syncHoverIfScrolled, seamBuffer]);
|
|
682
612
|
|
|
683
613
|
// ---- render ----------------------------------------------------------------
|
|
684
614
|
|
|
@@ -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'
|