@flamingo-stack/openframe-frontend-core 0.0.455 → 0.0.456
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-DGPKGESY.js → chunk-2I3GN7WW.js} +4 -4
- package/dist/{chunk-NNDNJCJ3.cjs → chunk-6K6LAD6H.cjs} +18 -18
- package/dist/{chunk-NNDNJCJ3.cjs.map → chunk-6K6LAD6H.cjs.map} +1 -1
- package/dist/{chunk-RT7DIGTN.cjs → chunk-6TI3DCOG.cjs} +3 -3
- package/dist/{chunk-RT7DIGTN.cjs.map → chunk-6TI3DCOG.cjs.map} +1 -1
- package/dist/{chunk-YSKSBLCG.cjs → chunk-A7FTNFBX.cjs} +73 -73
- package/dist/{chunk-YSKSBLCG.cjs.map → chunk-A7FTNFBX.cjs.map} +1 -1
- package/dist/{chunk-XAQLBUCP.js → chunk-DAEKUR3J.js} +2 -2
- package/dist/{chunk-QSIZW6EX.js → chunk-DU6R7XNU.js} +2 -2
- package/dist/{chunk-D5PBUVGB.cjs → chunk-DYBATZJP.cjs} +5 -5
- package/dist/{chunk-D5PBUVGB.cjs.map → chunk-DYBATZJP.cjs.map} +1 -1
- package/dist/{chunk-IDCPYG7N.cjs → chunk-F2CCUPKC.cjs} +29 -29
- package/dist/{chunk-IDCPYG7N.cjs.map → chunk-F2CCUPKC.cjs.map} +1 -1
- package/dist/{chunk-EGTGRG7N.js → chunk-F3IBBAKV.js} +2 -2
- package/dist/{chunk-M5RT26WQ.cjs → chunk-GC63K4VD.cjs} +73 -73
- package/dist/{chunk-M5RT26WQ.cjs.map → chunk-GC63K4VD.cjs.map} +1 -1
- package/dist/{chunk-RZL7I6YN.cjs → chunk-GQZGCMRF.cjs} +7 -7
- package/dist/{chunk-RZL7I6YN.cjs.map → chunk-GQZGCMRF.cjs.map} +1 -1
- package/dist/{chunk-X62ICFPJ.js → chunk-ILUX3DXS.js} +2 -2
- package/dist/{chunk-XT6S6DYS.cjs → chunk-INH6SIP5.cjs} +12 -12
- package/dist/{chunk-XT6S6DYS.cjs.map → chunk-INH6SIP5.cjs.map} +1 -1
- package/dist/{chunk-KTGGM5WA.cjs → chunk-IOHQJOLR.cjs} +36 -12
- package/dist/chunk-IOHQJOLR.cjs.map +1 -0
- package/dist/{chunk-6W4ZEVIC.js → chunk-JHIT44TA.js} +3 -3
- package/dist/{chunk-BE77G7GW.cjs → chunk-KH5VTGCS.cjs} +40 -40
- package/dist/{chunk-BE77G7GW.cjs.map → chunk-KH5VTGCS.cjs.map} +1 -1
- package/dist/{chunk-YXCYQKAX.js → chunk-LAFCXJVT.js} +5 -5
- package/dist/{chunk-5YPKZM6Y.js → chunk-LMBPJAQ2.js} +6 -6
- package/dist/{chunk-BD3JBEUC.js → chunk-MK2YWJAA.js} +64 -40
- package/dist/chunk-MK2YWJAA.js.map +1 -0
- package/dist/{chunk-UJZ6O2ON.cjs → chunk-TXS2AMFV.cjs} +32 -32
- package/dist/{chunk-UJZ6O2ON.cjs.map → chunk-TXS2AMFV.cjs.map} +1 -1
- package/dist/{chunk-LB3OZE4B.js → chunk-U27TXTAK.js} +6 -6
- package/dist/{chunk-N4CHA7T7.js → chunk-UEKCD6WV.js} +131 -16
- package/dist/chunk-UEKCD6WV.js.map +1 -0
- package/dist/{chunk-FCVF4YAB.cjs → chunk-UHVVNVUE.cjs} +40 -40
- package/dist/{chunk-FCVF4YAB.cjs.map → chunk-UHVVNVUE.cjs.map} +1 -1
- package/dist/{chunk-2KUIA2AR.js → chunk-UXKSOJR7.js} +3 -3
- package/dist/{chunk-GNYV6HYO.cjs → chunk-VZNJ3RYI.cjs} +15 -15
- package/dist/{chunk-GNYV6HYO.cjs.map → chunk-VZNJ3RYI.cjs.map} +1 -1
- package/dist/{chunk-JUUPMMKI.js → chunk-YLTY4Z4B.js} +3 -3
- package/dist/{chunk-62AXZKYN.cjs → chunk-YMGOTPY7.cjs} +981 -866
- package/dist/chunk-YMGOTPY7.cjs.map +1 -0
- package/dist/{chunk-VNE5R4LQ.js → chunk-ZQHGSR3H.js} +5 -5
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/guide-welcome.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/chat/mingo-welcome.d.ts.map +1 -1
- package/dist/components/chat/quick-action-wall.d.ts +5 -1
- package/dist/components/chat/quick-action-wall.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +17 -0
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- 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/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +135 -135
- package/dist/components/index.js +11 -11
- 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 +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/marquee-wall.d.ts +8 -1
- package/dist/components/ui/marquee-wall.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-reset-on-page-hidden.d.ts +19 -0
- package/dist/hooks/ui/use-reset-on-page-hidden.d.ts.map +1 -0
- package/dist/hooks/use-near-viewport.d.ts +3 -1
- package/dist/hooks/use-near-viewport.d.ts.map +1 -1
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -2
- package/package.json +1 -1
- package/src/components/chat/chat-message-list.tsx +48 -5
- package/src/components/chat/embeddable-chat.tsx +31 -3
- package/src/components/chat/guide-welcome.tsx +1 -0
- package/src/components/chat/mingo-welcome.tsx +1 -0
- package/src/components/chat/quick-action-wall.tsx +7 -0
- package/src/components/chat/types/component.types.ts +17 -0
- package/src/components/features/cards-strip.tsx +16 -0
- package/src/components/ui/marquee-wall.tsx +160 -10
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-reset-on-page-hidden.ts +32 -0
- package/src/hooks/use-near-viewport.ts +36 -12
- package/dist/chunk-62AXZKYN.cjs.map +0 -1
- package/dist/chunk-BD3JBEUC.js.map +0 -1
- package/dist/chunk-KTGGM5WA.cjs.map +0 -1
- package/dist/chunk-N4CHA7T7.js.map +0 -1
- /package/dist/{chunk-DGPKGESY.js.map → chunk-2I3GN7WW.js.map} +0 -0
- /package/dist/{chunk-XAQLBUCP.js.map → chunk-DAEKUR3J.js.map} +0 -0
- /package/dist/{chunk-QSIZW6EX.js.map → chunk-DU6R7XNU.js.map} +0 -0
- /package/dist/{chunk-EGTGRG7N.js.map → chunk-F3IBBAKV.js.map} +0 -0
- /package/dist/{chunk-X62ICFPJ.js.map → chunk-ILUX3DXS.js.map} +0 -0
- /package/dist/{chunk-6W4ZEVIC.js.map → chunk-JHIT44TA.js.map} +0 -0
- /package/dist/{chunk-YXCYQKAX.js.map → chunk-LAFCXJVT.js.map} +0 -0
- /package/dist/{chunk-5YPKZM6Y.js.map → chunk-LMBPJAQ2.js.map} +0 -0
- /package/dist/{chunk-LB3OZE4B.js.map → chunk-U27TXTAK.js.map} +0 -0
- /package/dist/{chunk-2KUIA2AR.js.map → chunk-UXKSOJR7.js.map} +0 -0
- /package/dist/{chunk-JUUPMMKI.js.map → chunk-YLTY4Z4B.js.map} +0 -0
- /package/dist/{chunk-VNE5R4LQ.js.map → chunk-ZQHGSR3H.js.map} +0 -0
|
@@ -96,6 +96,8 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
96
96
|
isLoading = false,
|
|
97
97
|
isTyping = false,
|
|
98
98
|
autoScroll = true,
|
|
99
|
+
overscrollContain = true,
|
|
100
|
+
pinBottom = false,
|
|
99
101
|
showAvatars = true,
|
|
100
102
|
fullWidth = false,
|
|
101
103
|
contentClassName,
|
|
@@ -254,6 +256,42 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
254
256
|
}
|
|
255
257
|
}, [autoScroll, messages, dialogId, scrollToBottom, contentRef])
|
|
256
258
|
|
|
259
|
+
// ---- Passive-demo hard pin (pinBottom) ---------------------------
|
|
260
|
+
// Scripted in-page replays (the Fae/Mingo demos) have no human at the
|
|
261
|
+
// keyboard, so the newest streamed content must ALWAYS sit at the
|
|
262
|
+
// bottom. `useStickToBottom`'s smart follow only tracks while the
|
|
263
|
+
// viewer is "at bottom", which is unreliable for an assistant-only
|
|
264
|
+
// stream from a cold, non-bottom mount (the exact bug where the last
|
|
265
|
+
// reply landed below the fold). When `pinBottom` is set we snap the
|
|
266
|
+
// scroller to its full height — the SAME mechanism for BOTH demo
|
|
267
|
+
// surfaces, so Fae and Mingo behave 1:1.
|
|
268
|
+
//
|
|
269
|
+
// CRUCIAL: only re-pin when the content HEIGHT actually changed (a
|
|
270
|
+
// streamed token appended, or the conversation swapped/reset). A parent
|
|
271
|
+
// that recreates props on every render — EmbeddableChat in previewMode
|
|
272
|
+
// rebuilds its scripted state each render, churning the `messages` array
|
|
273
|
+
// identity — would otherwise fire this effect on every re-render and snap
|
|
274
|
+
// the scroller back to the bottom, trapping the visitor and blocking
|
|
275
|
+
// internal scroll (Mingo couldn't scroll while Fae, with stable memoized
|
|
276
|
+
// props, could). Skipping the pin when the height is UNCHANGED means an
|
|
277
|
+
// idle re-render leaves the scroller alone, so once the stream settles the
|
|
278
|
+
// visitor can scroll up in BOTH surfaces; a real growth (streaming) or a
|
|
279
|
+
// shrink (a new conversation) still re-pins to the fresh bottom.
|
|
280
|
+
// `useLayoutEffect` so the write lands before paint (no visible jump).
|
|
281
|
+
// Pair with `autoScroll={false}` so the library's spring doesn't fight.
|
|
282
|
+
const pinPrevHeightRef = useRef(0)
|
|
283
|
+
useLayoutEffect(() => {
|
|
284
|
+
if (!pinBottom) {
|
|
285
|
+
pinPrevHeightRef.current = 0
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const el = scrollRef.current
|
|
289
|
+
if (!el) return
|
|
290
|
+
if (el.scrollHeight === pinPrevHeightRef.current) return
|
|
291
|
+
pinPrevHeightRef.current = el.scrollHeight
|
|
292
|
+
el.scrollTop = el.scrollHeight
|
|
293
|
+
}, [pinBottom, messages, isTyping, pendingApprovals, scrollRef])
|
|
294
|
+
|
|
257
295
|
// ---- Prepend anchoring (load-older) ------------------------------
|
|
258
296
|
// The library doesn't preserve user position when content grows ABOVE
|
|
259
297
|
// the viewport. We do: from the pre-load geometry snapshot (taken in the
|
|
@@ -494,11 +532,16 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
494
532
|
<div
|
|
495
533
|
ref={setScrollRef}
|
|
496
534
|
className={cn(
|
|
497
|
-
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
535
|
+
"flex h-full w-full flex-col overflow-y-auto overflow-x-hidden flex-1",
|
|
536
|
+
// `overscroll-contain` (default ON, opt-out via `overscrollContain={false}`):
|
|
537
|
+
// reaching the top/bottom of the thread must NOT chain the wheel/
|
|
538
|
+
// touch scroll to the page behind the chat — e.g. the company-hub
|
|
539
|
+
// deck (native body scroll + sticky slide panels) would otherwise
|
|
540
|
+
// advance slides while you scroll the chat (fixed in #1501; kept as
|
|
541
|
+
// the prop default here). Passive in-page DEMO chats disable it so
|
|
542
|
+
// the surrounding page keeps scrolling normally when the pointer is
|
|
543
|
+
// over the (non-interactive) thread.
|
|
544
|
+
overscrollContain && "overscroll-contain",
|
|
502
545
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
|
|
503
546
|
className,
|
|
504
547
|
)}
|
|
@@ -2264,7 +2264,19 @@ function EmbeddableChatInner({
|
|
|
2264
2264
|
<ChatMessageList
|
|
2265
2265
|
messages={messages}
|
|
2266
2266
|
isTyping={chatLoading}
|
|
2267
|
-
|
|
2267
|
+
// Real drawer: the library's smart follow. Passive in-page
|
|
2268
|
+
// demo (previewMode): deterministic hard pin instead — a
|
|
2269
|
+
// scripted assistant-only stream from a cold mount never
|
|
2270
|
+
// satisfies the library's "at bottom" gate, so the reply
|
|
2271
|
+
// landed below the fold. `pinBottom` snaps to bottom on
|
|
2272
|
+
// every frame; identical mechanism to the Fae demo box, so
|
|
2273
|
+
// both surfaces behave 1:1.
|
|
2274
|
+
autoScroll={!previewMode}
|
|
2275
|
+
pinBottom={previewMode}
|
|
2276
|
+
// Passive in-page demos (previewMode) let the surrounding
|
|
2277
|
+
// page scroll over the thread; the real drawer keeps
|
|
2278
|
+
// containment (deck slide-scroll fix).
|
|
2279
|
+
overscrollContain={!previewMode}
|
|
2268
2280
|
assistantType="mingo"
|
|
2269
2281
|
assistantIcon={mingoAssistantIcon}
|
|
2270
2282
|
renderEntityCard={renderEntityCard}
|
|
@@ -2272,11 +2284,27 @@ function EmbeddableChatInner({
|
|
|
2272
2284
|
renderContextItem={renderContextItem}
|
|
2273
2285
|
renderMention={renderMention}
|
|
2274
2286
|
NavLinkAnchor={NavLinkAnchorViaRuntime}
|
|
2275
|
-
//
|
|
2287
|
+
// Real Mingo drawer: hide the message-list scrollbar
|
|
2276
2288
|
// (scroll stays functional). Scoped here via `className`
|
|
2277
2289
|
// instead of `ChatMessageList` itself, so other list
|
|
2278
2290
|
// consumers (host chat, tickets) keep their thin bar.
|
|
2279
|
-
|
|
2291
|
+
//
|
|
2292
|
+
// `previewMode` (the passive in-page demo) instead:
|
|
2293
|
+
// • KEEP the default thin scrollbar — the "elevator" —
|
|
2294
|
+
// so the Mingo demo's scroll controls match the Fae
|
|
2295
|
+
// demo box (which renders the default ChatMessageList
|
|
2296
|
+
// scrollbar) 1:1.
|
|
2297
|
+
// • Re-enable pointer events on the scroller: previewMode
|
|
2298
|
+
// puts `pointer-events-none` on the whole panel (line
|
|
2299
|
+
// ~1981) so the demo doesn't trap clicks, but that also
|
|
2300
|
+
// killed wheel/touch scroll on the thread (the Mingo
|
|
2301
|
+
// embed couldn't scroll while the Fae box could). Only
|
|
2302
|
+
// the scroller re-enables; composer + chrome stay inert.
|
|
2303
|
+
className={
|
|
2304
|
+
previewMode
|
|
2305
|
+
? 'flex-1 pointer-events-auto'
|
|
2306
|
+
: 'flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
|
2307
|
+
}
|
|
2280
2308
|
// No inner `px`/`pb`: the panel wrapper already pads with
|
|
2281
2309
|
// `p-[var(--spacing-system-m)]`. The default content class
|
|
2282
2310
|
// adds `px-[var(--spacing-system-m)]` + `pb-…xs`, which
|
|
@@ -71,6 +71,10 @@ export interface QuickActionWallProps {
|
|
|
71
71
|
/** Marquee on/off (auto-off when content fits / reduced motion / loading). */
|
|
72
72
|
animate?: boolean
|
|
73
73
|
pauseOnHover?: boolean
|
|
74
|
+
/** Opt-in manual drag + wheel scroll (forwarded to {@link MarqueeWall}) — the
|
|
75
|
+
* auto-marquee pauses while the user drags/wheels and resumes from there.
|
|
76
|
+
* Enabled on the embeddable-chat quick-action walls. */
|
|
77
|
+
dragScroll?: boolean
|
|
74
78
|
fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
|
|
75
79
|
/** Color behind the wall — the fades blend into it. */
|
|
76
80
|
fadeColor?: string
|
|
@@ -139,6 +143,7 @@ export function QuickActionWall({
|
|
|
139
143
|
speed,
|
|
140
144
|
animate = true,
|
|
141
145
|
pauseOnHover,
|
|
146
|
+
dragScroll = false,
|
|
142
147
|
fade,
|
|
143
148
|
fadeColor,
|
|
144
149
|
fadeSize,
|
|
@@ -206,6 +211,7 @@ export function QuickActionWall({
|
|
|
206
211
|
speed={speed}
|
|
207
212
|
animate={animate && !loading}
|
|
208
213
|
pauseOnHover={pauseOnHover}
|
|
214
|
+
dragScroll={dragScroll}
|
|
209
215
|
copyGap={gap}
|
|
210
216
|
contentClassName={cn('flex items-center', contentClassName)}
|
|
211
217
|
contentStyle={{ gap }}
|
|
@@ -229,6 +235,7 @@ export function QuickActionWall({
|
|
|
229
235
|
// Skeleton walls stay put — the marquee starts when real chips land.
|
|
230
236
|
animate={animate && !loading}
|
|
231
237
|
pauseOnHover={pauseOnHover}
|
|
238
|
+
dragScroll={dragScroll}
|
|
232
239
|
fade={fade}
|
|
233
240
|
fadeColor={fadeColor}
|
|
234
241
|
fadeSize={fadeSize}
|
|
@@ -222,6 +222,23 @@ export interface ChatMessageListProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
222
222
|
typingMessage?: string
|
|
223
223
|
smoothScroll?: boolean
|
|
224
224
|
autoScroll?: boolean
|
|
225
|
+
/** `overscroll-behavior: contain` on the scroller (default true) — stops
|
|
226
|
+
* wheel/touch scroll from chaining to the page at the thread's edges (the
|
|
227
|
+
* deck / drawer need this). Passive in-page demo chats set `false` so the
|
|
228
|
+
* page keeps scrolling normally over the non-interactive thread. */
|
|
229
|
+
overscrollContain?: boolean
|
|
230
|
+
/** Deterministic stick-to-bottom for PASSIVE scripted replays (the in-page
|
|
231
|
+
* Fae/Mingo demos). Whenever the scroll HEIGHT changes — a streamed token
|
|
232
|
+
* grows the thread, or a new conversation replaces it — the scroller is
|
|
233
|
+
* hard-snapped to its full height, so the newest streamed line is always
|
|
234
|
+
* visible even though no human is at the keyboard. Unlike `autoScroll` (the
|
|
235
|
+
* library's smart follow, which only tracks while the viewer is "at bottom"
|
|
236
|
+
* and is unreliable for an assistant-only stream from a cold, non-bottom
|
|
237
|
+
* mount), this pins on real growth without that gate. A same-height idle
|
|
238
|
+
* re-render does NOT re-pin, so once the stream settles the viewer can freely
|
|
239
|
+
* scroll up. Pair with `autoScroll={false}` so the library's spring doesn't
|
|
240
|
+
* run in parallel. */
|
|
241
|
+
pinBottom?: boolean
|
|
225
242
|
showAvatars?: boolean
|
|
226
243
|
/** Same `fullWidth` semantics as `ChatHeaderProps.fullWidth` — drops
|
|
227
244
|
* the inner content wrapper's `max-w-ods-content-narrow` so messages
|
|
@@ -45,6 +45,7 @@ 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
47
|
import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine';
|
|
48
|
+
import { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden';
|
|
48
49
|
import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus';
|
|
49
50
|
import { Button } from '../ui/button';
|
|
50
51
|
import { SECTION_HEADING_CLASS } from '../layout/page-heading';
|
|
@@ -424,6 +425,20 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
424
425
|
const current = activeKeyRef.current;
|
|
425
426
|
if (current !== null) deactivate(current);
|
|
426
427
|
}, [deactivate]);
|
|
428
|
+
// Unconditional hover-clear (any pointer type) — the self-heal for a MISSED
|
|
429
|
+
// `pointerleave`. A leave can be swallowed by a tab blur, an interrupted
|
|
430
|
+
// pointer (pointercancel), or an overlay mounting under the cursor, leaving
|
|
431
|
+
// `inside` stuck true so the marquee freezes FOREVER ("gets stuck every once
|
|
432
|
+
// in a while"). Reset it on pointercancel + whenever the tab hides / the
|
|
433
|
+
// window loses focus; the next real hover re-arms it. Can only un-stick.
|
|
434
|
+
const clearHover = useCallback(() => {
|
|
435
|
+
hoverPointerRef.current.inside = false;
|
|
436
|
+
const current = activeKeyRef.current;
|
|
437
|
+
if (current !== null) deactivate(current);
|
|
438
|
+
}, [deactivate]);
|
|
439
|
+
// Shared SSOT self-heal: un-stick the hover flag whenever the tab hides / the
|
|
440
|
+
// window loses focus (a `pointerleave`/`pointercancel` can be swallowed).
|
|
441
|
+
useResetOnPageHidden(clearHover);
|
|
427
442
|
/** Re-sync hover only when the track actually moved under the pointer.
|
|
428
443
|
* KNOWN HOT-PATH COST (intentional): while the marquee runs with the
|
|
429
444
|
* pointer parked over strip whitespace (no card → not paused), scrollLeft
|
|
@@ -654,6 +669,7 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
654
669
|
onPointerEnter={onHoverPointerEnter}
|
|
655
670
|
onPointerMove={onHoverPointerMove}
|
|
656
671
|
onPointerLeave={onHoverPointerLeave}
|
|
672
|
+
onPointerCancel={clearHover}
|
|
657
673
|
onScroll={onSeamWarp}
|
|
658
674
|
>
|
|
659
675
|
<div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
|
|
@@ -4,6 +4,7 @@ import * as React from 'react'
|
|
|
4
4
|
import { cn } from '../../utils/cn'
|
|
5
5
|
import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine'
|
|
6
6
|
import { useMediaQuery } from '../../hooks/ui/use-media-query'
|
|
7
|
+
import { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden'
|
|
7
8
|
import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus'
|
|
8
9
|
import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport'
|
|
9
10
|
|
|
@@ -153,6 +154,13 @@ export interface MarqueeWallProps {
|
|
|
153
154
|
* must never move under a pointing cursor (required for interactive chip
|
|
154
155
|
* walls). Default true. */
|
|
155
156
|
pauseOnHover?: boolean
|
|
157
|
+
/** Opt-in MANUAL scroll: pointer-drag + horizontal wheel move the wall along
|
|
158
|
+
* its axis (transform-based — no inner scroller is created, so it stays
|
|
159
|
+
* deck-safe). The auto-marquee pauses during the interaction and briefly
|
|
160
|
+
* after, then resumes drifting from wherever the user left it. Off by
|
|
161
|
+
* default (decorative walls stay auto-only); enabled on the embeddable-chat
|
|
162
|
+
* quick-action walls so users can browse the actions by hand. */
|
|
163
|
+
dragScroll?: boolean
|
|
156
164
|
/** Edge fade(s) — the "there's more" affordance. ONE spelling of the
|
|
157
165
|
* clip-and-fade across every wall surface. */
|
|
158
166
|
fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
|
|
@@ -295,6 +303,7 @@ export function MarqueeWall({
|
|
|
295
303
|
speed = 40,
|
|
296
304
|
animate = true,
|
|
297
305
|
pauseOnHover = true,
|
|
306
|
+
dragScroll = false,
|
|
298
307
|
fade,
|
|
299
308
|
fadeColor = 'var(--color-bg)',
|
|
300
309
|
fadeSize,
|
|
@@ -386,6 +395,9 @@ export function MarqueeWall({
|
|
|
386
395
|
|
|
387
396
|
// ---- pause-reason set ------------------------------------------------------
|
|
388
397
|
const pointerInsideRef = React.useRef(false)
|
|
398
|
+
const clearPointerInside = React.useCallback(() => {
|
|
399
|
+
pointerInsideRef.current = false
|
|
400
|
+
}, [])
|
|
389
401
|
const onPointerEnter = React.useCallback((e: React.PointerEvent) => {
|
|
390
402
|
if (e.pointerType === 'touch') return
|
|
391
403
|
pointerInsideRef.current = true
|
|
@@ -394,6 +406,21 @@ export function MarqueeWall({
|
|
|
394
406
|
if (e.pointerType === 'touch') return
|
|
395
407
|
pointerInsideRef.current = false
|
|
396
408
|
}, [])
|
|
409
|
+
// Stale-flag self-heal (shared SSOT): if a `pointerleave` is MISSED (tab blur,
|
|
410
|
+
// overlay under the cursor, interrupted pointer), `inside` stays stuck true and
|
|
411
|
+
// the wall freezes forever — so un-stick it whenever the tab hides / the window
|
|
412
|
+
// loses focus. The next real pointer/enter re-arms it.
|
|
413
|
+
useResetOnPageHidden(clearPointerInside)
|
|
414
|
+
|
|
415
|
+
// ---- manual drag / wheel scroll (opt-in via `dragScroll`) ------------------
|
|
416
|
+
// Transform-based (no inner scroller → deck-safe): drag/wheel move the
|
|
417
|
+
// engine's float position directly; `draggingRef` + a short suppress window
|
|
418
|
+
// pause the auto-marquee during and just after the interaction so it resumes
|
|
419
|
+
// drifting from where the user left it. Refs declared here (before the engine)
|
|
420
|
+
// so `isPaused` can read them; the handlers (which need the engine's `wrap`)
|
|
421
|
+
// are defined after the engine call.
|
|
422
|
+
const draggingRef = React.useRef(false)
|
|
423
|
+
const dragSuppressUntilRef = React.useRef(0)
|
|
397
424
|
|
|
398
425
|
// ---- transform driver ------------------------------------------------------
|
|
399
426
|
const applyTransform = React.useCallback(
|
|
@@ -414,6 +441,26 @@ export function MarqueeWall({
|
|
|
414
441
|
},
|
|
415
442
|
[axis, reverse, trackId],
|
|
416
443
|
)
|
|
444
|
+
// Sync-aware position writer — THE single entry point for every position
|
|
445
|
+
// update (the engine's rAF apply AND the manual drag/wheel handlers). With a
|
|
446
|
+
// `sync` controller it publishes to the shared position and drives every peer
|
|
447
|
+
// wall's transform; without one it just writes our own. Routing drag/wheel
|
|
448
|
+
// through here (instead of raw `applyTransform`) keeps a wall that is both
|
|
449
|
+
// `sync`-ed and `dragScroll`-ed in lockstep with its peers — a direct
|
|
450
|
+
// `applyTransform` would move only this wall and leave `sync.position` + peers
|
|
451
|
+
// stale. Members' own `apply` stays the raw `applyTransform` (no re-publish),
|
|
452
|
+
// so the `forEach` fan-out can't recurse.
|
|
453
|
+
const applyPos = React.useCallback(
|
|
454
|
+
(pos: number) => {
|
|
455
|
+
if (sync) {
|
|
456
|
+
sync.position = pos
|
|
457
|
+
sync.members.forEach(m => m.apply(pos))
|
|
458
|
+
} else {
|
|
459
|
+
applyTransform(pos)
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
[sync, applyTransform],
|
|
463
|
+
)
|
|
417
464
|
// Clear any residual offset only when the marquee turns off STRUCTURALLY
|
|
418
465
|
// (content shrank so it fits, reduced-motion flipped) — the static wall must
|
|
419
466
|
// sit at its natural origin. A mere run-gate pause (`animate` false) does NOT
|
|
@@ -457,18 +504,16 @@ export function MarqueeWall({
|
|
|
457
504
|
}
|
|
458
505
|
}, [sync, applyTransform])
|
|
459
506
|
|
|
460
|
-
const { posRef } = useMarqueeEngine({
|
|
507
|
+
const { posRef, wrap } = useMarqueeEngine({
|
|
461
508
|
active: marqueeActive && nearViewport && isDriver,
|
|
462
509
|
speed,
|
|
463
|
-
isPaused:
|
|
464
|
-
(pauseOnHover && pointerInsideRef.current) ||
|
|
510
|
+
isPaused: now =>
|
|
511
|
+
(pauseOnHover && pointerInsideRef.current) ||
|
|
512
|
+
draggingRef.current ||
|
|
513
|
+
now < dragSuppressUntilRef.current ||
|
|
514
|
+
document.visibilityState === 'hidden',
|
|
465
515
|
getWrapSize: () => wrapSizeRef.current,
|
|
466
|
-
apply:
|
|
467
|
-
? pos => {
|
|
468
|
-
sync.position = pos
|
|
469
|
-
sync.members.forEach(m => m.apply(pos))
|
|
470
|
-
}
|
|
471
|
-
: applyTransform,
|
|
516
|
+
apply: applyPos,
|
|
472
517
|
// Synced pairs: a re-elected driver resumes from the pair's live position
|
|
473
518
|
// (engine seeds from readBack on activation) instead of restarting at 0.
|
|
474
519
|
readBack: sync ? () => sync.position : undefined,
|
|
@@ -490,17 +535,122 @@ export function MarqueeWall({
|
|
|
490
535
|
}
|
|
491
536
|
}, [marqueeMounted, reverse, posRef])
|
|
492
537
|
|
|
538
|
+
// ---- manual drag / wheel handlers (only wired when `dragScroll`) -----------
|
|
539
|
+
const DRAG_RESUME_MS = 1200
|
|
540
|
+
const dragStartRef = React.useRef({ coord: 0, pos: 0 })
|
|
541
|
+
const dragMovedRef = React.useRef(false)
|
|
542
|
+
const suppressClickRef = React.useRef(false)
|
|
543
|
+
|
|
544
|
+
const onDragPointerDown = React.useCallback((e: React.PointerEvent) => {
|
|
545
|
+
if (!marqueeMounted) return
|
|
546
|
+
draggingRef.current = true
|
|
547
|
+
dragMovedRef.current = false
|
|
548
|
+
// Clear any click-suppress left armed by a prior drag that ended WITHOUT a
|
|
549
|
+
// synthetic click on this container (pointercancel, or the pointer released
|
|
550
|
+
// off the wall) — otherwise `onDragClickCapture` would swallow this fresh
|
|
551
|
+
// tap's action. A new press is unambiguously a new intent.
|
|
552
|
+
suppressClickRef.current = false
|
|
553
|
+
dragStartRef.current = { coord: axis === 'y' ? e.clientY : e.clientX, pos: posRef.current }
|
|
554
|
+
try { containerRef.current?.setPointerCapture(e.pointerId) } catch { /* capture is best-effort */ }
|
|
555
|
+
}, [marqueeMounted, axis, posRef])
|
|
556
|
+
|
|
557
|
+
const onDragPointerMove = React.useCallback((e: React.PointerEvent) => {
|
|
558
|
+
if (!draggingRef.current) return
|
|
559
|
+
const delta = (axis === 'y' ? e.clientY : e.clientX) - dragStartRef.current.coord
|
|
560
|
+
if (Math.abs(delta) > 3) dragMovedRef.current = true
|
|
561
|
+
// Track follows the pointer. `applyPos` maps pos→translate as `-pos`
|
|
562
|
+
// (forward) / `pos-wrap` (reverse), so a rightward/downward drag (delta > 0)
|
|
563
|
+
// must DECREASE pos on a forward wall and INCREASE it on a reverse one.
|
|
564
|
+
// Via `applyPos` (not raw `applyTransform`) so synced peer walls follow.
|
|
565
|
+
posRef.current = wrap(dragStartRef.current.pos + (reverse ? delta : -delta))
|
|
566
|
+
applyPos(posRef.current)
|
|
567
|
+
dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS
|
|
568
|
+
}, [axis, reverse, wrap, posRef, applyPos])
|
|
569
|
+
|
|
570
|
+
const onDragPointerUp = React.useCallback((e: React.PointerEvent) => {
|
|
571
|
+
if (!draggingRef.current) return
|
|
572
|
+
draggingRef.current = false
|
|
573
|
+
// A real drag ends over a chip → swallow the synthetic click so the drag
|
|
574
|
+
// doesn't fire the chip's action.
|
|
575
|
+
if (dragMovedRef.current) suppressClickRef.current = true
|
|
576
|
+
dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS
|
|
577
|
+
try { containerRef.current?.releasePointerCapture(e.pointerId) } catch { /* best-effort */ }
|
|
578
|
+
}, [])
|
|
579
|
+
|
|
580
|
+
const onDragClickCapture = React.useCallback((e: React.MouseEvent) => {
|
|
581
|
+
if (suppressClickRef.current) {
|
|
582
|
+
suppressClickRef.current = false
|
|
583
|
+
e.preventDefault()
|
|
584
|
+
e.stopPropagation()
|
|
585
|
+
}
|
|
586
|
+
}, [])
|
|
587
|
+
|
|
588
|
+
// Along-axis wheel → move the wall. Wired as a NATIVE non-passive listener
|
|
589
|
+
// (not React's `onWheel`, which React attaches passively so `preventDefault`
|
|
590
|
+
// is a no-op): once WE consume an along-axis wheel we must `preventDefault` to
|
|
591
|
+
// suppress the browser's horizontal history-swipe (two-finger trackpad) on an
|
|
592
|
+
// x-wall. An x-wall ignores vertical page-scroll wheels (and vice-versa) so the
|
|
593
|
+
// surrounding page still scrolls normally.
|
|
594
|
+
React.useEffect(() => {
|
|
595
|
+
const el = containerRef.current
|
|
596
|
+
if (!el || !dragScroll || !marqueeMounted) return
|
|
597
|
+
const onWheel = (e: WheelEvent) => {
|
|
598
|
+
// Only claim the DOMINANT-axis wheel (per axis), so a cross-axis wheel
|
|
599
|
+
// still scrolls the page: an x-wall ignores vertical wheels, a y-wall
|
|
600
|
+
// ignores horizontal ones.
|
|
601
|
+
const primary =
|
|
602
|
+
axis === 'y'
|
|
603
|
+
? Math.abs(e.deltaY) >= Math.abs(e.deltaX)
|
|
604
|
+
? e.deltaY
|
|
605
|
+
: 0
|
|
606
|
+
: Math.abs(e.deltaX) >= Math.abs(e.deltaY)
|
|
607
|
+
? e.deltaX
|
|
608
|
+
: 0
|
|
609
|
+
if (primary === 0) return
|
|
610
|
+
e.preventDefault()
|
|
611
|
+
// Via `applyPos` (not raw `applyTransform`) so synced peer walls follow.
|
|
612
|
+
posRef.current = wrap(posRef.current + (reverse ? -primary : primary))
|
|
613
|
+
applyPos(posRef.current)
|
|
614
|
+
dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS
|
|
615
|
+
}
|
|
616
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
617
|
+
return () => el.removeEventListener('wheel', onWheel)
|
|
618
|
+
}, [dragScroll, marqueeMounted, axis, reverse, wrap, posRef, applyPos])
|
|
619
|
+
|
|
493
620
|
// ---- render ----------------------------------------------------------------
|
|
494
621
|
const renderContent = (isClone: boolean) =>
|
|
495
622
|
typeof children === 'function' ? children({ isClone }) : children
|
|
496
623
|
|
|
624
|
+
// Reduced-motion static fallback: when the marquee does NOT mount (chiefly
|
|
625
|
+
// prefers-reduced-motion) but the content OVERFLOWS, the drag/transform path
|
|
626
|
+
// is inert — so on a `dragScroll` wall fall back to NATIVE
|
|
627
|
+
// overflow scroll along the axis. Otherwise the overflowing chips would be
|
|
628
|
+
// clipped by `overflow-hidden` with no way to reach them (a11y regression vs
|
|
629
|
+
// the old wrapping row). The visible thin scrollbar (global `*` rule) is the
|
|
630
|
+
// right affordance for reduced-motion users. No engine needed — the browser
|
|
631
|
+
// scrolls the single static track.
|
|
632
|
+
const staticScrollable = dragScroll && overflows && !marqueeMounted
|
|
633
|
+
|
|
497
634
|
return (
|
|
498
635
|
<div
|
|
499
636
|
ref={containerRef}
|
|
500
|
-
className={cn(
|
|
637
|
+
className={cn(
|
|
638
|
+
'relative',
|
|
639
|
+
staticScrollable ? (axis === 'y' ? 'overflow-y-auto' : 'overflow-x-auto') : 'overflow-hidden',
|
|
640
|
+
dragScroll && marqueeMounted && 'cursor-grab active:cursor-grabbing select-none',
|
|
641
|
+
className,
|
|
642
|
+
)}
|
|
643
|
+
// x-walls let vertical page-scroll pass (`pan-y`) and own horizontal
|
|
644
|
+
// drags; y-walls the reverse. Only when drag is actually live.
|
|
645
|
+
style={dragScroll && marqueeMounted ? { touchAction: axis === 'y' ? 'pan-x' : 'pan-y' } : undefined}
|
|
501
646
|
data-marquee-track={trackId}
|
|
502
647
|
onPointerEnter={onPointerEnter}
|
|
503
648
|
onPointerLeave={onPointerLeave}
|
|
649
|
+
onPointerDown={dragScroll ? onDragPointerDown : undefined}
|
|
650
|
+
onPointerMove={dragScroll ? onDragPointerMove : undefined}
|
|
651
|
+
onPointerUp={dragScroll ? onDragPointerUp : undefined}
|
|
652
|
+
onPointerCancel={dragScroll ? onDragPointerUp : undefined}
|
|
653
|
+
onClickCapture={dragScroll ? onDragClickCapture : undefined}
|
|
504
654
|
>
|
|
505
655
|
<div
|
|
506
656
|
ref={trackRef}
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './use-media-query'
|
|
|
12
12
|
export * from './use-memoized-callback'
|
|
13
13
|
export * from './use-notification-permission'
|
|
14
14
|
export * from './use-onboarding-state'
|
|
15
|
+
export * from './use-reset-on-page-hidden'
|
|
15
16
|
export * from './use-search'
|
|
16
17
|
export * from './use-suppress-clone-focus'
|
|
17
18
|
export * from './use-table-pagination'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs `reset` whenever the tab is hidden or the window loses focus — exactly the
|
|
5
|
+
* moments a `pointerleave` / `pointercancel` can be SWALLOWED (tab blur, an
|
|
6
|
+
* overlay mounting under the cursor, an interrupted pointer), which otherwise
|
|
7
|
+
* leaves a hover/"inside" flag stuck `true` and freezes a marquee FOREVER. The
|
|
8
|
+
* next real pointer/hover re-arms the flag.
|
|
9
|
+
*
|
|
10
|
+
* SSOT self-heal for every marquee surface (MarqueeWall, CardsStrip) so the
|
|
11
|
+
* blur + `visibilitychange` wiring lives in one place. `reset` almost always
|
|
12
|
+
* only UN-sticks state. The one edge: on `blur` WITHOUT the tab hiding (a second
|
|
13
|
+
* window focused beside this one), clearing the hover-pause can let the marquee
|
|
14
|
+
* resume under a still-stationary cursor in the now-unfocused window — it
|
|
15
|
+
* re-pauses on the next `pointermove`/`pointerenter`. We keep the `blur` reset
|
|
16
|
+
* anyway because it's the only signal that catches a tab-blur that swallows the
|
|
17
|
+
* `pointerleave` without a `visibilitychange`. Pass a STABLE `reset` (memoize
|
|
18
|
+
* with `useCallback`) — the listeners re-attach whenever its identity changes.
|
|
19
|
+
*/
|
|
20
|
+
export function useResetOnPageHidden(reset: () => void): void {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const onVisibility = () => {
|
|
23
|
+
if (document.visibilityState === 'hidden') reset()
|
|
24
|
+
}
|
|
25
|
+
window.addEventListener('blur', reset)
|
|
26
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
27
|
+
return () => {
|
|
28
|
+
window.removeEventListener('blur', reset)
|
|
29
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
30
|
+
}
|
|
31
|
+
}, [reset])
|
|
32
|
+
}
|
|
@@ -29,19 +29,39 @@
|
|
|
29
29
|
|
|
30
30
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
31
31
|
|
|
32
|
-
// Per-rootMargin IO map. Multiple call sites with different
|
|
33
|
-
// get their own singleton observer.
|
|
32
|
+
// Per-(rootMargin, threshold) IO map. Multiple call sites with different
|
|
33
|
+
// margins/thresholds each get their own singleton observer.
|
|
34
34
|
const observers = new Map<string, IntersectionObserver>();
|
|
35
35
|
const subscribers = new WeakMap<Element, () => void>();
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Slack for the explicit ratio check: an element sized to exactly meet the
|
|
38
|
+
// threshold can report a ratio a hair under it (sub-pixel / float rounding), so
|
|
39
|
+
// accept `ratio >= threshold - EPSILON`. With `threshold: 0` this is a no-op
|
|
40
|
+
// (ratio is always ≥ 0 > -EPSILON) — the `isIntersecting` check alone gates.
|
|
41
|
+
const THRESHOLD_EPSILON = 0.01;
|
|
42
|
+
|
|
43
|
+
/** Stable map key for an observer config. */
|
|
44
|
+
function observerKey(rootMargin: string, threshold: number): string {
|
|
45
|
+
return `${rootMargin}|${threshold}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getObserverFor(rootMargin: string, threshold: number): IntersectionObserver {
|
|
49
|
+
const key = observerKey(rootMargin, threshold);
|
|
50
|
+
const existing = observers.get(key);
|
|
39
51
|
if (existing) return existing;
|
|
40
52
|
|
|
41
53
|
const io = new IntersectionObserver(
|
|
42
54
|
(entries) => {
|
|
43
55
|
entries.forEach((entry) => {
|
|
56
|
+
// Enforce the threshold EXPLICITLY. `isIntersecting` is spec-defined as
|
|
57
|
+
// `intersectionRatio > 0` (any overlap), so the initial `observe()`
|
|
58
|
+
// firing can report `true` at, say, 20% visible even with
|
|
59
|
+
// `threshold: 0.5` — which would prematurely pass the gate. Require both
|
|
60
|
+
// an intersection AND the ratio to actually meet the threshold (minus a
|
|
61
|
+
// small epsilon for the float rounding where the ratio lands a hair
|
|
62
|
+
// under the exact value).
|
|
44
63
|
if (!entry.isIntersecting) return;
|
|
64
|
+
if (entry.intersectionRatio < threshold - THRESHOLD_EPSILON) return;
|
|
45
65
|
// Race-safe: re-read the callback at fire time. A late IO firing
|
|
46
66
|
// after cleanup must not invoke a stale callback.
|
|
47
67
|
const cb = subscribers.get(entry.target);
|
|
@@ -52,9 +72,9 @@ function getObserverFor(rootMargin: string): IntersectionObserver {
|
|
|
52
72
|
}
|
|
53
73
|
});
|
|
54
74
|
},
|
|
55
|
-
{ rootMargin }
|
|
75
|
+
{ rootMargin, threshold }
|
|
56
76
|
);
|
|
57
|
-
observers.set(
|
|
77
|
+
observers.set(key, io);
|
|
58
78
|
return io;
|
|
59
79
|
}
|
|
60
80
|
|
|
@@ -78,12 +98,16 @@ export interface UseNearViewportResult<T extends Element = HTMLElement> {
|
|
|
78
98
|
* '500px' = element starts mounting 500px before scroll-in.
|
|
79
99
|
* '1000px' = a full viewport's worth of lookahead.
|
|
80
100
|
* '0px' = strict on-screen detection.
|
|
101
|
+
* @param threshold Fraction of the element that must be visible before firing
|
|
102
|
+
* (0 = any pixel — the default; 0.5 = at least half on-screen).
|
|
81
103
|
*/
|
|
82
104
|
export function useNearViewport<T extends Element = HTMLElement>(
|
|
83
|
-
rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN
|
|
105
|
+
rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN,
|
|
106
|
+
threshold: number = 0
|
|
84
107
|
): UseNearViewportResult<T> {
|
|
85
108
|
const [isNear, setIsNear] = useState(false);
|
|
86
109
|
const elRef = useRef<T | null>(null);
|
|
110
|
+
const key = observerKey(rootMargin, threshold);
|
|
87
111
|
|
|
88
112
|
// Subscribe/unsubscribe on element change.
|
|
89
113
|
const ref = useCallback(
|
|
@@ -96,7 +120,7 @@ export function useNearViewport<T extends Element = HTMLElement>(
|
|
|
96
120
|
const stillOurs = subscribers.get(prev);
|
|
97
121
|
if (stillOurs) {
|
|
98
122
|
subscribers.delete(prev);
|
|
99
|
-
observers.get(
|
|
123
|
+
observers.get(key)?.unobserve(prev);
|
|
100
124
|
}
|
|
101
125
|
}
|
|
102
126
|
|
|
@@ -105,9 +129,9 @@ export function useNearViewport<T extends Element = HTMLElement>(
|
|
|
105
129
|
|
|
106
130
|
const cb = () => setIsNear(true);
|
|
107
131
|
subscribers.set(node, cb);
|
|
108
|
-
getObserverFor(rootMargin).observe(node);
|
|
132
|
+
getObserverFor(rootMargin, threshold).observe(node);
|
|
109
133
|
},
|
|
110
|
-
[rootMargin]
|
|
134
|
+
[key, rootMargin, threshold]
|
|
111
135
|
);
|
|
112
136
|
|
|
113
137
|
// Unsubscribe on unmount. Identity check guards the StrictMode race.
|
|
@@ -117,10 +141,10 @@ export function useNearViewport<T extends Element = HTMLElement>(
|
|
|
117
141
|
if (!el) return;
|
|
118
142
|
if (subscribers.get(el)) {
|
|
119
143
|
subscribers.delete(el);
|
|
120
|
-
observers.get(
|
|
144
|
+
observers.get(key)?.unobserve(el);
|
|
121
145
|
}
|
|
122
146
|
};
|
|
123
|
-
}, [
|
|
147
|
+
}, [key]);
|
|
124
148
|
|
|
125
149
|
return { ref, isNear };
|
|
126
150
|
}
|