@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,158 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* THE marquee animation core (single source of truth) — extracted verbatim
|
|
7
|
+
* from <CardsStrip> so every endless-scroll surface (card strips, logo strips,
|
|
8
|
+
* quick-action walls, chip marquees) shares ONE rAF position engine ("unify
|
|
9
|
+
* means delete variations").
|
|
10
|
+
*
|
|
11
|
+
* The engine is axis- and driver-agnostic: it owns a FLOAT position ref, a
|
|
12
|
+
* velocity envelope, seam wrapping, and an optional chevron-style glide; the
|
|
13
|
+
* caller decides what a position "means" via `apply` — writing
|
|
14
|
+
* `scroller.scrollLeft` (CardsStrip: a real scroller the user can also drag)
|
|
15
|
+
* or a `translate` transform (MarqueeWall: decorative tracks, incl. vertical —
|
|
16
|
+
* mandatory inside surfaces that forbid inner scrollers, e.g. the deck's
|
|
17
|
+
* mobile single-fold panels).
|
|
18
|
+
*
|
|
19
|
+
* Engine semantics (do not "simplify"):
|
|
20
|
+
* - Position lives in a FLOAT ref, never read-modify-write from the DOM:
|
|
21
|
+
* scrollLeft reads back rounded values, so sub-pixel increments
|
|
22
|
+
* (speed/fps < 1px) get eaten by rounding — stutter + measurably slower
|
|
23
|
+
* than the configured speed.
|
|
24
|
+
* - Velocity envelope (~250ms time constant) eases between 0 and `speed`
|
|
25
|
+
* instead of binary stop/start — pause decelerates, resume accelerates from
|
|
26
|
+
* the current position (GSAP-marquee behavior; hard cuts read as jank).
|
|
27
|
+
* - Glide consumes a signed remaining-distance, wrapping modulo the copy size
|
|
28
|
+
* each frame, so seam warps are invisible to it (browser smooth-scroll
|
|
29
|
+
* animates toward an ABSOLUTE target and lunges after a warp).
|
|
30
|
+
* - `isPaused` is evaluated every frame — the caller owns the pause-reason
|
|
31
|
+
* set (hover, viewport, tab visibility, suppress windows); the engine only
|
|
32
|
+
* turns it into a smooth velocity target.
|
|
33
|
+
*/
|
|
34
|
+
export interface MarqueeEngineOptions {
|
|
35
|
+
/** Master switch — the rAF runs only while true. On (re)activation the
|
|
36
|
+
* position resyncs from `readBack` when provided. */
|
|
37
|
+
active: boolean
|
|
38
|
+
/** Cruise speed in px/s. */
|
|
39
|
+
speed: number
|
|
40
|
+
/** Per-frame pause verdict (caller-owned reason set). `now` is the rAF
|
|
41
|
+
* timestamp, for suppress-until comparisons. */
|
|
42
|
+
isPaused: (now: number) => boolean
|
|
43
|
+
/** Single-copy size in px (the wrap modulus). Return <= 0 to disable
|
|
44
|
+
* wrapping (position advances unwrapped — callers should size tracks so
|
|
45
|
+
* this never happens while active). */
|
|
46
|
+
getWrapSize: () => number
|
|
47
|
+
/** Lower bound of the wrap range: positions stay in
|
|
48
|
+
* [wrapMin, wrapMin + wrapSize). Default 0. Scroller drivers with USER
|
|
49
|
+
* scrolling pass their seam-warp buffer here so engine-written positions
|
|
50
|
+
* never enter the re-center zones — an exact-edge warp check is racy
|
|
51
|
+
* against the rAF (the engine nudges the scroller off the edge before the
|
|
52
|
+
* scroll handler samples it → "stuck at the left edge"). */
|
|
53
|
+
getWrapMin?: () => number
|
|
54
|
+
/** Write the position to the DOM (scrollLeft assignment or transform). */
|
|
55
|
+
apply: (pos: number) => void
|
|
56
|
+
/** Read the externally-mutated position back (real scrollers: user drag /
|
|
57
|
+
* native smooth scroll move scrollLeft under the engine). Omit for
|
|
58
|
+
* transform drivers — nothing external can move them. */
|
|
59
|
+
readBack?: () => number
|
|
60
|
+
/** Called at the end of every frame (e.g. hover re-resolution after the
|
|
61
|
+
* track moved under a stationary pointer). */
|
|
62
|
+
onAfterFrame?: () => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MarqueeEngineHandle {
|
|
66
|
+
/** The engine's float position (px along the axis, 0 ≤ pos < wrap while
|
|
67
|
+
* wrapping). Callers may write it together with their own DOM write (seam
|
|
68
|
+
* warp on manual scroll). */
|
|
69
|
+
posRef: MutableRefObject<number>
|
|
70
|
+
/** Accumulate a signed glide distance (chevron navigation). Runs through
|
|
71
|
+
* the engine's own tween — never native smooth scroll while wrapping. */
|
|
72
|
+
glideBy: (px: number) => void
|
|
73
|
+
/** Wrap an arbitrary position into [0, wrapSize). */
|
|
74
|
+
wrap: (pos: number) => number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Resync threshold (px): external movement beyond this adopts the DOM value.
|
|
78
|
+
const RESYNC_EPSILON = 1.5
|
|
79
|
+
|
|
80
|
+
export function useMarqueeEngine(options: MarqueeEngineOptions): MarqueeEngineHandle {
|
|
81
|
+
const posRef = useRef(0)
|
|
82
|
+
const glideRemainingRef = useRef(0)
|
|
83
|
+
const speedEnvRef = useRef(0)
|
|
84
|
+
|
|
85
|
+
// Latest-options ref: the rAF loop always reads current values without the
|
|
86
|
+
// effect restarting on every render.
|
|
87
|
+
const optsRef = useRef(options)
|
|
88
|
+
optsRef.current = options
|
|
89
|
+
|
|
90
|
+
const wrap = useCallback((pos: number) => {
|
|
91
|
+
const half = optsRef.current.getWrapSize()
|
|
92
|
+
if (half <= 0) return pos
|
|
93
|
+
const min = optsRef.current.getWrapMin?.() ?? 0
|
|
94
|
+
while (pos >= min + half) pos -= half
|
|
95
|
+
while (pos < min) pos += half
|
|
96
|
+
return pos
|
|
97
|
+
}, [])
|
|
98
|
+
|
|
99
|
+
const glideBy = useCallback((px: number) => {
|
|
100
|
+
glideRemainingRef.current += px
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
const active = options.active
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!active) return
|
|
106
|
+
let raf = 0
|
|
107
|
+
let last = performance.now()
|
|
108
|
+
const readBack = optsRef.current.readBack
|
|
109
|
+
if (readBack) posRef.current = readBack()
|
|
110
|
+
|
|
111
|
+
const tick = (now: number) => {
|
|
112
|
+
const opts = optsRef.current
|
|
113
|
+
const dt = Math.min(now - last, 100) / 1000 // clamp tab-wake jumps
|
|
114
|
+
last = now
|
|
115
|
+
|
|
116
|
+
const paused = opts.isPaused(now)
|
|
117
|
+
// Ease the velocity toward its target (0 when paused) — smooth decel on
|
|
118
|
+
// pause, smooth accel on resume, always from the CURRENT position.
|
|
119
|
+
const targetSpeed = paused ? 0 : opts.speed
|
|
120
|
+
speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25)
|
|
121
|
+
if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0
|
|
122
|
+
|
|
123
|
+
const external = opts.readBack ? opts.readBack() : null
|
|
124
|
+
const resync = () => {
|
|
125
|
+
if (external !== null && Math.abs(external - posRef.current) > RESYNC_EPSILON) {
|
|
126
|
+
posRef.current = external
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const glide = glideRemainingRef.current
|
|
131
|
+
if (glide !== 0) {
|
|
132
|
+
// Ease-out toward the glide target; runs even while "paused" (the
|
|
133
|
+
// interaction that queued it sets its own suppress window).
|
|
134
|
+
resync()
|
|
135
|
+
const speed = Math.max(Math.abs(glide) * 6, 240) // px/s, proportional
|
|
136
|
+
const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt)
|
|
137
|
+
glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step
|
|
138
|
+
posRef.current = wrap(posRef.current + step)
|
|
139
|
+
opts.apply(posRef.current)
|
|
140
|
+
} else if (speedEnvRef.current > 0) {
|
|
141
|
+
// Resync after external movement (user scroll, seam warp).
|
|
142
|
+
resync()
|
|
143
|
+
posRef.current = wrap(posRef.current + speedEnvRef.current * dt)
|
|
144
|
+
opts.apply(posRef.current)
|
|
145
|
+
} else if (external !== null) {
|
|
146
|
+
// Idle: follow external movement so resumes start from where the
|
|
147
|
+
// user left the scroller.
|
|
148
|
+
posRef.current = external
|
|
149
|
+
}
|
|
150
|
+
opts.onAfterFrame?.()
|
|
151
|
+
raf = requestAnimationFrame(tick)
|
|
152
|
+
}
|
|
153
|
+
raf = requestAnimationFrame(tick)
|
|
154
|
+
return () => cancelAnimationFrame(raf)
|
|
155
|
+
}, [active, wrap])
|
|
156
|
+
|
|
157
|
+
return { posRef, glideBy, wrap }
|
|
158
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
// Every focusable a clone might contain — matched so it can be pulled out of the
|
|
6
|
+
// keyboard tab order (see useSuppressCloneFocus). `[contenteditable]:not(
|
|
7
|
+
// [contenteditable="false"])` catches the empty/`"true"`/`"plaintext-only"`
|
|
8
|
+
// editable forms; `summary` is focusable inside a <details>.
|
|
9
|
+
const CLONE_FOCUSABLE_SELECTOR =
|
|
10
|
+
'a[href],button,input,select,textarea,iframe,summary,audio[controls],video[controls],[tabindex],[contenteditable]:not([contenteditable="false"])'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Keep a marquee clone's content pointer-CLICKABLE while taking it out of the
|
|
14
|
+
* accessibility tree and the keyboard tab order. Shared by every endless-loop
|
|
15
|
+
* surface that renders a second track copy (<CardsStrip> managed cells,
|
|
16
|
+
* <MarqueeWall> clone copies).
|
|
17
|
+
*
|
|
18
|
+
* `inert` (the previous approach) does all three at once: a11y-hidden,
|
|
19
|
+
* un-focusable AND un-clickable. That third effect was the "news marquee stops
|
|
20
|
+
* being clickable after you scroll it for a while" bug — the 2-copy endless
|
|
21
|
+
* loop ALWAYS paints clone cards inside the viewport near the copy seam, so a
|
|
22
|
+
* user who manually scrolls and parks there is looking at real-looking cards
|
|
23
|
+
* that silently swallow every click. Clones must therefore stay clickable (a
|
|
24
|
+
* click on ANY visible card must open its link — mouse, ⌘-click, middle-click
|
|
25
|
+
* and context-menu all handled natively by the clone's own anchor).
|
|
26
|
+
*
|
|
27
|
+
* The a11y contract is still met without `inert`: the caller keeps
|
|
28
|
+
* `aria-hidden` on the clone wrapper (out of the a11y tree) and this hook
|
|
29
|
+
* forces every focusable descendant to `tabindex="-1"` (out of the tab order →
|
|
30
|
+
* no duplicate tab stops, no aria-hidden-focus violation). Re-applied on
|
|
31
|
+
* subtree mutation. This is the standard loop-clone treatment used by
|
|
32
|
+
* Swiper/Splide.
|
|
33
|
+
*/
|
|
34
|
+
export function useSuppressCloneFocus(active: boolean) {
|
|
35
|
+
const ref = useRef<HTMLDivElement | null>(null)
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const root = ref.current
|
|
38
|
+
if (!root || !active) return
|
|
39
|
+
const suppress = () => {
|
|
40
|
+
root.querySelectorAll<HTMLElement>(CLONE_FOCUSABLE_SELECTOR).forEach(el => {
|
|
41
|
+
// Only stamp the ones not already stamped — avoids touching the DOM (and
|
|
42
|
+
// clobbering an intentional tabindex) on every mutation.
|
|
43
|
+
if (el.getAttribute('tabindex') !== '-1') el.setAttribute('tabindex', '-1')
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
suppress()
|
|
47
|
+
// Watch structure AND the attributes that can turn a descendant tabbable
|
|
48
|
+
// (an anchor gaining `href`, React restoring `tabindex="0"`, an element
|
|
49
|
+
// becoming editable, media gaining `controls`). The `!== '-1'` guard above
|
|
50
|
+
// makes our own tabindex writes a no-op, so the observer never loops.
|
|
51
|
+
const mo = new MutationObserver(suppress)
|
|
52
|
+
mo.observe(root, {
|
|
53
|
+
childList: true,
|
|
54
|
+
subtree: true,
|
|
55
|
+
attributes: true,
|
|
56
|
+
attributeFilter: ['href', 'tabindex', 'contenteditable', 'controls'],
|
|
57
|
+
})
|
|
58
|
+
return () => mo.disconnect()
|
|
59
|
+
}, [active])
|
|
60
|
+
return ref
|
|
61
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
|
2
|
+
import { MarqueeWall, useMarqueeSync } from '../components/ui/marquee-wall'
|
|
3
|
+
|
|
4
|
+
// Arbitrary-children demo content: simple lockup tiles (the wall is
|
|
5
|
+
// content-agnostic — chips, logos, feed rows all ride the same engine).
|
|
6
|
+
const TILES = [
|
|
7
|
+
'Datto RMM',
|
|
8
|
+
'ConnectWise',
|
|
9
|
+
'HaloPSA',
|
|
10
|
+
'SentinelOne',
|
|
11
|
+
'Proofpoint',
|
|
12
|
+
'Veeam',
|
|
13
|
+
'Auvik',
|
|
14
|
+
'IT Glue',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
function Tile({ label }: { label: string }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex h-16 w-48 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-card text-h6 text-ods-text-secondary">
|
|
20
|
+
{label}
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const meta: Meta<typeof MarqueeWall> = {
|
|
26
|
+
title: 'UI/MarqueeWall',
|
|
27
|
+
component: MarqueeWall,
|
|
28
|
+
parameters: {
|
|
29
|
+
layout: 'padded',
|
|
30
|
+
docs: {
|
|
31
|
+
description: {
|
|
32
|
+
component:
|
|
33
|
+
'THE endless-scroll wall container — a clipped area whose content loops as a marquee along either axis, fading where the clip cuts. Transform-driven on the shared `useMarqueeEngine` core (no inner scroller). Auto-static when the content fits or under `prefers-reduced-motion`. `useMarqueeSync` pins multiple walls to one position driver; `trackId` publishes live offsets for FLIP-morph coordination.',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
decorators: [
|
|
38
|
+
(Story) => (
|
|
39
|
+
<div className="bg-ods-bg p-[var(--spacing-system-xl)]">
|
|
40
|
+
<Story />
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default meta
|
|
47
|
+
type Story = StoryObj<typeof MarqueeWall>
|
|
48
|
+
|
|
49
|
+
/** Horizontal loop of arbitrary children with the default right fade. */
|
|
50
|
+
export const Horizontal: Story = {
|
|
51
|
+
render: () => (
|
|
52
|
+
<MarqueeWall fade="right" copyGap="var(--spacing-system-mf)" className="w-full" contentClassName="flex items-center gap-[var(--spacing-system-mf)]">
|
|
53
|
+
{TILES.map(t => (
|
|
54
|
+
<Tile key={t} label={t} />
|
|
55
|
+
))}
|
|
56
|
+
</MarqueeWall>
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Vertical loop (bottom fade → bottom→top travel) inside a height cap. */
|
|
61
|
+
export const Vertical: Story = {
|
|
62
|
+
render: () => (
|
|
63
|
+
<div className="w-64 rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
|
|
64
|
+
<MarqueeWall
|
|
65
|
+
fade="bottom"
|
|
66
|
+
fadeColor="var(--color-bg-card)"
|
|
67
|
+
copyGap="var(--spacing-system-mf)"
|
|
68
|
+
className="max-h-[240px]"
|
|
69
|
+
contentClassName="flex flex-col gap-[var(--spacing-system-mf)]"
|
|
70
|
+
>
|
|
71
|
+
{TILES.map(t => (
|
|
72
|
+
<Tile key={t} label={t} />
|
|
73
|
+
))}
|
|
74
|
+
</MarqueeWall>
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Two walls pinned to ONE position driver via `useMarqueeSync` — scroll
|
|
80
|
+
* stays pixel-locked (the FreeTrialCTA resolve-strip mechanism). */
|
|
81
|
+
export const SyncedPair: Story = {
|
|
82
|
+
render: function SyncedPairStory() {
|
|
83
|
+
const sync = useMarqueeSync()
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col gap-[var(--spacing-system-mf)]">
|
|
86
|
+
{[0, 1].map(row => (
|
|
87
|
+
<MarqueeWall
|
|
88
|
+
key={row}
|
|
89
|
+
sync={sync}
|
|
90
|
+
axis="x"
|
|
91
|
+
copyGap="var(--spacing-system-mf)"
|
|
92
|
+
className="w-full"
|
|
93
|
+
contentClassName="flex items-center gap-[var(--spacing-system-mf)]"
|
|
94
|
+
>
|
|
95
|
+
{TILES.map(t => (
|
|
96
|
+
<Tile key={t} label={t} />
|
|
97
|
+
))}
|
|
98
|
+
</MarqueeWall>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Plain mode — the consumer's opt-out: same wall, no motion, no fades. */
|
|
106
|
+
export const Plain: Story = {
|
|
107
|
+
render: () => (
|
|
108
|
+
<MarqueeWall mode="plain" className="w-full" contentClassName="flex items-center gap-[var(--spacing-system-mf)]">
|
|
109
|
+
{TILES.map(t => (
|
|
110
|
+
<Tile key={t} label={t} />
|
|
111
|
+
))}
|
|
112
|
+
</MarqueeWall>
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Reverse travel (content moves right) with a custom-size leading fade. */
|
|
117
|
+
export const Reversed: Story = {
|
|
118
|
+
render: () => (
|
|
119
|
+
<MarqueeWall
|
|
120
|
+
reverse
|
|
121
|
+
fade={['left', 'right']}
|
|
122
|
+
fadeSize={{ left: 48 }}
|
|
123
|
+
copyGap="var(--spacing-system-mf)"
|
|
124
|
+
className="w-full"
|
|
125
|
+
contentClassName="flex items-center gap-[var(--spacing-system-mf)]"
|
|
126
|
+
>
|
|
127
|
+
{TILES.map(t => (
|
|
128
|
+
<Tile key={t} label={t} />
|
|
129
|
+
))}
|
|
130
|
+
</MarqueeWall>
|
|
131
|
+
),
|
|
132
|
+
}
|
|
@@ -32,14 +32,14 @@ const meta: Meta<typeof QuickActionMarquee> = {
|
|
|
32
32
|
docs: {
|
|
33
33
|
description: {
|
|
34
34
|
component:
|
|
35
|
-
'Endless horizontally-scrolling strip of quick-action chips (Figma fae/mingo marquee rows). The item list is padded to ≥
|
|
35
|
+
'Endless horizontally-scrolling strip of quick-action chips (Figma fae/mingo marquee rows) — a single-row preset of the shared `<MarqueeWall>` engine (the same rAF core as the card strips). The item list is padded to ≥12 chips and the wall clones the track for the seamless loop; the clone copy is `aria-hidden` and never focusable. Speed is in px/s (constant on-screen speed regardless of item count). Pauses on hover (`pauseOnHover`, default on) and always under `prefers-reduced-motion`. Pass `onSelect` to make chips interactive buttons; omit it for a purely decorative strip.',
|
|
36
36
|
},
|
|
37
37
|
},
|
|
38
38
|
},
|
|
39
39
|
argTypes: {
|
|
40
40
|
items: { control: false },
|
|
41
41
|
direction: { control: 'inline-radio', options: ['left', 'right'] },
|
|
42
|
-
|
|
42
|
+
speed: { control: { type: 'number', min: 10, max: 200, step: 10 } },
|
|
43
43
|
pauseOnHover: { control: 'boolean' },
|
|
44
44
|
onSelect: { control: false },
|
|
45
45
|
},
|
|
@@ -66,7 +66,7 @@ export const Default: Story = {
|
|
|
66
66
|
/** The two Figma rows: fae (left) over mingo (right, reversed). */
|
|
67
67
|
export const TwoRows: Story = {
|
|
68
68
|
render: () => (
|
|
69
|
-
<div className="flex flex-col gap-
|
|
69
|
+
<div className="flex flex-col gap-[var(--spacing-system-mf)]">
|
|
70
70
|
<QuickActionMarquee items={FAE_ITEMS} direction="left" onSelect={(i) => console.log(i.id)} />
|
|
71
71
|
<QuickActionMarquee items={MINGO_ITEMS} direction="right" onSelect={(i) => console.log(i.id)} />
|
|
72
72
|
</div>
|
|
@@ -93,8 +93,9 @@ export const Decorative: Story = {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Fewer than
|
|
97
|
-
*
|
|
96
|
+
* Fewer than 12 items — the track is auto-repeated up to the `minChips`
|
|
97
|
+
* padding target so one copy always overflows the container and the loop
|
|
98
|
+
* engages.
|
|
98
99
|
*/
|
|
99
100
|
export const FewItems: Story = {
|
|
100
101
|
args: {
|
|
@@ -103,11 +104,11 @@ export const FewItems: Story = {
|
|
|
103
104
|
},
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
/** Faster
|
|
107
|
+
/** Faster scroll (`speed={120}` px/s) and hover-pause disabled. */
|
|
107
108
|
export const FastNoPause: Story = {
|
|
108
109
|
args: {
|
|
109
110
|
items: FAE_ITEMS,
|
|
110
|
-
|
|
111
|
+
speed: 120,
|
|
111
112
|
pauseOnHover: false,
|
|
112
113
|
onSelect: (item) => console.log('select', item.id),
|
|
113
114
|
},
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
|
2
|
+
import { QuickActionWall, interleave } from '../components/chat/quick-action-wall'
|
|
3
|
+
import type { QuickActionChip } from '../components/chat/chat-quick-action-row'
|
|
4
|
+
|
|
5
|
+
// A mixed IT/SEC stream (per-chip themes + lozenges) and a fae wall — the two
|
|
6
|
+
// wall registers: category-classified deck panels vs agent-themed hero walls.
|
|
7
|
+
const IT_ACTIONS: ReadonlyArray<QuickActionChip> = [
|
|
8
|
+
{ id: 'reset-password', label: 'Reset a password', icon: { name: 'key' }, theme: 'it' },
|
|
9
|
+
{ id: 'new-laptop', label: 'Provision a new laptop', icon: { name: 'laptop' }, theme: 'it' },
|
|
10
|
+
{ id: 'printer', label: 'Fix the printer', icon: { name: 'printer' }, theme: 'it' },
|
|
11
|
+
{ id: 'vpn', label: 'VPN will not connect', icon: { name: 'globe' }, theme: 'it' },
|
|
12
|
+
{ id: 'disk-full', label: 'Disk almost full', icon: { name: 'hard-drive' }, theme: 'it' },
|
|
13
|
+
{ id: 'onboard', label: 'Onboard a new hire', icon: { name: 'user-plus' }, theme: 'it' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const SEC_ACTIONS: ReadonlyArray<QuickActionChip> = [
|
|
17
|
+
{ id: 'phishing', label: 'Phishing report triage', icon: { name: 'fish' }, theme: 'sec' },
|
|
18
|
+
{ id: 'mfa', label: 'Enforce MFA', icon: { name: 'shield-check' }, theme: 'sec' },
|
|
19
|
+
{ id: 'patch', label: 'Patch the CVE', icon: { name: 'bug' }, theme: 'sec' },
|
|
20
|
+
{ id: 'offboard', label: 'Revoke access on exit', icon: { name: 'user-minus' }, theme: 'sec' },
|
|
21
|
+
{ id: 'edr-alert', label: 'EDR alert review', icon: { name: 'radar' }, theme: 'sec' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const FAE_ACTIONS: ReadonlyArray<QuickActionChip> = [
|
|
25
|
+
{ id: 'how-to-start', label: 'How to start', icon: { name: 'fae' } },
|
|
26
|
+
{ id: 'connect-device', label: 'Connect device', icon: { name: 'search' }, theme: 'fae' },
|
|
27
|
+
{ id: 'find-device', label: 'Find device', icon: { name: 'compass' }, theme: 'fae' },
|
|
28
|
+
{ id: 'remote-connection', label: 'Remote connection', icon: { name: 'rocket' }, theme: 'fae' },
|
|
29
|
+
{ id: 'run-scripts', label: 'Run scripts', icon: { name: 'bracket-curly' }, theme: 'fae' },
|
|
30
|
+
{ id: 'device-software', label: 'Device software', icon: { name: 'package' }, theme: 'fae' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const meta: Meta<typeof QuickActionWall> = {
|
|
34
|
+
title: 'Chat/QuickActionWall',
|
|
35
|
+
component: QuickActionWall,
|
|
36
|
+
parameters: {
|
|
37
|
+
layout: 'padded',
|
|
38
|
+
docs: {
|
|
39
|
+
description: {
|
|
40
|
+
component:
|
|
41
|
+
'THE quick-action chip wall: a themed pile of the shared chip on the shared `<MarqueeWall>` engine. Static-with-fade when content fits (or `prefers-reduced-motion`); an endless marquee when it overflows — the axis follows the fade (`bottom` fade → content travels bottom→top; `right` fade → right→left). Themes: `fae`/`mingo` (agent identities) and `it`/`sec` (classification pair, with lozenges). Interactive chips pause the track on hover; clone-copy chips stay clickable with wrapper-level a11y. `rows` renders the brick-wall mode (stacked independent row marquees).',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
decorators: [
|
|
46
|
+
(Story) => (
|
|
47
|
+
<div className="bg-ods-bg p-[var(--spacing-system-xl)]">
|
|
48
|
+
<Story />
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default meta
|
|
55
|
+
type Story = StoryObj<typeof QuickActionWall>
|
|
56
|
+
|
|
57
|
+
/** Vertical wall (bottom fade → bottom→top marquee): the deck-panel register —
|
|
58
|
+
* mixed IT/SEC stream with lozenges, decorative chips. */
|
|
59
|
+
export const VerticalMixedStream: Story = {
|
|
60
|
+
render: () => (
|
|
61
|
+
<div className="rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
|
|
62
|
+
<QuickActionWall
|
|
63
|
+
chips={interleave(IT_ACTIONS, SEC_ACTIONS)}
|
|
64
|
+
lozenges
|
|
65
|
+
fade="bottom"
|
|
66
|
+
fadeColor="var(--color-bg-card)"
|
|
67
|
+
className="max-h-[180px]"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Horizontal wall (right fade): the hero-tab register — a wide fae wall
|
|
74
|
+
* drifting right→left, interactive chips (hover pauses the track). */
|
|
75
|
+
export const HorizontalInteractive: Story = {
|
|
76
|
+
render: () => (
|
|
77
|
+
<QuickActionWall
|
|
78
|
+
chips={FAE_ACTIONS.map((c, i) => ({ ...c, selected: i === 0, selectedAccent: 'pink', onSelect: () => console.log(c.id) }))}
|
|
79
|
+
fade="right"
|
|
80
|
+
className="w-full"
|
|
81
|
+
contentClassName="w-[1400px]"
|
|
82
|
+
/>
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Plain mode — the consumer's opt-out: same chips and layout, no marquee,
|
|
87
|
+
* no blur. */
|
|
88
|
+
export const Plain: Story = {
|
|
89
|
+
render: () => (
|
|
90
|
+
<div className="rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
|
|
91
|
+
<QuickActionWall
|
|
92
|
+
chips={interleave(IT_ACTIONS, SEC_ACTIONS)}
|
|
93
|
+
mode="plain"
|
|
94
|
+
lozenges
|
|
95
|
+
className="max-h-[180px]"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Loading: skeleton chips (1:1 geometry, lozenge slots reserved) — static
|
|
102
|
+
* until real chips land. */
|
|
103
|
+
export const Loading: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<div className="rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
|
|
106
|
+
<QuickActionWall
|
|
107
|
+
chips={[]}
|
|
108
|
+
loading
|
|
109
|
+
lozenges
|
|
110
|
+
fade="bottom"
|
|
111
|
+
fadeColor="var(--color-bg-card)"
|
|
112
|
+
className="max-h-[180px]"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
),
|
|
116
|
+
}
|
package/tailwind.config.ts
CHANGED
|
@@ -375,18 +375,11 @@ const config: Config = {
|
|
|
375
375
|
from: { opacity: "0" },
|
|
376
376
|
to: { opacity: "1" },
|
|
377
377
|
},
|
|
378
|
-
// Endless chip strip (QuickActionMarquee): the track renders its items
|
|
379
|
-
// twice, so -50% lands exactly one copy over for a seamless loop.
|
|
380
|
-
"qa-marquee": {
|
|
381
|
-
from: { transform: "translateX(0)" },
|
|
382
|
-
to: { transform: "translateX(-50%)" },
|
|
383
|
-
},
|
|
384
378
|
},
|
|
385
379
|
animation: {
|
|
386
380
|
"accordion-down": "accordion-down 0.2s ease-out",
|
|
387
381
|
"accordion-up": "accordion-up 0.2s ease-out",
|
|
388
382
|
"fade-in": "fade-in 0.3s ease-out",
|
|
389
|
-
"qa-marquee": "qa-marquee var(--qa-marquee-duration,40s) linear infinite",
|
|
390
383
|
},
|
|
391
384
|
},
|
|
392
385
|
},
|