@flamingo-stack/openframe-frontend-core 0.0.510 → 0.0.511
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-GE3JUTW2.js → chunk-27OKD7ZV.js} +44 -14
- package/dist/chunk-27OKD7ZV.js.map +1 -0
- package/dist/{chunk-2RLI5XVI.cjs → chunk-2CE725NN.cjs} +16 -16
- package/dist/{chunk-2RLI5XVI.cjs.map → chunk-2CE725NN.cjs.map} +1 -1
- package/dist/{chunk-Q4K3TGNP.cjs → chunk-3NECEXDF.cjs} +74 -44
- package/dist/chunk-3NECEXDF.cjs.map +1 -0
- package/dist/{chunk-RJ3D3PE2.cjs → chunk-4SG55XVA.cjs} +9 -9
- package/dist/{chunk-RJ3D3PE2.cjs.map → chunk-4SG55XVA.cjs.map} +1 -1
- package/dist/{chunk-SYZFFRVD.cjs → chunk-5BAGE7AI.cjs} +13 -13
- package/dist/{chunk-SYZFFRVD.cjs.map → chunk-5BAGE7AI.cjs.map} +1 -1
- package/dist/{chunk-6FIDTXYC.js → chunk-7AQSXQEL.js} +3 -3
- package/dist/{chunk-6TL5XIAW.cjs → chunk-7YV67K3I.cjs} +119 -119
- package/dist/{chunk-6TL5XIAW.cjs.map → chunk-7YV67K3I.cjs.map} +1 -1
- package/dist/{chunk-5CDPRCPS.cjs → chunk-A5YZUV6B.cjs} +23 -23
- package/dist/{chunk-5CDPRCPS.cjs.map → chunk-A5YZUV6B.cjs.map} +1 -1
- package/dist/{chunk-WSAPKKDU.cjs → chunk-C4EUXZSA.cjs} +9 -9
- package/dist/{chunk-WSAPKKDU.cjs.map → chunk-C4EUXZSA.cjs.map} +1 -1
- package/dist/{chunk-5AZZQQYS.cjs → chunk-DPLXHG7H.cjs} +40 -40
- package/dist/{chunk-5AZZQQYS.cjs.map → chunk-DPLXHG7H.cjs.map} +1 -1
- package/dist/{chunk-BTUHMCYW.js → chunk-FS3YSNSU.js} +193 -6
- package/dist/chunk-FS3YSNSU.js.map +1 -0
- package/dist/{chunk-RB2EW6IZ.cjs → chunk-GHXTF35X.cjs} +3231 -3165
- package/dist/chunk-GHXTF35X.cjs.map +1 -0
- package/dist/{chunk-7VUKIWF6.cjs → chunk-HIV2GIFP.cjs} +3 -3
- package/dist/{chunk-7VUKIWF6.cjs.map → chunk-HIV2GIFP.cjs.map} +1 -1
- package/dist/{chunk-R4HKB3YD.cjs → chunk-IIPBTWQR.cjs} +22 -22
- package/dist/{chunk-R4HKB3YD.cjs.map → chunk-IIPBTWQR.cjs.map} +1 -1
- package/dist/{chunk-JMBIGH4H.cjs → chunk-IUZMWMVX.cjs} +83 -83
- package/dist/{chunk-JMBIGH4H.cjs.map → chunk-IUZMWMVX.cjs.map} +1 -1
- package/dist/{chunk-XJVRAKEX.cjs → chunk-IXCBVJRU.cjs} +56 -33
- package/dist/chunk-IXCBVJRU.cjs.map +1 -0
- package/dist/{chunk-TBGSQTJK.js → chunk-J4VB4EHW.js} +6 -6
- package/dist/{chunk-UWPT23SA.js → chunk-JXMNLH7F.js} +3 -3
- package/dist/{chunk-A62C4C4V.cjs → chunk-KI4EGKNV.cjs} +3 -2
- package/dist/chunk-KI4EGKNV.cjs.map +1 -0
- package/dist/{chunk-RHQ7BJKK.cjs → chunk-LEWQRAZ4.cjs} +73 -73
- package/dist/{chunk-RHQ7BJKK.cjs.map → chunk-LEWQRAZ4.cjs.map} +1 -1
- package/dist/{chunk-DBR5JLX3.js → chunk-LVAJK2U2.js} +46 -23
- package/dist/chunk-LVAJK2U2.js.map +1 -0
- package/dist/{chunk-OB7YDOTK.cjs → chunk-NJADHL2G.cjs} +8 -8
- package/dist/{chunk-OB7YDOTK.cjs.map → chunk-NJADHL2G.cjs.map} +1 -1
- package/dist/{chunk-HCZJZMU7.js → chunk-NKA3O4L6.js} +3 -3
- package/dist/{chunk-SRYV4H3M.js → chunk-NNWSGOUP.js} +3 -3
- package/dist/{chunk-EYTXD62B.cjs → chunk-OGVIGAMZ.cjs} +232 -45
- package/dist/chunk-OGVIGAMZ.cjs.map +1 -0
- package/dist/{chunk-EJ3EBYNV.cjs → chunk-OMN7A6RP.cjs} +32 -32
- package/dist/{chunk-EJ3EBYNV.cjs.map → chunk-OMN7A6RP.cjs.map} +1 -1
- package/dist/{chunk-WNOGR5AA.js → chunk-PRMR6WKC.js} +3 -3
- package/dist/{chunk-J2JUAS4C.cjs → chunk-PXQQFRUD.cjs} +11 -11
- package/dist/{chunk-J2JUAS4C.cjs.map → chunk-PXQQFRUD.cjs.map} +1 -1
- package/dist/{chunk-VDJF4ZAU.js → chunk-RU3SCECX.js} +3 -3
- package/dist/{chunk-53JKCFFE.cjs → chunk-RXFBW2EJ.cjs} +12 -12
- package/dist/{chunk-53JKCFFE.cjs.map → chunk-RXFBW2EJ.cjs.map} +1 -1
- package/dist/{chunk-JOCFPAS6.js → chunk-TNZOUHHN.js} +6 -6
- package/dist/{chunk-HX4CJYXL.js → chunk-TR7AMHGB.js} +10 -10
- package/dist/{chunk-MLGGF24T.js → chunk-TYVHM67X.js} +8 -8
- package/dist/{chunk-Z35QWWJH.cjs → chunk-U4WPFX5K.cjs} +34 -34
- package/dist/{chunk-Z35QWWJH.cjs.map → chunk-U4WPFX5K.cjs.map} +1 -1
- package/dist/{chunk-M4MKQKFF.js → chunk-UBTOOE55.js} +2 -2
- package/dist/{chunk-FEGEC6FY.js → chunk-X7AJ3J42.js} +5170 -5104
- package/dist/chunk-X7AJ3J42.js.map +1 -0
- package/dist/{chunk-SZMA65OW.js → chunk-XURRAFIB.js} +2 -2
- package/dist/{chunk-CTMLY2GN.js → chunk-YGS7BLSN.js} +5 -5
- package/dist/{chunk-RG2WRBPT.js → chunk-Z3ADCYC5.js} +2 -2
- package/dist/{chunk-2AASLY6D.js → chunk-ZBQV7WH7.js} +3 -3
- package/dist/{chunk-4GPWLGKT.js → chunk-ZRILYK5U.js} +2 -1
- package/dist/{chunk-LBOWSKGM.js → chunk-ZSIEM7CL.js} +5 -5
- package/dist/components/calendar.cjs +4 -4
- package/dist/components/calendar.js +3 -3
- package/dist/components/case-studies/index.cjs +17 -17
- package/dist/components/case-studies/index.js +7 -7
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +13 -7
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +12 -6
- package/dist/components/chat/remark-mention-chips.d.ts +5 -2
- package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
- package/dist/components/chat/utils/discuss-ref-prompt.d.ts +48 -0
- package/dist/components/chat/utils/discuss-ref-prompt.d.ts.map +1 -0
- package/dist/components/chat/utils/index.d.ts +2 -0
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +63 -0
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -0
- package/dist/components/chat/utils/source-row-cta.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +8 -8
- package/dist/components/contact/index.js +7 -7
- package/dist/components/docs/index.cjs +10 -10
- package/dist/components/docs/index.js +9 -9
- package/dist/components/embeds/index.cjs +8 -8
- package/dist/components/embeds/index.js +7 -7
- package/dist/components/faq/index.cjs +9 -9
- package/dist/components/faq/index.js +8 -8
- package/dist/components/features/index.cjs +7 -7
- package/dist/components/features/index.js +6 -6
- package/dist/components/help-center-pages/index.cjs +28 -28
- package/dist/components/help-center-pages/index.js +19 -19
- package/dist/components/icons/index.cjs +3 -3
- package/dist/components/icons/index.js +2 -2
- package/dist/components/index.cjs +253 -247
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -17
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +92 -92
- package/dist/components/meeting-scheduler/index.js +9 -9
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/navigation/index.js +6 -6
- package/dist/components/onboarding-guides/index.cjs +11 -11
- package/dist/components/onboarding-guides/index.js +10 -10
- package/dist/components/related-content/index.cjs +9 -9
- package/dist/components/related-content/index.js +8 -8
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +11 -3
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -1
- package/dist/components/shared/doc-search/use-doc-search.d.ts +7 -0
- package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +11 -11
- package/dist/components/tickets/index.js +10 -10
- package/dist/components/ui/actions-menu.d.ts.map +1 -1
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +70 -70
- package/dist/components/ui/file-manager/index.js +3 -3
- package/dist/components/ui/index.cjs +13 -7
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +12 -6
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
- package/dist/components/ui/overlay-open-registry.d.ts +50 -0
- package/dist/components/ui/overlay-open-registry.d.ts.map +1 -0
- package/dist/components/ui/portal-container.d.ts +24 -0
- package/dist/components/ui/portal-container.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -4
- package/dist/hooks/index.js +3 -3
- package/dist/index.cjs +20 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +24 -14
- package/dist/utils/anchor-id.d.ts +32 -0
- package/dist/utils/anchor-id.d.ts.map +1 -0
- package/dist/utils/content-href.d.ts +27 -6
- package/dist/utils/content-href.d.ts.map +1 -1
- package/dist/utils/index.cjs +213 -152
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +212 -153
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/list-url.d.ts.map +1 -1
- package/dist/utils/same-page-hash-nav.d.ts +17 -6
- package/dist/utils/same-page-hash-nav.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/__tests__/remark-mention-chips.test.ts +67 -0
- package/src/components/chat/chat-message-enhanced.tsx +3 -2
- package/src/components/chat/chat-message-list.tsx +49 -5
- package/src/components/chat/embeddable-chat.tsx +19 -2
- package/src/components/chat/entity-cards/dispatch.tsx +65 -4
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +28 -4
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +5 -3
- package/src/components/chat/remark-mention-chips.ts +6 -3
- package/src/components/chat/utils/__tests__/discuss-ref-prompt.test.ts +67 -0
- package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +156 -0
- package/src/components/chat/utils/discuss-ref-prompt.ts +63 -0
- package/src/components/chat/utils/index.ts +16 -0
- package/src/components/chat/utils/resolve-fetched-card-href.ts +104 -0
- package/src/components/chat/utils/source-row-cta.ts +5 -1
- package/src/components/shared/doc-search/__tests__/resolve-search-result-action.test.ts +145 -0
- package/src/components/shared/doc-search/resolve-search-result-action.ts +46 -10
- package/src/components/shared/doc-search/use-doc-search.ts +50 -14
- package/src/components/ui/__tests__/overlay-open-registry.test.tsx +87 -0
- package/src/components/ui/actions-menu.tsx +65 -2
- package/src/components/ui/dropdown-menu.tsx +62 -12
- package/src/components/ui/markdown/base-components.tsx +26 -0
- package/src/components/ui/overlay-open-registry.tsx +99 -0
- package/src/components/ui/portal-container.tsx +31 -0
- package/src/utils/__tests__/anchor-id.test.ts +139 -0
- package/src/utils/__tests__/content-href.test.ts +74 -0
- package/src/utils/__tests__/list-url.test.ts +11 -1
- package/src/utils/anchor-id.ts +106 -0
- package/src/utils/content-href.ts +113 -13
- package/src/utils/index.ts +8 -0
- package/src/utils/list-url.ts +6 -1
- package/src/utils/same-page-hash-nav.ts +28 -9
- package/dist/chunk-A62C4C4V.cjs.map +0 -1
- package/dist/chunk-BTUHMCYW.js.map +0 -1
- package/dist/chunk-DBR5JLX3.js.map +0 -1
- package/dist/chunk-EYTXD62B.cjs.map +0 -1
- package/dist/chunk-FEGEC6FY.js.map +0 -1
- package/dist/chunk-GE3JUTW2.js.map +0 -1
- package/dist/chunk-Q4K3TGNP.cjs.map +0 -1
- package/dist/chunk-RB2EW6IZ.cjs.map +0 -1
- package/dist/chunk-XJVRAKEX.cjs.map +0 -1
- /package/dist/{chunk-6FIDTXYC.js.map → chunk-7AQSXQEL.js.map} +0 -0
- /package/dist/{chunk-TBGSQTJK.js.map → chunk-J4VB4EHW.js.map} +0 -0
- /package/dist/{chunk-UWPT23SA.js.map → chunk-JXMNLH7F.js.map} +0 -0
- /package/dist/{chunk-HCZJZMU7.js.map → chunk-NKA3O4L6.js.map} +0 -0
- /package/dist/{chunk-SRYV4H3M.js.map → chunk-NNWSGOUP.js.map} +0 -0
- /package/dist/{chunk-WNOGR5AA.js.map → chunk-PRMR6WKC.js.map} +0 -0
- /package/dist/{chunk-VDJF4ZAU.js.map → chunk-RU3SCECX.js.map} +0 -0
- /package/dist/{chunk-JOCFPAS6.js.map → chunk-TNZOUHHN.js.map} +0 -0
- /package/dist/{chunk-HX4CJYXL.js.map → chunk-TR7AMHGB.js.map} +0 -0
- /package/dist/{chunk-MLGGF24T.js.map → chunk-TYVHM67X.js.map} +0 -0
- /package/dist/{chunk-M4MKQKFF.js.map → chunk-UBTOOE55.js.map} +0 -0
- /package/dist/{chunk-SZMA65OW.js.map → chunk-XURRAFIB.js.map} +0 -0
- /package/dist/{chunk-CTMLY2GN.js.map → chunk-YGS7BLSN.js.map} +0 -0
- /package/dist/{chunk-RG2WRBPT.js.map → chunk-Z3ADCYC5.js.map} +0 -0
- /package/dist/{chunk-2AASLY6D.js.map → chunk-ZBQV7WH7.js.map} +0 -0
- /package/dist/{chunk-4GPWLGKT.js.map → chunk-ZRILYK5U.js.map} +0 -0
- /package/dist/{chunk-LBOWSKGM.js.map → chunk-ZSIEM7CL.js.map} +0 -0
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
* Everything else (debounce, `useChatRuntime` for embed-mode short-
|
|
27
27
|
* circuit, embed-shim router, the action-resolver + result-mapper) is
|
|
28
28
|
* now lib-resident.
|
|
29
|
+
*
|
|
30
|
+
* ## Result hrefs go through the content-href seam
|
|
31
|
+
*
|
|
32
|
+
* A row's href is NOT the RAG `externalUrl` verbatim any more: when the
|
|
33
|
+
* host wires `runtime.composeContentUrl` (or passes `composeContentUrl`
|
|
34
|
+
* here), the row is re-resolved through it — the SAME seam the catalog
|
|
35
|
+
* cards and the chat entity cards use. So a host that serves onboarding
|
|
36
|
+
* guides at `/help-center/onboarding-guides/<slug>` gets that path from
|
|
37
|
+
* the dropdown too, instead of bouncing the user to the canonical hub
|
|
38
|
+
* URL. Unwired hosts keep the verbatim-`externalUrl` behavior.
|
|
29
39
|
*/
|
|
30
40
|
|
|
31
41
|
import { useState, useEffect, useCallback } from 'react'
|
|
@@ -39,6 +49,7 @@ import {
|
|
|
39
49
|
stripSameOriginToPath,
|
|
40
50
|
NEW_TAB_FEATURES,
|
|
41
51
|
} from '../../chat/utils/chat-nav-resolution'
|
|
52
|
+
import type { ComposeContentUrl } from '../../../utils/content-href'
|
|
42
53
|
import type { DocSearchResult } from './types'
|
|
43
54
|
import { mapDocSearchResults } from './map-doc-search-results'
|
|
44
55
|
import { resolveSearchResultAction } from './resolve-search-result-action'
|
|
@@ -72,6 +83,12 @@ export interface UseDocSearchConfig {
|
|
|
72
83
|
* (the hub's reverse-proxy route). Embedders with a different
|
|
73
84
|
* path can override. */
|
|
74
85
|
searchEndpoint?: string
|
|
86
|
+
/** Optional content-href seam override. Same fall-back chain as
|
|
87
|
+
* `searchEndpoint`: prop → `runtime.composeContentUrl` → none. Wired,
|
|
88
|
+
* it makes a result row honor the host's `hostedTypes` / `overrides`
|
|
89
|
+
* instead of navigating to the RAG's canonical hub URL — so a row
|
|
90
|
+
* lands where the catalog card for the same entity lands. */
|
|
91
|
+
composeContentUrl?: ComposeContentUrl
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
export function useDocSearch(config: UseDocSearchConfig) {
|
|
@@ -82,6 +99,7 @@ export function useDocSearch(config: UseDocSearchConfig) {
|
|
|
82
99
|
tableIds,
|
|
83
100
|
onInPageSwap,
|
|
84
101
|
searchEndpoint,
|
|
102
|
+
composeContentUrl,
|
|
85
103
|
} = config
|
|
86
104
|
const tableIdsKey = tableIds && tableIds.length > 0 ? tableIds.join(',') : ''
|
|
87
105
|
|
|
@@ -94,6 +112,11 @@ export function useDocSearch(config: UseDocSearchConfig) {
|
|
|
94
112
|
const runtime = useChatRuntime()
|
|
95
113
|
const resolvedSearchEndpoint =
|
|
96
114
|
searchEndpoint ?? runtime?.endpoints.docsSearchUrl ?? '/api/docs/search'
|
|
115
|
+
// Content-href seam, same prop → runtime → none chain. Threading it into
|
|
116
|
+
// `resolveSearchResultAction` is what makes the dropdown obey the host's
|
|
117
|
+
// route map; without it the row navigates to the RAG's canonical hub URL
|
|
118
|
+
// even when the host serves that content in-app under a different path.
|
|
119
|
+
const resolvedComposeContentUrl = composeContentUrl ?? runtime?.composeContentUrl
|
|
97
120
|
|
|
98
121
|
const [query, setQuery] = useState('')
|
|
99
122
|
const [results, setResults] = useState<SearchResult[]>([])
|
|
@@ -172,6 +195,7 @@ export function useDocSearch(config: UseDocSearchConfig) {
|
|
|
172
195
|
result,
|
|
173
196
|
source,
|
|
174
197
|
runtime?.navigation.mode,
|
|
198
|
+
resolvedComposeContentUrl,
|
|
175
199
|
)
|
|
176
200
|
// Modifier / non-primary mouse click → force new tab regardless of
|
|
177
201
|
// same-tab/new-tab decision. The dropdown row is a `<div>`, not an
|
|
@@ -188,19 +212,6 @@ export function useDocSearch(config: UseDocSearchConfig) {
|
|
|
188
212
|
(typeof modifiers.button === 'number' && modifiers.button !== 0))
|
|
189
213
|
switch (action.kind) {
|
|
190
214
|
case 'navigate-same-tab': {
|
|
191
|
-
// Embed-mode short-circuit — autocomplete row clicked while
|
|
192
|
-
// the chat panel is hosted inside an embedding app.
|
|
193
|
-
if (runtime?.navigation.mode === 'embed') {
|
|
194
|
-
setKeepOpen(true)
|
|
195
|
-
const targetPlatform =
|
|
196
|
-
(result.metadata?.targetPlatform as string | null | undefined) ?? null
|
|
197
|
-
resolveExternalNavigation({
|
|
198
|
-
href: action.href,
|
|
199
|
-
targetPlatform,
|
|
200
|
-
runtime,
|
|
201
|
-
}).open()
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
215
|
if (wantsNewTab) {
|
|
205
216
|
setKeepOpen(true)
|
|
206
217
|
window.open(action.href, '_blank', NEW_TAB_FEATURES)
|
|
@@ -226,6 +237,23 @@ export function useDocSearch(config: UseDocSearchConfig) {
|
|
|
226
237
|
// product-hub) — open in a new tab. Keep dropdown open so the
|
|
227
238
|
// user can pick another result without re-searching.
|
|
228
239
|
setKeepOpen(true)
|
|
240
|
+
// Embed-mode short-circuit — the row was clicked while the panel is
|
|
241
|
+
// hosted inside an embedding app, so absolutize against the
|
|
242
|
+
// embedder-supplied content origin and honor its `openExternal`
|
|
243
|
+
// override. Not the only path an embed host takes: a composed
|
|
244
|
+
// RELATIVE href resolves to `navigate-same-tab` above and never
|
|
245
|
+
// reaches `decideNewTab`, which is what lets an embedder keep its
|
|
246
|
+
// own in-app routes in-app.
|
|
247
|
+
if (runtime?.navigation.mode === 'embed') {
|
|
248
|
+
const targetPlatform =
|
|
249
|
+
(result.metadata?.targetPlatform as string | null | undefined) ?? null
|
|
250
|
+
resolveExternalNavigation({
|
|
251
|
+
href: action.href,
|
|
252
|
+
targetPlatform,
|
|
253
|
+
runtime,
|
|
254
|
+
}).open()
|
|
255
|
+
return
|
|
256
|
+
}
|
|
229
257
|
window.open(action.href, '_blank', NEW_TAB_FEATURES)
|
|
230
258
|
return
|
|
231
259
|
case 'ask-ai':
|
|
@@ -249,7 +277,15 @@ export function useDocSearch(config: UseDocSearchConfig) {
|
|
|
249
277
|
return
|
|
250
278
|
}
|
|
251
279
|
},
|
|
252
|
-
[
|
|
280
|
+
[
|
|
281
|
+
onNavigate,
|
|
282
|
+
source,
|
|
283
|
+
baseRoute,
|
|
284
|
+
router,
|
|
285
|
+
onInPageSwap,
|
|
286
|
+
runtime,
|
|
287
|
+
resolvedComposeContentUrl,
|
|
288
|
+
],
|
|
253
289
|
)
|
|
254
290
|
|
|
255
291
|
// Reset keepOpen when query changes.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, act } from '@testing-library/react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import {
|
|
6
|
+
OverlayOpenRegistryProvider,
|
|
7
|
+
useReportOverlayOpen,
|
|
8
|
+
} from '../overlay-open-registry'
|
|
9
|
+
|
|
10
|
+
/** Stand-in for `ActionsMenuDropdown` — reports its open state, renders nothing. */
|
|
11
|
+
function Overlay({ open }: { open: boolean }) {
|
|
12
|
+
useReportOverlayOpen(open)
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('OverlayOpenRegistry', () => {
|
|
17
|
+
it('fires only on 0 → 1 and 1 → 0, not per overlay', () => {
|
|
18
|
+
const onOpenChange = vi.fn()
|
|
19
|
+
function Harness({ a, b }: { a: boolean; b: boolean }) {
|
|
20
|
+
return (
|
|
21
|
+
<OverlayOpenRegistryProvider onOpenChange={onOpenChange}>
|
|
22
|
+
<Overlay open={a} />
|
|
23
|
+
<Overlay open={b} />
|
|
24
|
+
</OverlayOpenRegistryProvider>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
const { rerender } = render(<Harness a={false} b={false} />)
|
|
28
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
29
|
+
|
|
30
|
+
rerender(<Harness a b={false} />)
|
|
31
|
+
expect(onOpenChange.mock.calls).toEqual([[true]])
|
|
32
|
+
|
|
33
|
+
// Second overlay opens while the first is still open — no new signal.
|
|
34
|
+
rerender(<Harness a b />)
|
|
35
|
+
expect(onOpenChange.mock.calls).toEqual([[true]])
|
|
36
|
+
|
|
37
|
+
// First closes, one still open — still no signal.
|
|
38
|
+
rerender(<Harness a={false} b />)
|
|
39
|
+
expect(onOpenChange.mock.calls).toEqual([[true]])
|
|
40
|
+
|
|
41
|
+
rerender(<Harness a={false} b={false} />)
|
|
42
|
+
expect(onOpenChange.mock.calls).toEqual([[true], [false]])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('releases when an open overlay unmounts', () => {
|
|
46
|
+
const onOpenChange = vi.fn()
|
|
47
|
+
function Harness({ mounted }: { mounted: boolean }) {
|
|
48
|
+
return (
|
|
49
|
+
<OverlayOpenRegistryProvider onOpenChange={onOpenChange}>
|
|
50
|
+
{mounted ? <Overlay open /> : null}
|
|
51
|
+
</OverlayOpenRegistryProvider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
const { rerender } = render(<Harness mounted />)
|
|
55
|
+
expect(onOpenChange.mock.calls).toEqual([[true]])
|
|
56
|
+
rerender(<Harness mounted={false} />)
|
|
57
|
+
expect(onOpenChange.mock.calls).toEqual([[true], [false]])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('is inert without a provider — no throw, no crash', () => {
|
|
61
|
+
expect(() => render(<Overlay open />)).not.toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('a stable context value keeps overlays from re-rendering on count changes', () => {
|
|
65
|
+
const renderSpy = vi.fn()
|
|
66
|
+
function CountingOverlay({ open }: { open: boolean }) {
|
|
67
|
+
renderSpy()
|
|
68
|
+
useReportOverlayOpen(open)
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
let bump: (() => void) | undefined
|
|
72
|
+
function Harness() {
|
|
73
|
+
const [, setTick] = useState(0)
|
|
74
|
+
bump = () => setTick((t) => t + 1)
|
|
75
|
+
return (
|
|
76
|
+
<OverlayOpenRegistryProvider>
|
|
77
|
+
<CountingOverlay open={false} />
|
|
78
|
+
</OverlayOpenRegistryProvider>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
render(<Harness />)
|
|
82
|
+
const before = renderSpy.mock.calls.length
|
|
83
|
+
act(() => bump?.())
|
|
84
|
+
// One re-render from the parent's own state, not extra churn from context.
|
|
85
|
+
expect(renderSpy.mock.calls.length).toBe(before + 1)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -13,6 +13,16 @@ import {
|
|
|
13
13
|
DropdownMenuContent,
|
|
14
14
|
DropdownMenuTrigger,
|
|
15
15
|
} from "./dropdown-menu";
|
|
16
|
+
import {
|
|
17
|
+
COLLISION_PADDING_PX,
|
|
18
|
+
useCollisionBoundary,
|
|
19
|
+
usePortalContainer,
|
|
20
|
+
} from "./portal-container";
|
|
21
|
+
import { useReportOverlayOpen } from "./overlay-open-registry";
|
|
22
|
+
|
|
23
|
+
/** Trigger movement (px) that counts as "the user scrolled away" rather than
|
|
24
|
+
* touch-momentum jitter or the sub-pixel settle right after opening. */
|
|
25
|
+
const SCROLL_DISMISS_THRESHOLD_PX = 12;
|
|
16
26
|
|
|
17
27
|
export interface ActionsMenuItemIconAction {
|
|
18
28
|
icon: React.ReactNode;
|
|
@@ -126,6 +136,10 @@ const SecondaryAction: React.FC<{ action: ActionsMenuItemIconAction }> = ({ acti
|
|
|
126
136
|
};
|
|
127
137
|
|
|
128
138
|
const MenuItem: React.FC<MenuItemProps> = ({ item, onItemClick }) => {
|
|
139
|
+
// Submenus below portal + collide against the OWNING surface, matching the
|
|
140
|
+
// root menu (`DropdownMenuContent`). Read unconditionally — hooks first.
|
|
141
|
+
const portalContainer = usePortalContainer();
|
|
142
|
+
const collisionBoundary = useCollisionBoundary();
|
|
129
143
|
const activate = useCallback(() => {
|
|
130
144
|
if (item.disabled) return;
|
|
131
145
|
if (item.type === "checkbox") {
|
|
@@ -265,9 +279,14 @@ const MenuItem: React.FC<MenuItemProps> = ({ item, onItemClick }) => {
|
|
|
265
279
|
>
|
|
266
280
|
{rowContent}
|
|
267
281
|
</DropdownMenuPrimitive.SubTrigger>
|
|
268
|
-
<DropdownMenuPrimitive.Portal>
|
|
282
|
+
<DropdownMenuPrimitive.Portal container={portalContainer ?? undefined}>
|
|
269
283
|
<DropdownMenuPrimitive.SubContent
|
|
270
284
|
sideOffset={4}
|
|
285
|
+
// Confine flip/shift (and the available-height this menu sizes
|
|
286
|
+
// itself by) to the owning surface — see `useCollisionBoundary`.
|
|
287
|
+
collisionBoundary={collisionBoundary ?? undefined}
|
|
288
|
+
collisionPadding={collisionBoundary ? COLLISION_PADDING_PX : undefined}
|
|
289
|
+
hideWhenDetached
|
|
271
290
|
className="z-[1500] min-w-[256px] max-h-[var(--radix-popper-available-height)] bg-ods-bg border border-ods-border rounded-md shadow-xl overflow-y-auto p-0"
|
|
272
291
|
>
|
|
273
292
|
{item.submenu.map((subItem, index) => (
|
|
@@ -377,6 +396,50 @@ export const ActionsMenuDropdown: React.FC<ActionsMenuDropdownProps> = ({
|
|
|
377
396
|
onChange: onOpenChange,
|
|
378
397
|
});
|
|
379
398
|
|
|
399
|
+
// Tell the surrounding surface an overlay is open, so it can stop moving
|
|
400
|
+
// the ground under it (the chat thread suspends its follow-the-bottom
|
|
401
|
+
// auto-scroll — see `OverlayOpenRegistryProvider`). Inert without a
|
|
402
|
+
// provider, so menus elsewhere are unaffected.
|
|
403
|
+
useReportOverlayOpen(open);
|
|
404
|
+
|
|
405
|
+
// Dismiss once the USER scrolls the trigger away.
|
|
406
|
+
//
|
|
407
|
+
// Two different situations, two different answers. Content moving on its own
|
|
408
|
+
// (a streaming reply) is handled above by suspending the auto-scroll — the
|
|
409
|
+
// menu must survive that, nobody asked for it to close. A deliberate scroll
|
|
410
|
+
// is the opposite: the reader left, and an anchored menu would ride along
|
|
411
|
+
// until it hovers over unrelated chrome. `hideWhenDetached` only kicks in
|
|
412
|
+
// once the trigger is FULLY clipped, so a half-scrolled trigger still drags
|
|
413
|
+
// a visible menu across the header.
|
|
414
|
+
//
|
|
415
|
+
// Scoped deliberately:
|
|
416
|
+
// • only scrolls of containers that actually contain the trigger — a
|
|
417
|
+
// scroll in a neighbouring column is none of our business;
|
|
418
|
+
// • only past `SCROLL_DISMISS_THRESHOLD_PX` of movement, so touch
|
|
419
|
+
// momentum and the ~1px settle after opening don't close the menu the
|
|
420
|
+
// user is reaching for.
|
|
421
|
+
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
|
422
|
+
React.useEffect(() => {
|
|
423
|
+
if (!open) return;
|
|
424
|
+
if (typeof document === "undefined") return;
|
|
425
|
+
const trigger = triggerRef.current;
|
|
426
|
+
if (!trigger) return;
|
|
427
|
+
const anchorTop = trigger.getBoundingClientRect().top;
|
|
428
|
+
const onScroll = (event: Event) => {
|
|
429
|
+
const target = event.target as Node | null;
|
|
430
|
+
// `document` fires for the page scroll and contains everything.
|
|
431
|
+
const scrolledTheTrigger =
|
|
432
|
+
target === document ||
|
|
433
|
+
(target instanceof Node && target.contains(trigger));
|
|
434
|
+
if (!scrolledTheTrigger) return;
|
|
435
|
+
const moved = Math.abs(trigger.getBoundingClientRect().top - anchorTop);
|
|
436
|
+
if (moved > SCROLL_DISMISS_THRESHOLD_PX) setOpen(false);
|
|
437
|
+
};
|
|
438
|
+
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
|
439
|
+
return () =>
|
|
440
|
+
document.removeEventListener("scroll", onScroll, { capture: true });
|
|
441
|
+
}, [open, setOpen]);
|
|
442
|
+
|
|
380
443
|
const handleItemClick = useCallback(
|
|
381
444
|
(item: ActionsMenuItem) => {
|
|
382
445
|
onItemClick?.(item);
|
|
@@ -393,7 +456,7 @@ export const ActionsMenuDropdown: React.FC<ActionsMenuDropdownProps> = ({
|
|
|
393
456
|
|
|
394
457
|
return (
|
|
395
458
|
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
|
396
|
-
<DropdownMenuTrigger asChild>
|
|
459
|
+
<DropdownMenuTrigger asChild ref={triggerRef}>
|
|
397
460
|
{customTrigger ?? (
|
|
398
461
|
<Button
|
|
399
462
|
variant="outline"
|
|
@@ -5,7 +5,11 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
|
5
5
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
6
6
|
|
|
7
7
|
import { cn } from "../../utils/cn"
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
COLLISION_PADDING_PX,
|
|
10
|
+
useCollisionBoundary,
|
|
11
|
+
usePortalContainer,
|
|
12
|
+
} from "./portal-container"
|
|
9
13
|
|
|
10
14
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
|
11
15
|
|
|
@@ -44,31 +48,77 @@ DropdownMenuSubTrigger.displayName =
|
|
|
44
48
|
const DropdownMenuSubContent = React.forwardRef<
|
|
45
49
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
46
50
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
47
|
-
>(({ className, ...props }, ref) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
51
|
+
>(({ className, collisionBoundary, collisionPadding, hideWhenDetached, ...props }, ref) => {
|
|
52
|
+
// Same container as the root content — `DropdownMenuContent` portals, and a
|
|
53
|
+
// submenu left un-portaled inherits whatever clipping/stacking context its
|
|
54
|
+
// parent sits in instead of the one the host published. (The hand-rolled
|
|
55
|
+
// submenu in `actions-menu` already portals; this keeps the shared primitive
|
|
56
|
+
// consistent with both.)
|
|
57
|
+
const container = usePortalContainer()
|
|
58
|
+
// Same boundary as the root content — a submenu must not escape the surface
|
|
59
|
+
// its parent menu is confined to.
|
|
60
|
+
const boundary = useCollisionBoundary()
|
|
61
|
+
const resolvedBoundary = collisionBoundary ?? boundary ?? undefined
|
|
62
|
+
return (
|
|
63
|
+
<DropdownMenuPrimitive.Portal container={container ?? undefined}>
|
|
64
|
+
<DropdownMenuPrimitive.SubContent
|
|
65
|
+
ref={ref}
|
|
66
|
+
collisionBoundary={resolvedBoundary}
|
|
67
|
+
collisionPadding={collisionPadding ?? (resolvedBoundary ? COLLISION_PADDING_PX : undefined)}
|
|
68
|
+
// Only defaulted ON where a boundary was published (i.e. inside a panel
|
|
69
|
+
// that owns its menus) — see `DropdownMenuContent`.
|
|
70
|
+
hideWhenDetached={hideWhenDetached ?? (resolvedBoundary ? true : undefined)}
|
|
71
|
+
className={cn(
|
|
72
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-ods-border bg-ods-card p-1 text-ods-text-primary shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
{...props}
|
|
76
|
+
/>
|
|
77
|
+
</DropdownMenuPrimitive.Portal>
|
|
78
|
+
)
|
|
79
|
+
})
|
|
57
80
|
DropdownMenuSubContent.displayName =
|
|
58
81
|
DropdownMenuPrimitive.SubContent.displayName
|
|
59
82
|
|
|
60
83
|
const DropdownMenuContent = React.forwardRef<
|
|
61
84
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
62
85
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
63
|
-
>(({
|
|
86
|
+
>(({
|
|
87
|
+
className,
|
|
88
|
+
sideOffset = 4,
|
|
89
|
+
collisionBoundary,
|
|
90
|
+
collisionPadding,
|
|
91
|
+
hideWhenDetached,
|
|
92
|
+
...props
|
|
93
|
+
}, ref) => {
|
|
64
94
|
// Portal into the active container (e.g. a drawer) so the menu inherits its
|
|
65
95
|
// stacking context; falls back to `document.body` when none is provided.
|
|
66
96
|
const container = usePortalContainer()
|
|
97
|
+
// Keep flip/shift inside the surface that owns the menu. Without this Radix
|
|
98
|
+
// collides against the VIEWPORT, so a menu opened near the top of the chat
|
|
99
|
+
// panel grows upward past the panel and lands over the app header. An
|
|
100
|
+
// explicit prop still wins; no provider → viewport, Radix's own default.
|
|
101
|
+
const boundary = useCollisionBoundary()
|
|
102
|
+
const resolvedBoundary = collisionBoundary ?? boundary ?? undefined
|
|
67
103
|
return (
|
|
68
104
|
<DropdownMenuPrimitive.Portal container={container ?? undefined}>
|
|
69
105
|
<DropdownMenuPrimitive.Content
|
|
70
106
|
ref={ref}
|
|
71
107
|
sideOffset={sideOffset}
|
|
108
|
+
collisionBoundary={resolvedBoundary}
|
|
109
|
+
collisionPadding={collisionPadding ?? (resolvedBoundary ? COLLISION_PADDING_PX : undefined)}
|
|
110
|
+
// A menu tracks its trigger for as long as it is open. When the trigger
|
|
111
|
+
// scrolls out of its clipping ancestor — a chat card carried away by the
|
|
112
|
+
// thread's auto-scroll while the menu is open — Radix keeps the menu
|
|
113
|
+
// pinned to that off-screen anchor, so it slides to the surface edge and
|
|
114
|
+
// reads as a detached popup floating over unrelated chrome.
|
|
115
|
+
// `hideWhenDetached` makes it disappear with its trigger instead.
|
|
116
|
+
//
|
|
117
|
+
// Defaulted ON only where a boundary was published. This is a SHARED
|
|
118
|
+
// primitive: flipping it on globally would change dismissal behavior for
|
|
119
|
+
// every dropdown in every consumer, and the detached-popup problem it
|
|
120
|
+
// solves is specific to a menu living inside a scrolling panel.
|
|
121
|
+
hideWhenDetached={hideWhenDetached ?? (resolvedBoundary ? true : undefined)}
|
|
72
122
|
className={cn(
|
|
73
123
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-ods-border bg-ods-card p-1 text-ods-text-primary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
74
124
|
className
|
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
useHeadingId,
|
|
22
22
|
} from './heading-ids';
|
|
23
23
|
import { slugifyHeadingText } from '../../../utils/markdown-heading-id';
|
|
24
|
+
import {
|
|
25
|
+
getHashTargetElement,
|
|
26
|
+
navigateSamePageHash,
|
|
27
|
+
HUB_HEADER_OFFSET_PX,
|
|
28
|
+
} from '../../../utils/same-page-hash-nav';
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* True when an image `src` is worth rendering. Shared by the base `img` and
|
|
@@ -284,12 +289,33 @@ export function buildBaseComponents({
|
|
|
284
289
|
);
|
|
285
290
|
}
|
|
286
291
|
|
|
292
|
+
// In-page anchor. Doc TOCs are authored against GitHub's slugger
|
|
293
|
+
// (`## 📚 Table of Contents` → `#-table-of-contents`) while our heading
|
|
294
|
+
// ids trim the emoji's leftover hyphen, so the raw href resolves to
|
|
295
|
+
// nothing and the browser silently ignores the click. Resolve through
|
|
296
|
+
// `getHashTargetElement` — the same resolver deep links use — then hand
|
|
297
|
+
// the real id to the canonical hash-nav helper, which also lands the
|
|
298
|
+
// heading BELOW the sticky header instead of under it, matching how the
|
|
299
|
+
// "On this page" rail scrolls.
|
|
300
|
+
const isInPageAnchor = typeof href === 'string' && href.startsWith('#') && href.length > 1;
|
|
301
|
+
const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
302
|
+
// Let modifier / non-primary clicks keep their native behavior.
|
|
303
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
304
|
+
const target = getHashTargetElement(href.slice(1));
|
|
305
|
+
// Nothing matched anywhere in the document → leave the browser's
|
|
306
|
+
// default alone rather than swallowing a click that isn't ours.
|
|
307
|
+
if (!target?.id) return;
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
navigateSamePageHash(`#${target.id}`, { headerOffset: HUB_HEADER_OFFSET_PX });
|
|
310
|
+
};
|
|
311
|
+
|
|
287
312
|
return (
|
|
288
313
|
<a
|
|
289
314
|
href={href}
|
|
290
315
|
className={`text-ods-accent no-underline relative transition-colors duration-200 hover:after:w-full after:content-[''] after:absolute after:w-0 after:h-0.5 after:-bottom-0.5 after:left-0 after:bg-ods-accent after:transition-all after:duration-300 ${linkClassName || ''}`}
|
|
291
316
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
292
317
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
318
|
+
onClick={isInPageAnchor ? handleAnchorClick : undefined}
|
|
293
319
|
>
|
|
294
320
|
{children}
|
|
295
321
|
</a>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A surface-scoped count of overlays (⋯ menus, popovers) currently open INSIDE
|
|
5
|
+
* that surface, so the surface can suspend behaviour that would move the ground
|
|
6
|
+
* under them.
|
|
7
|
+
*
|
|
8
|
+
* ## Why
|
|
9
|
+
*
|
|
10
|
+
* An open menu stays anchored to its trigger. In a chat thread the ground moves
|
|
11
|
+
* on its own: a streaming reply grows the transcript and the auto-scroll follows
|
|
12
|
+
* the bottom, carrying the trigger — and its menu — upward until the menu is
|
|
13
|
+
* pinned to an off-screen anchor at the panel edge, visually detached from the
|
|
14
|
+
* card it belongs to.
|
|
15
|
+
*
|
|
16
|
+
* The honest fix is not to dismiss the menu on every scroll event (that breaks
|
|
17
|
+
* touch, where momentum scrolling fires constantly) but to stop pulling the
|
|
18
|
+
* ground: while an overlay is open the thread stops chasing the bottom, exactly
|
|
19
|
+
* how ChatGPT/Claude stop chasing once you interact with the transcript. When
|
|
20
|
+
* the overlay closes, the thread catches up if it was at the bottom before.
|
|
21
|
+
*
|
|
22
|
+
* ## Wiring
|
|
23
|
+
*
|
|
24
|
+
* Surface: wrap the region in `<OverlayOpenRegistryProvider onOpenChange={…}>`.
|
|
25
|
+
* Overlay: call `useReportOverlayOpen(open)`. No provider → the hook is inert,
|
|
26
|
+
* so overlays outside such a surface behave exactly as before.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as React from 'react'
|
|
30
|
+
|
|
31
|
+
export interface OverlayOpenRegistry {
|
|
32
|
+
/** Register one open overlay; call the returned fn to release it. */
|
|
33
|
+
acquire: () => () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const OverlayOpenRegistryContext =
|
|
37
|
+
React.createContext<OverlayOpenRegistry | null>(null)
|
|
38
|
+
|
|
39
|
+
/** Read the surrounding registry (or `null` when there is no provider). */
|
|
40
|
+
export function useOverlayOpenRegistry(): OverlayOpenRegistry | null {
|
|
41
|
+
return React.useContext(OverlayOpenRegistryContext)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Report this overlay's open state to the surrounding surface. Safe to call
|
|
46
|
+
* unconditionally — with no provider it does nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function useReportOverlayOpen(open: boolean): void {
|
|
49
|
+
const registry = useOverlayOpenRegistry()
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
if (!open || !registry) return
|
|
52
|
+
return registry.acquire()
|
|
53
|
+
}, [open, registry])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OverlayOpenRegistryProviderProps {
|
|
57
|
+
/** Fires on 0 → 1 and 1 → 0 transitions only, not on every acquire. */
|
|
58
|
+
onOpenChange?: (hasOpenOverlay: boolean) => void
|
|
59
|
+
children: React.ReactNode
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Owns the count for one surface. The context value is referentially stable, so
|
|
64
|
+
* mounting this provider does not re-render overlays when the count changes —
|
|
65
|
+
* only the surface's own `onOpenChange` callback fires.
|
|
66
|
+
*/
|
|
67
|
+
export function OverlayOpenRegistryProvider({
|
|
68
|
+
onOpenChange,
|
|
69
|
+
children,
|
|
70
|
+
}: OverlayOpenRegistryProviderProps) {
|
|
71
|
+
const countRef = React.useRef(0)
|
|
72
|
+
const onOpenChangeRef = React.useRef(onOpenChange)
|
|
73
|
+
onOpenChangeRef.current = onOpenChange
|
|
74
|
+
|
|
75
|
+
const registry = React.useMemo<OverlayOpenRegistry>(
|
|
76
|
+
() => ({
|
|
77
|
+
acquire: () => {
|
|
78
|
+
countRef.current += 1
|
|
79
|
+
if (countRef.current === 1) onOpenChangeRef.current?.(true)
|
|
80
|
+
let released = false
|
|
81
|
+
return () => {
|
|
82
|
+
// Guard against a double release (StrictMode double-invokes effect
|
|
83
|
+
// cleanups in development) driving the count negative.
|
|
84
|
+
if (released) return
|
|
85
|
+
released = true
|
|
86
|
+
countRef.current -= 1
|
|
87
|
+
if (countRef.current === 0) onOpenChangeRef.current?.(false)
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
[],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<OverlayOpenRegistryContext.Provider value={registry}>
|
|
96
|
+
{children}
|
|
97
|
+
</OverlayOpenRegistryContext.Provider>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -26,3 +26,34 @@ export const PortalContainerContext = React.createContext<HTMLElement | null>(
|
|
|
26
26
|
export function usePortalContainer(): HTMLElement | null {
|
|
27
27
|
return React.useContext(PortalContainerContext)
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The element Radix overlays should treat as their COLLISION boundary — the
|
|
32
|
+
* surface they must stay inside of.
|
|
33
|
+
*
|
|
34
|
+
* Separate from {@link PortalContainerContext} on purpose: the portal target is
|
|
35
|
+
* a `display: contents` node (no box, so it can't be measured), while a
|
|
36
|
+
* collision boundary must be a real, laid-out element.
|
|
37
|
+
*
|
|
38
|
+
* Why it matters: Radix positions overlays `position: fixed` and, by default,
|
|
39
|
+
* flips/shifts them against the VIEWPORT. A menu opened near the top of a
|
|
40
|
+
* panel therefore happily grows upward past the panel's edge and lands on top
|
|
41
|
+
* of the app header — visually detached from the surface it belongs to. Handing
|
|
42
|
+
* Radix the panel element instead keeps flip/shift (and the
|
|
43
|
+
* `--radix-popper-available-height` a menu sizes itself by) inside that panel.
|
|
44
|
+
*
|
|
45
|
+
* Default `null` → viewport, i.e. Radix's own default, unchanged everywhere a
|
|
46
|
+
* provider isn't present.
|
|
47
|
+
*/
|
|
48
|
+
export const CollisionBoundaryContext = React.createContext<HTMLElement | null>(
|
|
49
|
+
null,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/** Read the active collision boundary (or `null` for the viewport). */
|
|
53
|
+
export function useCollisionBoundary(): HTMLElement | null {
|
|
54
|
+
return React.useContext(CollisionBoundaryContext)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Padding (px) kept between an overlay and its boundary edge. Matches the
|
|
58
|
+
* panel's own gutter so a shifted menu never sits flush against the edge. */
|
|
59
|
+
export const COLLISION_PADDING_PX = 8
|