@flamingo-stack/openframe-frontend-core 0.0.534 → 0.0.535-1763.4621.1
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-NCUKKJRG.cjs → chunk-2P4J2YLI.cjs} +98 -98
- package/dist/{chunk-NCUKKJRG.cjs.map → chunk-2P4J2YLI.cjs.map} +1 -1
- package/dist/{chunk-GS4HJA2G.js → chunk-4A736Z6S.js} +74 -20
- package/dist/chunk-4A736Z6S.js.map +1 -0
- package/dist/{chunk-O6VJZX2N.js → chunk-4DTUUM5V.js} +95 -58
- package/dist/chunk-4DTUUM5V.js.map +1 -0
- package/dist/{chunk-UWB33C6R.cjs → chunk-4HQFV4PK.cjs} +14 -14
- package/dist/{chunk-UWB33C6R.cjs.map → chunk-4HQFV4PK.cjs.map} +1 -1
- package/dist/{chunk-GYONSC3U.cjs → chunk-4QYUNDS3.cjs} +3 -3
- package/dist/{chunk-GYONSC3U.cjs.map → chunk-4QYUNDS3.cjs.map} +1 -1
- package/dist/{chunk-M5ODB6RQ.cjs → chunk-5OLF6WXF.cjs} +40 -40
- package/dist/{chunk-M5ODB6RQ.cjs.map → chunk-5OLF6WXF.cjs.map} +1 -1
- package/dist/{chunk-SXLEPNY2.cjs → chunk-5RJUHUCI.cjs} +106 -52
- package/dist/chunk-5RJUHUCI.cjs.map +1 -0
- package/dist/{chunk-RHKH4YUY.cjs → chunk-7MHYRBBQ.cjs} +5 -5
- package/dist/{chunk-RHKH4YUY.cjs.map → chunk-7MHYRBBQ.cjs.map} +1 -1
- package/dist/{chunk-LYRGM25P.cjs → chunk-E3YB55RR.cjs} +223 -186
- package/dist/chunk-E3YB55RR.cjs.map +1 -0
- package/dist/{chunk-W4MXOBEW.js → chunk-EHPQKXXW.js} +3 -3
- package/dist/{chunk-LBZEPB4Y.cjs → chunk-GV4FDDFV.cjs} +18 -18
- package/dist/{chunk-LBZEPB4Y.cjs.map → chunk-GV4FDDFV.cjs.map} +1 -1
- package/dist/{chunk-D62VYSTL.js → chunk-JVO5CYMK.js} +8 -8
- package/dist/{chunk-BSE2UT6L.js → chunk-K6566PEB.js} +3 -3
- package/dist/{chunk-WBYRBTAS.js → chunk-KLFGP6PR.js} +2 -2
- package/dist/{chunk-KZ6PWFHK.js → chunk-LIQJAC6Z.js} +2 -2
- package/dist/{chunk-MTCU5MQN.js → chunk-LIX5OUHH.js} +2 -2
- package/dist/{chunk-RCBRCZUF.js → chunk-LQPHB2YF.js} +2 -2
- package/dist/{chunk-BUVD2OAI.js → chunk-MZVFZMQ6.js} +6 -6
- package/dist/{chunk-ZLLXYE2G.js → chunk-N3AEJTCK.js} +5 -5
- package/dist/{chunk-ONIYUMTO.js → chunk-NB6BP6JB.js} +3 -3
- package/dist/{chunk-ZKQXE2P4.cjs → chunk-P65EZEBW.cjs} +12 -12
- package/dist/{chunk-ZKQXE2P4.cjs.map → chunk-P65EZEBW.cjs.map} +1 -1
- package/dist/{chunk-VYOGAQLM.js → chunk-PLGITXT7.js} +2 -2
- package/dist/{chunk-YW2ZU7LC.cjs → chunk-PSBL3S2B.cjs} +7 -7
- package/dist/{chunk-YW2ZU7LC.cjs.map → chunk-PSBL3S2B.cjs.map} +1 -1
- package/dist/{chunk-H4W4LENJ.js → chunk-QUVIT4JP.js} +6 -6
- package/dist/{chunk-2YJ2CVRC.cjs → chunk-RO67OOKJ.cjs} +40 -40
- package/dist/{chunk-2YJ2CVRC.cjs.map → chunk-RO67OOKJ.cjs.map} +1 -1
- package/dist/{chunk-67MWXMNT.cjs → chunk-TS2EHMEX.cjs} +29 -29
- package/dist/{chunk-67MWXMNT.cjs.map → chunk-TS2EHMEX.cjs.map} +1 -1
- package/dist/{chunk-UMDGRL6A.cjs → chunk-U2Q7GEX2.cjs} +32 -32
- package/dist/{chunk-UMDGRL6A.cjs.map → chunk-U2Q7GEX2.cjs.map} +1 -1
- package/dist/{chunk-2ALT6CTY.js → chunk-VPDTDKO5.js} +2 -2
- package/dist/{chunk-MLILEBJU.cjs → chunk-Y55XQFJK.cjs} +75 -75
- package/dist/{chunk-MLILEBJU.cjs.map → chunk-Y55XQFJK.cjs.map} +1 -1
- package/dist/{chunk-POFCTXNH.js → chunk-Y72MXG5N.js} +4 -4
- package/dist/{chunk-JELZAWSM.cjs → chunk-ZKKEKG73.cjs} +3 -3
- package/dist/{chunk-JELZAWSM.cjs.map → chunk-ZKKEKG73.cjs.map} +1 -1
- package/dist/{chunk-EBSZWSC5.cjs → chunk-ZR5CJTNR.cjs} +4 -4
- package/dist/{chunk-EBSZWSC5.cjs.map → chunk-ZR5CJTNR.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-input.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- 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/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +24 -24
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +138 -138
- package/dist/components/index.js +13 -13
- package/dist/components/meeting-scheduler/index.cjs +40 -40
- package/dist/components/meeting-scheduler/index.js +4 -4
- 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/popover.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/autocomplete.d.ts.map +1 -1
- package/dist/components/ui/file-upload.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/search-input.d.ts.map +1 -1
- package/dist/components/ui/searchable-select.d.ts.map +1 -1
- package/dist/components/ui/tag-select-dropdown.d.ts.map +1 -1
- package/dist/components/ui/tags-manager.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-keyboard-collision-padding.d.ts +2 -0
- package/dist/hooks/ui/use-keyboard-collision-padding.d.ts.map +1 -0
- 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-input.tsx +84 -32
- package/src/components/popover.tsx +22 -14
- package/src/components/ui/autocomplete.tsx +9 -2
- package/src/components/ui/file-upload.tsx +5 -1
- package/src/components/ui/search-input.tsx +9 -2
- package/src/components/ui/searchable-select.tsx +9 -2
- package/src/components/ui/tag-select-dropdown.tsx +8 -2
- package/src/components/ui/tags-manager.tsx +9 -2
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-keyboard-collision-padding.ts +101 -0
- package/dist/chunk-GS4HJA2G.js.map +0 -1
- package/dist/chunk-LYRGM25P.cjs.map +0 -1
- package/dist/chunk-O6VJZX2N.js.map +0 -1
- package/dist/chunk-SXLEPNY2.cjs.map +0 -1
- /package/dist/{chunk-W4MXOBEW.js.map → chunk-EHPQKXXW.js.map} +0 -0
- /package/dist/{chunk-D62VYSTL.js.map → chunk-JVO5CYMK.js.map} +0 -0
- /package/dist/{chunk-BSE2UT6L.js.map → chunk-K6566PEB.js.map} +0 -0
- /package/dist/{chunk-WBYRBTAS.js.map → chunk-KLFGP6PR.js.map} +0 -0
- /package/dist/{chunk-KZ6PWFHK.js.map → chunk-LIQJAC6Z.js.map} +0 -0
- /package/dist/{chunk-MTCU5MQN.js.map → chunk-LIX5OUHH.js.map} +0 -0
- /package/dist/{chunk-RCBRCZUF.js.map → chunk-LQPHB2YF.js.map} +0 -0
- /package/dist/{chunk-BUVD2OAI.js.map → chunk-MZVFZMQ6.js.map} +0 -0
- /package/dist/{chunk-ZLLXYE2G.js.map → chunk-N3AEJTCK.js.map} +0 -0
- /package/dist/{chunk-ONIYUMTO.js.map → chunk-NB6BP6JB.js.map} +0 -0
- /package/dist/{chunk-VYOGAQLM.js.map → chunk-PLGITXT7.js.map} +0 -0
- /package/dist/{chunk-H4W4LENJ.js.map → chunk-QUVIT4JP.js.map} +0 -0
- /package/dist/{chunk-2ALT6CTY.js.map → chunk-VPDTDKO5.js.map} +0 -0
- /package/dist/{chunk-POFCTXNH.js.map → chunk-Y72MXG5N.js.map} +0 -0
|
@@ -168,6 +168,13 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
168
168
|
} = rest as typeof rest & { rows?: number }
|
|
169
169
|
|
|
170
170
|
const [value, setValue] = useState('')
|
|
171
|
+
// Whether the editor currently holds at least one committed mention chip —
|
|
172
|
+
// it alone needs the tall line box (see the editor's className). Read from the
|
|
173
|
+
// DOM, NOT by testing the draft against `MENTION_GLOBAL`: that regex also
|
|
174
|
+
// matches ordinary typed text (`meet @3:00`, `@prod:us-east`), which would
|
|
175
|
+
// switch the editor to a 36px line box with no chip on screen. Chips are only
|
|
176
|
+
// ever created by `rebuildDom`, so the two writers below cover every path.
|
|
177
|
+
const [hasChips, setHasChips] = useState(false)
|
|
171
178
|
const [focused, setFocused] = useState(false)
|
|
172
179
|
const [isStopping, setIsStopping] = useState(false)
|
|
173
180
|
// The ghost preview is dismissed as soon as the user edits the draft (typing
|
|
@@ -224,7 +231,12 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
224
231
|
if (typeof caret === 'number') placeCaretAtOffset(el, caret)
|
|
225
232
|
else placeCaretAtEnd(el)
|
|
226
233
|
}
|
|
234
|
+
setHasChips(el.querySelector('[data-token]') !== null)
|
|
227
235
|
}
|
|
236
|
+
// Advance the mirror HERE, not only on the next render: `fire` clears the
|
|
237
|
+
// draft and then reads it back within the same tick to decide whether a
|
|
238
|
+
// refused send may restore it.
|
|
239
|
+
valueRef.current = next
|
|
228
240
|
setValue(next)
|
|
229
241
|
}, [])
|
|
230
242
|
|
|
@@ -233,19 +245,31 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
233
245
|
(message: string) => {
|
|
234
246
|
const can = (message.length > 0 || allowEmptySend) && !sending && !disabled
|
|
235
247
|
if (!can || !onSend) return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
// Clear FIRST and put it back only if the host refuses, rather than
|
|
249
|
+
// clearing once the send settles. The editor stays typable while `sending`
|
|
250
|
+
// (see `contentEditable` below), and a host whose `onSend` is async —
|
|
251
|
+
// `TicketReplyComposer` awaits a round trip — leaves a window in which the
|
|
252
|
+
// user starts their next message; a deferred clear would silently delete
|
|
253
|
+
// it. Restoring is guarded on the box still being empty for the same
|
|
254
|
+
// reason. `applyValue` advances `valueRef` synchronously, so the guard
|
|
255
|
+
// reads the cleared draft even when `onSend` refuses in the same tick.
|
|
256
|
+
const sentMeta = metaRef.current
|
|
257
|
+
metaRef.current = new Map()
|
|
258
|
+
applyValue('')
|
|
259
|
+
shouldRefocusRef.current = true
|
|
260
|
+
focusEditor()
|
|
261
|
+
const restore = () => {
|
|
262
|
+
if (valueRef.current.length > 0) return
|
|
263
|
+
metaRef.current = sentMeta
|
|
264
|
+
applyValue(message)
|
|
242
265
|
}
|
|
266
|
+
const result = onSend(message)
|
|
243
267
|
if (result instanceof Promise) {
|
|
244
268
|
void result.then((ok) => {
|
|
245
|
-
if (ok
|
|
269
|
+
if (ok === false) restore()
|
|
246
270
|
})
|
|
247
|
-
} else if (result
|
|
248
|
-
|
|
271
|
+
} else if (result === false) {
|
|
272
|
+
restore()
|
|
249
273
|
}
|
|
250
274
|
},
|
|
251
275
|
[allowEmptySend, sending, disabled, onSend, focusEditor, applyValue],
|
|
@@ -351,6 +375,9 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
351
375
|
const syncFromDom = useCallback(() => {
|
|
352
376
|
const el = editorRef.current
|
|
353
377
|
if (!el) return
|
|
378
|
+
// Typing can DELETE a chip (they are atomic `contentEditable=false` nodes),
|
|
379
|
+
// so the flag has to be re-read here too, not just where chips are built.
|
|
380
|
+
setHasChips(el.querySelector('[data-token]') !== null)
|
|
354
381
|
setValue(serialize(el))
|
|
355
382
|
}, [])
|
|
356
383
|
|
|
@@ -452,14 +479,13 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
452
479
|
const hasContent = value.trim().length > 0 || allowEmptySend
|
|
453
480
|
const sendDisabled = sending || disabled || !hasContent
|
|
454
481
|
const isEmpty = value.length === 0
|
|
455
|
-
const isDisabled = sending || disabled
|
|
456
482
|
// Non-destructive ghost preview (e.g. a hovered quick-action's full prompt).
|
|
457
483
|
// Shown whenever a `previewText` is set (even over an existing draft): the
|
|
458
484
|
// editor's real value is NEVER touched — it's just visually hidden behind the
|
|
459
485
|
// ghost while previewing, and reappears verbatim once `previewText` clears.
|
|
460
486
|
// Declarative, no `setValue` / draft save-restore. Suppressed the moment the
|
|
461
487
|
// user edits the draft (`previewDismissed`) so typing is never hidden.
|
|
462
|
-
const showPreview = !
|
|
488
|
+
const showPreview = !sending && !disabled && !!previewText && !previewDismissed
|
|
463
489
|
|
|
464
490
|
return (
|
|
465
491
|
<div
|
|
@@ -493,24 +519,28 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
493
519
|
}}
|
|
494
520
|
className={cn(
|
|
495
521
|
// `items-center` keeps the empty single-line text AND a mention chip
|
|
496
|
-
// vertically centered the same way. `py-1.5`
|
|
497
|
-
// editor's `leading-9` (36px) →
|
|
498
|
-
//
|
|
499
|
-
// (incl. its ~3.5px vertical-align overhead), so there's almost
|
|
500
|
-
// slack for the chip to drift in → it reads as centered
|
|
501
|
-
//
|
|
522
|
+
// vertically centered the same way. With a chip in the box, `py-1.5`
|
|
523
|
+
// (6px) pairs with the editor's `leading-9` (36px) → that row is
|
|
524
|
+
// 36 + 2*6 = 48px. The 36px line box is just barely above the 32px
|
|
525
|
+
// chip (incl. its ~3.5px vertical-align overhead), so there's almost
|
|
526
|
+
// no slack for the chip to drift in → it reads as centered.
|
|
527
|
+
// A chipless row is shorter than its content box and the `min-h-*`
|
|
528
|
+
// here is what sets its height: 48px from `md` up, 44px below.
|
|
502
529
|
"flex w-full items-center gap-2 px-3 py-1.5 min-h-11 md:min-h-12 cursor-text group transition-colors duration-200",
|
|
503
530
|
!hideBorder && "rounded-[6px] border bg-ods-card border-ods-border",
|
|
504
|
-
!hideBorder && (focused ? "border-ods-accent" : !
|
|
505
|
-
!hideBorder &&
|
|
531
|
+
!hideBorder && (focused ? "border-ods-accent" : !disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover"),
|
|
532
|
+
!hideBorder && disabled && "!cursor-not-allowed bg-ods-bg",
|
|
506
533
|
hideBorder && "bg-transparent",
|
|
507
534
|
)}
|
|
508
535
|
>
|
|
509
536
|
{startIcon && <span className="flex h-6 shrink-0 items-center">{startIcon}</span>}
|
|
510
537
|
|
|
511
538
|
<div className="relative flex-1 min-w-0">
|
|
539
|
+
{/* No chip-line override on the placeholder: it only renders while
|
|
540
|
+
the draft is EMPTY, so the editor is always on its plain line
|
|
541
|
+
box here. */}
|
|
512
542
|
{isEmpty && !showPreview && (
|
|
513
|
-
<span className="pointer-events-none absolute left-0 top-0 select-none text-h4
|
|
543
|
+
<span className="pointer-events-none absolute left-0 top-0 select-none text-h4 text-ods-text-secondary">
|
|
514
544
|
{disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
|
|
515
545
|
</span>
|
|
516
546
|
)}
|
|
@@ -525,7 +555,7 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
525
555
|
text; clearing `previewText` restores the (opacity-hidden)
|
|
526
556
|
editor. */}
|
|
527
557
|
{showPreview && (
|
|
528
|
-
<p className="pointer-events-none absolute inset-x-0 top-0 m-0 select-none truncate text-h4
|
|
558
|
+
<p className={cn("pointer-events-none absolute inset-x-0 top-0 m-0 select-none truncate text-h4 text-ods-text-secondary", hasChips && "!leading-9")}>
|
|
529
559
|
{previewText}
|
|
530
560
|
</p>
|
|
531
561
|
)}
|
|
@@ -540,7 +570,21 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
540
570
|
role="textbox"
|
|
541
571
|
aria-multiline="true"
|
|
542
572
|
aria-label={typeof placeholder === 'string' ? placeholder : 'Message'}
|
|
543
|
-
|
|
573
|
+
// `sending` blocks SENDING, never typing. Taking
|
|
574
|
+
// `contentEditable` away under a focused editor blurs it, and
|
|
575
|
+
// iOS dismisses the keyboard on blur; restoring it re-establishes
|
|
576
|
+
// the input session (and the refocus-after-send effect below
|
|
577
|
+
// re-presents the keyboard once more), so on a phone the keyboard
|
|
578
|
+
// flashed shut and open again around every send — repeatedly,
|
|
579
|
+
// because a host's `sending` toggles once per phase of a turn
|
|
580
|
+
// (`chatLoading` in `embeddable-chat` is `isTyping ||
|
|
581
|
+
// isCompacting`, and the legacy Mingo page adds dialog creation).
|
|
582
|
+
// Nothing is lost by staying editable: `fire()` and the send
|
|
583
|
+
// button are both gated on `sending` already, so a draft typed
|
|
584
|
+
// while a turn streams simply waits for its turn — Enter is a
|
|
585
|
+
// no-op until the send button lights back up. Only `disabled` —
|
|
586
|
+
// the transport is gone — takes the editor out of service.
|
|
587
|
+
contentEditable={!disabled}
|
|
544
588
|
suppressContentEditableWarning
|
|
545
589
|
spellCheck
|
|
546
590
|
onInput={handleInput}
|
|
@@ -558,17 +602,25 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
558
602
|
// see the preview comment above) and is only made transparent;
|
|
559
603
|
// the absolutely-positioned single-line preview paints over it.
|
|
560
604
|
showPreview && "opacity-0",
|
|
561
|
-
// `leading-9` (36px) sits just above the 32px chip's
|
|
562
|
-
// (~35.5px incl. vertical-align overhead): the LINE
|
|
563
|
-
// height (deterministic 48px, no overflow) AND the
|
|
564
|
-
// keeps the chip centered
|
|
565
|
-
// `
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
605
|
+
// WITH chips, `leading-9` (36px) sits just above the 32px chip's
|
|
606
|
+
// line-box need (~35.5px incl. vertical-align overhead): the LINE
|
|
607
|
+
// drives row height (deterministic 48px, no overflow) AND the
|
|
608
|
+
// near-zero slack keeps the chip centered against the `align-top`
|
|
609
|
+
// `h-9` wrapper `buildChipEl` gives it. ~4px gap between wrapped
|
|
610
|
+
// chip rows.
|
|
611
|
+
// WITHOUT chips there is nothing tall to center, and forcing the
|
|
612
|
+
// same 36px box on 14px phone text (`text-h4`) put a blank line's
|
|
613
|
+
// worth of air between every wrapped row — so plain drafts keep
|
|
614
|
+
// `text-h4`'s own responsive line-height (20px / 24px at md).
|
|
615
|
+
// `min-h-*` pins the single-line box even when EMPTY: a
|
|
616
|
+
// non-editable (`contentEditable=false`, i.e. `disabled`) empty div
|
|
617
|
+
// would otherwise collapse to 0px, and `items-center` would
|
|
618
|
+
// re-center the shorter wrapper in the row — dropping the `top-0`
|
|
619
|
+
// placeholder down.
|
|
620
|
+
"text-h4 text-ods-text-primary",
|
|
621
|
+
hasChips ? "min-h-9 !leading-9" : "min-h-[var(--font-line-space-h4-body)]",
|
|
570
622
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
|
|
571
|
-
|
|
623
|
+
disabled && "cursor-not-allowed",
|
|
572
624
|
)}
|
|
573
625
|
/>
|
|
574
626
|
</div>
|
|
@@ -4,6 +4,7 @@ import * as React from "react"
|
|
|
4
4
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|
5
5
|
|
|
6
6
|
import { cn } from "../utils/cn"
|
|
7
|
+
import { useKeyboardCollisionPadding } from "../hooks/ui/use-keyboard-collision-padding"
|
|
7
8
|
|
|
8
9
|
const Popover = PopoverPrimitive.Root
|
|
9
10
|
|
|
@@ -12,20 +13,27 @@ const PopoverTrigger = PopoverPrimitive.Trigger
|
|
|
12
13
|
const PopoverContent = React.forwardRef<
|
|
13
14
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
14
15
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
15
|
-
>(({ className, align = "center", sideOffset = 4, ...props }, ref) =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
16
|
+
>(({ className, align = "center", sideOffset = 4, collisionPadding, ...props }, ref) => {
|
|
17
|
+
// Any popover holding a field — the colour picker's hex/RGB inputs, a search
|
|
18
|
+
// box — is positioned against a collision viewport that does not know the
|
|
19
|
+
// software keyboard exists. See useKeyboardCollisionPadding.
|
|
20
|
+
const keyboardPadding = useKeyboardCollisionPadding()
|
|
21
|
+
return (
|
|
22
|
+
<PopoverPrimitive.Portal>
|
|
23
|
+
<PopoverPrimitive.Content
|
|
24
|
+
ref={ref}
|
|
25
|
+
align={align}
|
|
26
|
+
sideOffset={sideOffset}
|
|
27
|
+
collisionPadding={collisionPadding ?? { bottom: keyboardPadding }}
|
|
28
|
+
className={cn(
|
|
29
|
+
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
|
|
30
|
+
className
|
|
31
|
+
)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
</PopoverPrimitive.Portal>
|
|
35
|
+
)
|
|
36
|
+
})
|
|
29
37
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
|
30
38
|
|
|
31
39
|
export { Popover, PopoverTrigger, PopoverContent }
|
|
@@ -27,6 +27,7 @@ import { Loader2 } from "lucide-react"
|
|
|
27
27
|
import { cn } from "../../utils/cn"
|
|
28
28
|
import { TruncateText } from "./truncate-text"
|
|
29
29
|
import { useAutoLimitTags } from "../../hooks/ui/use-auto-limit-tags"
|
|
30
|
+
import { useKeyboardCollisionPadding } from "../../hooks/ui/use-keyboard-collision-padding"
|
|
30
31
|
import { FieldWrapper } from "./field-wrapper"
|
|
31
32
|
import { HiddenTagsPopup } from "./hidden-tags-popup"
|
|
32
33
|
import { Tag } from "./tag"
|
|
@@ -190,6 +191,7 @@ function AutocompleteInner<T = string>(
|
|
|
190
191
|
|
|
191
192
|
const [isOpen, setIsOpen] = useState(false)
|
|
192
193
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
194
|
+
const keyboardPadding = useKeyboardCollisionPadding()
|
|
193
195
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
194
196
|
const hiddenTagsPopupRef = useRef<HTMLDivElement>(null)
|
|
195
197
|
|
|
@@ -561,12 +563,17 @@ function AutocompleteInner<T = string>(
|
|
|
561
563
|
className={cn(
|
|
562
564
|
"z-50 w-[var(--radix-popover-trigger-width)] mt-1",
|
|
563
565
|
"bg-ods-card border border-ods-border rounded-[4px]",
|
|
566
|
+
"flex flex-col overflow-hidden max-h-[var(--radix-popper-available-height)]",
|
|
564
567
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
565
568
|
"data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
|
|
566
569
|
dropdownClassName
|
|
567
570
|
)}
|
|
568
571
|
sideOffset={4}
|
|
569
572
|
align="start"
|
|
573
|
+
// The anchor is the text field that raises the software keyboard, so
|
|
574
|
+
// this list is always positioned with the keyboard up — see
|
|
575
|
+
// useKeyboardCollisionPadding.
|
|
576
|
+
collisionPadding={{ bottom: keyboardPadding }}
|
|
570
577
|
onOpenAutoFocus={(e) => {
|
|
571
578
|
e.preventDefault()
|
|
572
579
|
autoLimitTags.inputRef.current?.focus()
|
|
@@ -578,8 +585,8 @@ function AutocompleteInner<T = string>(
|
|
|
578
585
|
}
|
|
579
586
|
}}
|
|
580
587
|
>
|
|
581
|
-
<ScrollAreaPrimitive.Root className="overflow-hidden">
|
|
582
|
-
<ScrollAreaPrimitive.Viewport className="max-h-[240px] w-full [&>div]:!block">
|
|
588
|
+
<ScrollAreaPrimitive.Root className="flex min-h-0 flex-col overflow-hidden">
|
|
589
|
+
<ScrollAreaPrimitive.Viewport className="min-h-0 max-h-[240px] w-full [&>div]:!block">
|
|
583
590
|
<div role="listbox">
|
|
584
591
|
{loading ? (
|
|
585
592
|
<div className="px-3 py-2 text-ods-text-secondary text-h6">
|
|
@@ -325,7 +325,11 @@ export function FileUpload<T extends FileUploadCandidate = File>({
|
|
|
325
325
|
// already on screen, an unreadable selection), and this runs from a click
|
|
326
326
|
// handler where a rejection would go unhandled.
|
|
327
327
|
try {
|
|
328
|
-
|
|
328
|
+
// Skip the empty (cancelled) case rather than routing it through
|
|
329
|
+
// handleFiles, whose first act is to clear a validation message the user
|
|
330
|
+
// may still be reading.
|
|
331
|
+
const picked = await pickFiles()
|
|
332
|
+
if (picked.length > 0) handleFiles(picked)
|
|
329
333
|
} catch (err) {
|
|
330
334
|
setValidationError(err instanceof Error ? err.message : "Could not open the file picker")
|
|
331
335
|
}
|
|
@@ -6,6 +6,7 @@ import * as React from "react"
|
|
|
6
6
|
import { cn } from "../../utils/cn"
|
|
7
7
|
import { useDebounce } from "../../hooks/ui/use-debounce"
|
|
8
8
|
import { useAutoLimitTags } from "../../hooks/ui/use-auto-limit-tags"
|
|
9
|
+
import { useKeyboardCollisionPadding } from "../../hooks/ui/use-keyboard-collision-padding"
|
|
9
10
|
import { SearchIcon } from "../icons-v2-generated"
|
|
10
11
|
import { XmarkCircleIcon } from "../icons-v2-generated/signs-and-symbols/xmark-circle-icon"
|
|
11
12
|
import { Tag } from "./tag"
|
|
@@ -168,6 +169,7 @@ export function SearchInput({
|
|
|
168
169
|
// ---- Popover state ----
|
|
169
170
|
const [isOpen, setIsOpen] = React.useState(false)
|
|
170
171
|
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
|
|
172
|
+
const keyboardPadding = useKeyboardCollisionPadding()
|
|
171
173
|
|
|
172
174
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
173
175
|
|
|
@@ -499,6 +501,7 @@ export function SearchInput({
|
|
|
499
501
|
className={cn(
|
|
500
502
|
"z-50 w-[var(--radix-popover-trigger-width)] mt-1",
|
|
501
503
|
"bg-ods-card border border-ods-border rounded-[6px] overflow-hidden shadow-lg",
|
|
504
|
+
"flex flex-col max-h-[var(--radix-popper-available-height)]",
|
|
502
505
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
503
506
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
504
507
|
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
@@ -507,6 +510,10 @@ export function SearchInput({
|
|
|
507
510
|
)}
|
|
508
511
|
sideOffset={4}
|
|
509
512
|
align="start"
|
|
513
|
+
// The anchor is the search field that raises the software keyboard,
|
|
514
|
+
// so these suggestions are always positioned with the keyboard up —
|
|
515
|
+
// see useKeyboardCollisionPadding.
|
|
516
|
+
collisionPadding={{ bottom: keyboardPadding }}
|
|
510
517
|
onOpenAutoFocus={(e) => {
|
|
511
518
|
e.preventDefault()
|
|
512
519
|
inputRef.current?.focus()
|
|
@@ -517,8 +524,8 @@ export function SearchInput({
|
|
|
517
524
|
}
|
|
518
525
|
}}
|
|
519
526
|
>
|
|
520
|
-
<ScrollAreaPrimitive.Root className="overflow-hidden">
|
|
521
|
-
<ScrollAreaPrimitive.Viewport className="max-h-[320px] w-full">
|
|
527
|
+
<ScrollAreaPrimitive.Root className="flex min-h-0 flex-col overflow-hidden">
|
|
528
|
+
<ScrollAreaPrimitive.Viewport className="min-h-0 max-h-[320px] w-full">
|
|
522
529
|
<div role="listbox">
|
|
523
530
|
{renderDropdownContent()}
|
|
524
531
|
</div>
|
|
@@ -5,6 +5,7 @@ import * as React from 'react'
|
|
|
5
5
|
import { CheckIcon, SearchIcon } from '../icons-v2-generated'
|
|
6
6
|
import { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon'
|
|
7
7
|
import { cn } from '../../utils/cn'
|
|
8
|
+
import { useKeyboardCollisionPadding } from '../../hooks/ui/use-keyboard-collision-padding'
|
|
8
9
|
import { Input } from './input'
|
|
9
10
|
|
|
10
11
|
export interface SearchableSelectOption {
|
|
@@ -67,6 +68,7 @@ export function SearchableSelect({
|
|
|
67
68
|
}: SearchableSelectProps) {
|
|
68
69
|
const [isOpen, setIsOpen] = React.useState(false)
|
|
69
70
|
const [search, setSearch] = React.useState('')
|
|
71
|
+
const keyboardPadding = useKeyboardCollisionPadding()
|
|
70
72
|
|
|
71
73
|
React.useEffect(() => {
|
|
72
74
|
if (!isOpen) setSearch('')
|
|
@@ -119,8 +121,13 @@ export function SearchableSelect({
|
|
|
119
121
|
<PopoverPrimitive.Content
|
|
120
122
|
align={align}
|
|
121
123
|
sideOffset={6}
|
|
124
|
+
// The search field below raises the software keyboard the moment this
|
|
125
|
+
// opens — see useKeyboardCollisionPadding for why neither the collision
|
|
126
|
+
// viewport nor the available height knows about it otherwise.
|
|
127
|
+
collisionPadding={{ bottom: keyboardPadding }}
|
|
122
128
|
className={cn(
|
|
123
129
|
'z-50 min-w-[var(--radix-popover-trigger-width)] bg-ods-card border border-ods-border rounded-md shadow-lg overflow-hidden',
|
|
130
|
+
'flex flex-col max-h-[var(--radix-popper-available-height)]',
|
|
124
131
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
125
132
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
126
133
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
@@ -128,7 +135,7 @@ export function SearchableSelect({
|
|
|
128
135
|
contentClassName,
|
|
129
136
|
)}
|
|
130
137
|
>
|
|
131
|
-
<div className="border-b border-ods-border">
|
|
138
|
+
<div className="shrink-0 border-b border-ods-border">
|
|
132
139
|
<Input
|
|
133
140
|
autoFocus
|
|
134
141
|
value={search}
|
|
@@ -138,7 +145,7 @@ export function SearchableSelect({
|
|
|
138
145
|
className="rounded-none border-0"
|
|
139
146
|
/>
|
|
140
147
|
</div>
|
|
141
|
-
<div className="max-h-80 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
|
|
148
|
+
<div className="min-h-0 max-h-80 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
|
|
142
149
|
{isLoading ? (
|
|
143
150
|
<div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-h5 text-ods-text-secondary">
|
|
144
151
|
{loadingText}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
|
4
4
|
import * as React from 'react'
|
|
5
5
|
import { cn } from '../../utils/cn'
|
|
6
|
+
import { useKeyboardCollisionPadding } from '../../hooks/ui/use-keyboard-collision-padding'
|
|
6
7
|
import { CheckIcon, PlusIcon, SearchIcon, TrashIcon } from '../icons-v2-generated'
|
|
7
8
|
import { Button } from './button'
|
|
8
9
|
import { Input } from './input'
|
|
@@ -59,6 +60,7 @@ export function TagSelectDropdown({
|
|
|
59
60
|
}: TagSelectDropdownProps) {
|
|
60
61
|
const [isOpen, setIsOpen] = React.useState(false)
|
|
61
62
|
const [search, setSearch] = React.useState('')
|
|
63
|
+
const keyboardPadding = useKeyboardCollisionPadding()
|
|
62
64
|
|
|
63
65
|
React.useEffect(() => {
|
|
64
66
|
if (!isOpen) setSearch('')
|
|
@@ -110,15 +112,19 @@ export function TagSelectDropdown({
|
|
|
110
112
|
<PopoverPrimitive.Content
|
|
111
113
|
align={align}
|
|
112
114
|
sideOffset={6}
|
|
115
|
+
// The search field below raises the software keyboard the moment this
|
|
116
|
+
// opens — see useKeyboardCollisionPadding.
|
|
117
|
+
collisionPadding={{ bottom: keyboardPadding }}
|
|
113
118
|
className={cn(
|
|
114
119
|
'z-50 w-72 bg-ods-card border border-ods-border rounded-[6px] shadow-lg overflow-hidden',
|
|
120
|
+
'flex flex-col max-h-[var(--radix-popper-available-height)]',
|
|
115
121
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
116
122
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
117
123
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
118
124
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
|
|
119
125
|
)}
|
|
120
126
|
>
|
|
121
|
-
<div className="border-b border-ods-border">
|
|
127
|
+
<div className="shrink-0 border-b border-ods-border">
|
|
122
128
|
<Input
|
|
123
129
|
autoFocus
|
|
124
130
|
value={search}
|
|
@@ -134,7 +140,7 @@ export function TagSelectDropdown({
|
|
|
134
140
|
className="rounded-none border-0"
|
|
135
141
|
/>
|
|
136
142
|
</div>
|
|
137
|
-
<div className="max-h-72 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
|
|
143
|
+
<div className="min-h-0 max-h-72 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
|
|
138
144
|
{ordered.map(opt => {
|
|
139
145
|
const isSelected = selectedIds.includes(opt.id)
|
|
140
146
|
return (
|
|
@@ -4,6 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
5
5
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
|
6
6
|
import { cn } from "../../utils/cn";
|
|
7
|
+
import { useKeyboardCollisionPadding } from "../../hooks/ui/use-keyboard-collision-padding";
|
|
7
8
|
import {
|
|
8
9
|
CheckIcon,
|
|
9
10
|
PencilIcon,
|
|
@@ -70,6 +71,7 @@ export function TagsManager({
|
|
|
70
71
|
}: TagsManagerProps) {
|
|
71
72
|
const [open, setOpen] = React.useState(false);
|
|
72
73
|
const [search, setSearch] = React.useState("");
|
|
74
|
+
const keyboardPadding = useKeyboardCollisionPadding();
|
|
73
75
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
|
74
76
|
const [editingName, setEditingName] = React.useState("");
|
|
75
77
|
const editInputRef = React.useRef<HTMLInputElement>(null);
|
|
@@ -242,6 +244,7 @@ export function TagsManager({
|
|
|
242
244
|
className={cn(
|
|
243
245
|
"z-50 w-[var(--radix-popover-trigger-width)] mt-1",
|
|
244
246
|
"bg-ods-card border border-ods-border rounded",
|
|
247
|
+
"flex flex-col overflow-hidden max-h-[var(--radix-popper-available-height)]",
|
|
245
248
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
246
249
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
247
250
|
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
@@ -249,6 +252,10 @@ export function TagsManager({
|
|
|
249
252
|
)}
|
|
250
253
|
sideOffset={4}
|
|
251
254
|
align="start"
|
|
255
|
+
// The anchor is the tag field that raises the software keyboard, so
|
|
256
|
+
// this list is always positioned with the keyboard up — see
|
|
257
|
+
// useKeyboardCollisionPadding.
|
|
258
|
+
collisionPadding={{ bottom: keyboardPadding }}
|
|
252
259
|
onOpenAutoFocus={(e) => {
|
|
253
260
|
e.preventDefault();
|
|
254
261
|
inputRef.current?.focus();
|
|
@@ -259,8 +266,8 @@ export function TagsManager({
|
|
|
259
266
|
}
|
|
260
267
|
}}
|
|
261
268
|
>
|
|
262
|
-
<ScrollAreaPrimitive.Root className="overflow-hidden">
|
|
263
|
-
<ScrollAreaPrimitive.Viewport className="max-h-60 w-full">
|
|
269
|
+
<ScrollAreaPrimitive.Root className="flex min-h-0 flex-col overflow-hidden">
|
|
270
|
+
<ScrollAreaPrimitive.Viewport className="min-h-0 max-h-60 w-full">
|
|
264
271
|
<div role="listbox">
|
|
265
272
|
{filtered.map((tag) => {
|
|
266
273
|
const isSelected = selectedIds.includes(tag.id);
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './use-horizontal-scrollbar'
|
|
|
8
8
|
export * from './use-image-edge-color'
|
|
9
9
|
export * from './use-is-truncated'
|
|
10
10
|
export * from './use-isomorphic-layout-effect'
|
|
11
|
+
export * from './use-keyboard-collision-padding'
|
|
11
12
|
export * from './use-local-storage'
|
|
12
13
|
export * from './use-marquee-engine'
|
|
13
14
|
export * from './use-media-query'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extra `collisionPadding.bottom` (px) a Radix popper needs to clear the
|
|
7
|
+
* software keyboard. 0 on desktop and anywhere the keyboard is down.
|
|
8
|
+
*
|
|
9
|
+
* Radix positions every popper with floating-ui at `strategy: 'fixed'`, and
|
|
10
|
+
* floating-ui takes its collision viewport from `window.visualViewport`.
|
|
11
|
+
* Neither mobile shell shrinks the box a keyboard opens into: iOS runs
|
|
12
|
+
* Capacitor's Keyboard plugin at `resize: 'none'`, which leaves the WKWebView
|
|
13
|
+
* frame alone, and Android's window is edge-to-edge, where the IME is only a
|
|
14
|
+
* WindowInsets type. So `flip`/`shift`/`size` all believe the screen still
|
|
15
|
+
* reaches the bottom, and a dropdown whose own search field just raised the
|
|
16
|
+
* keyboard is placed straight behind it — worst inside modals, which sit near
|
|
17
|
+
* the bottom of the screen by design.
|
|
18
|
+
*
|
|
19
|
+
* The shell publishes the keyboard height as `--of-keyboard-inset` on <html>
|
|
20
|
+
* (openframe-frontend's `keyboard-inset.ts`; unset everywhere else, hence the
|
|
21
|
+
* 0 default). Handing the part floating-ui does not already know about back as
|
|
22
|
+
* collision padding is what makes the top of the keyboard the bottom edge of
|
|
23
|
+
* the screen.
|
|
24
|
+
*
|
|
25
|
+
* ```tsx
|
|
26
|
+
* const keyboardPadding = useKeyboardCollisionPadding()
|
|
27
|
+
* <PopoverPrimitive.Content collisionPadding={{ bottom: keyboardPadding }} />
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Pair it with `max-h-[var(--radix-popper-available-height)]` on the content:
|
|
31
|
+
* padding alone flips a dropdown above its trigger, but a list taller than what
|
|
32
|
+
* is left above the keyboard would then spill back over it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const KEYBOARD_INSET_VAR = '--of-keyboard-inset'
|
|
36
|
+
|
|
37
|
+
function measure(): number {
|
|
38
|
+
if (typeof document === 'undefined') return 0
|
|
39
|
+
const inset = Number.parseFloat(
|
|
40
|
+
getComputedStyle(document.documentElement).getPropertyValue(KEYBOARD_INSET_VAR),
|
|
41
|
+
)
|
|
42
|
+
// Also catches the NaN of an unset variable.
|
|
43
|
+
if (!(inset > 0)) return 0
|
|
44
|
+
// How far floating-ui's own viewport bottom sits below the top of the
|
|
45
|
+
// keyboard. iOS reports the keyboard on the visual viewport and Android does
|
|
46
|
+
// not; measuring the difference rather than branching on the platform keeps
|
|
47
|
+
// both correct and can never reserve the same keyboard twice.
|
|
48
|
+
const layoutHeight = document.documentElement.clientHeight
|
|
49
|
+
const viewport = window.visualViewport
|
|
50
|
+
const viewportBottom = viewport ? viewport.offsetTop + viewport.height : layoutHeight
|
|
51
|
+
return Math.max(0, Math.round(viewportBottom - (layoutHeight - inset)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// One document-wide subscription for every popper on the page: the keyboard is
|
|
55
|
+
// a single global, and each open dropdown re-reading it would mean another
|
|
56
|
+
// MutationObserver plus a forced style resolve per keystroke-driven re-render.
|
|
57
|
+
const listeners = new Set<() => void>()
|
|
58
|
+
let padding = 0
|
|
59
|
+
let teardown: (() => void) | null = null
|
|
60
|
+
|
|
61
|
+
function sync(): void {
|
|
62
|
+
const next = measure()
|
|
63
|
+
if (next === padding) return
|
|
64
|
+
padding = next
|
|
65
|
+
for (const notify of listeners) notify()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function subscribe(onStoreChange: () => void): () => void {
|
|
69
|
+
listeners.add(onStoreChange)
|
|
70
|
+
if (listeners.size === 1) {
|
|
71
|
+
padding = measure()
|
|
72
|
+
// Two independent signals. The inset lands on <html>'s inline style, so the
|
|
73
|
+
// attribute is the only notification for it; the visual viewport moves on
|
|
74
|
+
// its own schedule (iOS reports the keyboard there, and a pinch-zoom shifts
|
|
75
|
+
// it with no keyboard involved at all) and changes what is left to reserve.
|
|
76
|
+
const observer = new MutationObserver(sync)
|
|
77
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
|
78
|
+
const viewport = window.visualViewport
|
|
79
|
+
viewport?.addEventListener('resize', sync)
|
|
80
|
+
viewport?.addEventListener('scroll', sync)
|
|
81
|
+
teardown = () => {
|
|
82
|
+
observer.disconnect()
|
|
83
|
+
viewport?.removeEventListener('resize', sync)
|
|
84
|
+
viewport?.removeEventListener('scroll', sync)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return () => {
|
|
88
|
+
listeners.delete(onStoreChange)
|
|
89
|
+
if (listeners.size > 0) return
|
|
90
|
+
teardown?.()
|
|
91
|
+
teardown = null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useKeyboardCollisionPadding(): number {
|
|
96
|
+
return useSyncExternalStore(
|
|
97
|
+
subscribe,
|
|
98
|
+
() => padding,
|
|
99
|
+
() => 0,
|
|
100
|
+
)
|
|
101
|
+
}
|