@flamingo-stack/openframe-frontend-core 0.0.533 → 0.0.534-snapshot.20260812085314
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-2MYDGLJS.cjs → chunk-2P4J2YLI.cjs} +98 -98
- package/dist/{chunk-2MYDGLJS.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-LIUARTOZ.js → chunk-4DTUUM5V.js} +150 -309
- package/dist/chunk-4DTUUM5V.js.map +1 -0
- package/dist/{chunk-RKITTO5V.cjs → chunk-4HQFV4PK.cjs} +14 -14
- package/dist/{chunk-RKITTO5V.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-GNOK5J34.cjs → chunk-5OLF6WXF.cjs} +40 -40
- package/dist/{chunk-GNOK5J34.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-OLOKQQAP.cjs → chunk-7MHYRBBQ.cjs} +5 -5
- package/dist/{chunk-OLOKQQAP.cjs.map → chunk-7MHYRBBQ.cjs.map} +1 -1
- package/dist/{chunk-UPHN2VRZ.cjs → chunk-E3YB55RR.cjs} +954 -1113
- package/dist/chunk-E3YB55RR.cjs.map +1 -0
- package/dist/{chunk-5U67WTAS.js → chunk-EHPQKXXW.js} +3 -3
- package/dist/{chunk-JDW2IL7A.cjs → chunk-GV4FDDFV.cjs} +18 -18
- package/dist/{chunk-JDW2IL7A.cjs.map → chunk-GV4FDDFV.cjs.map} +1 -1
- package/dist/{chunk-6W3ULZ4X.js → chunk-JVO5CYMK.js} +8 -8
- package/dist/{chunk-4QKXNWH5.js → chunk-K6566PEB.js} +3 -3
- package/dist/{chunk-YLB6XXO6.js → chunk-KLFGP6PR.js} +2 -2
- package/dist/{chunk-6YXN5RHW.js → chunk-LIQJAC6Z.js} +2 -2
- package/dist/{chunk-46FKXLNE.js → chunk-LIX5OUHH.js} +2 -2
- package/dist/{chunk-RCBRCZUF.js → chunk-LQPHB2YF.js} +2 -2
- package/dist/{chunk-2XGHEJZK.js → chunk-MZVFZMQ6.js} +6 -6
- package/dist/{chunk-6SMLYZLH.js → chunk-N3AEJTCK.js} +5 -5
- package/dist/{chunk-LE3BJ5H2.js → chunk-NB6BP6JB.js} +3 -3
- package/dist/{chunk-QZ2NAPLF.cjs → chunk-P65EZEBW.cjs} +12 -12
- package/dist/{chunk-QZ2NAPLF.cjs.map → chunk-P65EZEBW.cjs.map} +1 -1
- package/dist/{chunk-MVGL5NNJ.js → chunk-PLGITXT7.js} +2 -2
- package/dist/{chunk-KMB66NYX.cjs → chunk-PSBL3S2B.cjs} +7 -7
- package/dist/{chunk-KMB66NYX.cjs.map → chunk-PSBL3S2B.cjs.map} +1 -1
- package/dist/{chunk-U555TMPC.js → chunk-QUVIT4JP.js} +6 -6
- package/dist/{chunk-4ZRHVOEB.cjs → chunk-RO67OOKJ.cjs} +40 -40
- package/dist/{chunk-4ZRHVOEB.cjs.map → chunk-RO67OOKJ.cjs.map} +1 -1
- package/dist/{chunk-NAV4FRNL.cjs → chunk-TS2EHMEX.cjs} +29 -29
- package/dist/{chunk-NAV4FRNL.cjs.map → chunk-TS2EHMEX.cjs.map} +1 -1
- package/dist/{chunk-Z3NOI7X7.cjs → chunk-U2Q7GEX2.cjs} +32 -32
- package/dist/{chunk-Z3NOI7X7.cjs.map → chunk-U2Q7GEX2.cjs.map} +1 -1
- package/dist/{chunk-XN4JKIJY.js → chunk-VPDTDKO5.js} +2 -2
- package/dist/{chunk-ENPGPZIB.cjs → chunk-Y55XQFJK.cjs} +75 -75
- package/dist/{chunk-ENPGPZIB.cjs.map → chunk-Y55XQFJK.cjs.map} +1 -1
- package/dist/{chunk-2TUOBFPK.js → chunk-Y72MXG5N.js} +4 -4
- package/dist/{chunk-DFQLUK75.cjs → chunk-ZKKEKG73.cjs} +3 -3
- package/dist/{chunk-DFQLUK75.cjs.map → chunk-ZKKEKG73.cjs.map} +1 -1
- package/dist/{chunk-J4BE4FIR.cjs → chunk-ZR5CJTNR.cjs} +4 -4
- package/dist/{chunk-J4BE4FIR.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/markdown/rich/embed-overrides.d.ts.map +1 -1
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
- 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/dist/utils/social-embed-cache.d.ts +16 -16
- package/dist/utils/social-embed-cache.d.ts.map +1 -1
- 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/__tests__/__snapshots__/markdown-parity.test.tsx.snap +1 -5
- package/src/components/ui/autocomplete.tsx +9 -2
- package/src/components/ui/file-upload.tsx +5 -1
- package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +58 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +13 -7
- package/src/components/ui/markdown/rich/shortcodes.ts +15 -0
- 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/src/utils/social-embed-cache.ts +63 -336
- package/dist/chunk-GS4HJA2G.js.map +0 -1
- package/dist/chunk-LIUARTOZ.js.map +0 -1
- package/dist/chunk-SXLEPNY2.cjs.map +0 -1
- package/dist/chunk-UPHN2VRZ.cjs.map +0 -1
- /package/dist/{chunk-5U67WTAS.js.map → chunk-EHPQKXXW.js.map} +0 -0
- /package/dist/{chunk-6W3ULZ4X.js.map → chunk-JVO5CYMK.js.map} +0 -0
- /package/dist/{chunk-4QKXNWH5.js.map → chunk-K6566PEB.js.map} +0 -0
- /package/dist/{chunk-YLB6XXO6.js.map → chunk-KLFGP6PR.js.map} +0 -0
- /package/dist/{chunk-6YXN5RHW.js.map → chunk-LIQJAC6Z.js.map} +0 -0
- /package/dist/{chunk-46FKXLNE.js.map → chunk-LIX5OUHH.js.map} +0 -0
- /package/dist/{chunk-RCBRCZUF.js.map → chunk-LQPHB2YF.js.map} +0 -0
- /package/dist/{chunk-2XGHEJZK.js.map → chunk-MZVFZMQ6.js.map} +0 -0
- /package/dist/{chunk-6SMLYZLH.js.map → chunk-N3AEJTCK.js.map} +0 -0
- /package/dist/{chunk-LE3BJ5H2.js.map → chunk-NB6BP6JB.js.map} +0 -0
- /package/dist/{chunk-MVGL5NNJ.js.map → chunk-PLGITXT7.js.map} +0 -0
- /package/dist/{chunk-U555TMPC.js.map → chunk-QUVIT4JP.js.map} +0 -0
- /package/dist/{chunk-XN4JKIJY.js.map → chunk-VPDTDKO5.js.map} +0 -0
- /package/dist/{chunk-2TUOBFPK.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 }
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
|
-
exports[`RichMarkdownRenderer golden parity > renders fixture: audit-reddit-embed-bq 1`] = `
|
|
4
|
-
"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div data-mock="reddit" data-url="https://www.reddit.com/r/msp/comments/abc/post/"></div>
|
|
5
|
-
<script async src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script></article></div></div>"
|
|
6
|
-
`;
|
|
3
|
+
exports[`RichMarkdownRenderer golden parity > renders fixture: audit-reddit-embed-bq 1`] = `"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div data-mock="reddit" data-url="https://www.reddit.com/r/msp/comments/abc/post/"></div></article></div></div>"`;
|
|
7
4
|
|
|
8
5
|
exports[`RichMarkdownRenderer golden parity > renders fixture: audit-style-attrs 1`] = `
|
|
9
6
|
"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div class="takeaway" style="padding: 8px;">Key point</div>
|
|
@@ -160,7 +157,6 @@ exports[`RichMarkdownRenderer golden parity > renders fixture: hostile-onerror 1
|
|
|
160
157
|
|
|
161
158
|
exports[`RichMarkdownRenderer golden parity > renders fixture: hostile-script 1`] = `
|
|
162
159
|
"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4">before</p>
|
|
163
|
-
<p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4"><script>alert(1)</script></p>
|
|
164
160
|
<p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4">after</p></article></div></div>"
|
|
165
161
|
`;
|
|
166
162
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { processShortcodes } from '../shortcodes';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Step 1.5 of processShortcodes: pasted social embed markup ships a
|
|
6
|
+
* `widgets.js` loader `<script>` alongside its blockquote. Scripts never
|
|
7
|
+
* execute on this surface, but before the strip they leaked into the
|
|
8
|
+
* article body as VISIBLE escaped source text (2026-08 Reddit embed
|
|
9
|
+
* incident). These tests pin the strip's contract.
|
|
10
|
+
*/
|
|
11
|
+
describe('processShortcodes script stripping', () => {
|
|
12
|
+
it('strips the Reddit widgets.js loader that accompanies pasted blockquote embeds', () => {
|
|
13
|
+
const input =
|
|
14
|
+
'<blockquote class="reddit-embed-bq" data-embed-height="316">' +
|
|
15
|
+
'<a href="https://www.reddit.com/r/sysadmin/comments/1op05cl/anyone_using_splashtop_as_their_main_remote/">Anyone using Splashtop?</a>' +
|
|
16
|
+
'</blockquote>' +
|
|
17
|
+
'<script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>';
|
|
18
|
+
const output = processShortcodes(input);
|
|
19
|
+
expect(output).not.toContain('<script');
|
|
20
|
+
expect(output).not.toContain('widgets.js');
|
|
21
|
+
expect(output).toContain('reddit-embed-bq');
|
|
22
|
+
expect(output).toContain('/comments/1op05cl/');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('strips the Twitter widgets.js loader', () => {
|
|
26
|
+
const input =
|
|
27
|
+
'<blockquote class="twitter-tweet"><a href="https://twitter.com/user/status/123">tweet</a></blockquote>' +
|
|
28
|
+
'<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>';
|
|
29
|
+
const output = processShortcodes(input);
|
|
30
|
+
expect(output).not.toContain('<script');
|
|
31
|
+
expect(output).toContain('twitter-tweet');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('strips inline script elements with body content', () => {
|
|
35
|
+
const output = processShortcodes('before <script>alert("x")</script> after');
|
|
36
|
+
expect(output).not.toContain('script');
|
|
37
|
+
expect(output).toContain('before');
|
|
38
|
+
expect(output).toContain('after');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('preserves script tags inside fenced code blocks', () => {
|
|
42
|
+
const input = 'Example:\n\n```html\n<script src="https://example.com/app.js"></script>\n```\n';
|
|
43
|
+
const output = processShortcodes(input);
|
|
44
|
+
expect(output).toContain('<script src="https://example.com/app.js"></script>');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('preserves script tags inside inline code', () => {
|
|
48
|
+
const input = 'Use `<script defer></script>` for deferred loading.';
|
|
49
|
+
const output = processShortcodes(input);
|
|
50
|
+
expect(output).toContain('`<script defer></script>`');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('leaves an unclosed script opener alone (falls through to the escape pre-pass)', () => {
|
|
54
|
+
const input = 'An unclosed <script> opener in prose.';
|
|
55
|
+
const output = processShortcodes(input);
|
|
56
|
+
expect(output).toContain('<script>');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -153,13 +153,15 @@ export function buildRichEmbedOverrides({
|
|
|
153
153
|
return (standard.div as any)(divProps);
|
|
154
154
|
},
|
|
155
155
|
|
|
156
|
-
//
|
|
157
|
-
// `<blockquote class="reddit-embed-bq">…<a href="post-url">`
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
156
|
+
// Pasted-from-the-platform embed markup lives in published blog posts as
|
|
157
|
+
// `<blockquote class="reddit-embed-bq">…<a href="post-url">` (58 blocks
|
|
158
|
+
// across 9 posts) — and Twitter's equivalent is
|
|
159
|
+
// `<blockquote class="twitter-tweet">…<a href="status-url">`. Each pair
|
|
160
|
+
// ships a `widgets.js` loader script, which `processShortcodes` strips
|
|
161
|
+
// from the source text (Step 1.5). The composition rehydrates the
|
|
162
|
+
// blockquote itself: extract the post URL from the first matching link
|
|
163
|
+
// and render the platform's embed-client SSOT. Other blockquotes fall
|
|
164
|
+
// through to the engine's base blockquote.
|
|
163
165
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
164
166
|
blockquote: (bqProps: any) => {
|
|
165
167
|
const { node, className } = bqProps;
|
|
@@ -168,6 +170,10 @@ export function buildRichEmbedOverrides({
|
|
|
168
170
|
const postUrl = findFirstHref(node, /reddit\.com/);
|
|
169
171
|
if (postUrl) return <RedditEmbedClient url={postUrl} />;
|
|
170
172
|
}
|
|
173
|
+
if (classNames.includes('twitter-tweet')) {
|
|
174
|
+
const tweetUrl = findFirstHref(node, /(?:twitter\.com|x\.com)\/[^/]+\/status\//);
|
|
175
|
+
if (tweetUrl) return <TwitterEmbedClient url={tweetUrl} />;
|
|
176
|
+
}
|
|
171
177
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
172
178
|
return (standard.blockquote as any)(bqProps);
|
|
173
179
|
},
|
|
@@ -78,6 +78,21 @@ export const processShortcodes = (content: string): string => {
|
|
|
78
78
|
return placeholder;
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
// Step 1.5: Strip well-formed `<script …>…</script>` elements from prose.
|
|
82
|
+
// Pasted social embed markup ships a loader script alongside its markup
|
|
83
|
+
// (Reddit: `<blockquote class="reddit-embed-bq">…` + embed.reddit.com
|
|
84
|
+
// widgets.js; Twitter: platform.twitter.com widgets.js). Scripts NEVER
|
|
85
|
+
// execute on this surface — the sanitize stack strips real ones — but the
|
|
86
|
+
// engine's text pre-pass escapes the tag first, so it used to render as
|
|
87
|
+
// VISIBLE source text under every embed. Runs after Step 1 so script tags
|
|
88
|
+
// inside code fences / inline code stay literal sample code; an UNCLOSED
|
|
89
|
+
// opener is left alone (falls through to today's escape-to-text behavior,
|
|
90
|
+
// which is the safe degrade). Quantifiers are hard-bounded (ReDoS).
|
|
91
|
+
processedContent = processedContent.replace(
|
|
92
|
+
/<script\b[^>]{0,2000}>[\s\S]{0,20000}?<\/script\s{0,10}>/gi,
|
|
93
|
+
''
|
|
94
|
+
);
|
|
95
|
+
|
|
81
96
|
// Step 2: Temporarily replace markdown links to protect them
|
|
82
97
|
const markdownLinks: string[] = [];
|
|
83
98
|
processedContent = processedContent.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match) => {
|
|
@@ -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);
|