@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.
Files changed (127) hide show
  1. package/dist/{chunk-NCUKKJRG.cjs → chunk-2P4J2YLI.cjs} +98 -98
  2. package/dist/{chunk-NCUKKJRG.cjs.map → chunk-2P4J2YLI.cjs.map} +1 -1
  3. package/dist/{chunk-GS4HJA2G.js → chunk-4A736Z6S.js} +74 -20
  4. package/dist/chunk-4A736Z6S.js.map +1 -0
  5. package/dist/{chunk-O6VJZX2N.js → chunk-4DTUUM5V.js} +95 -58
  6. package/dist/chunk-4DTUUM5V.js.map +1 -0
  7. package/dist/{chunk-UWB33C6R.cjs → chunk-4HQFV4PK.cjs} +14 -14
  8. package/dist/{chunk-UWB33C6R.cjs.map → chunk-4HQFV4PK.cjs.map} +1 -1
  9. package/dist/{chunk-GYONSC3U.cjs → chunk-4QYUNDS3.cjs} +3 -3
  10. package/dist/{chunk-GYONSC3U.cjs.map → chunk-4QYUNDS3.cjs.map} +1 -1
  11. package/dist/{chunk-M5ODB6RQ.cjs → chunk-5OLF6WXF.cjs} +40 -40
  12. package/dist/{chunk-M5ODB6RQ.cjs.map → chunk-5OLF6WXF.cjs.map} +1 -1
  13. package/dist/{chunk-SXLEPNY2.cjs → chunk-5RJUHUCI.cjs} +106 -52
  14. package/dist/chunk-5RJUHUCI.cjs.map +1 -0
  15. package/dist/{chunk-RHKH4YUY.cjs → chunk-7MHYRBBQ.cjs} +5 -5
  16. package/dist/{chunk-RHKH4YUY.cjs.map → chunk-7MHYRBBQ.cjs.map} +1 -1
  17. package/dist/{chunk-LYRGM25P.cjs → chunk-E3YB55RR.cjs} +223 -186
  18. package/dist/chunk-E3YB55RR.cjs.map +1 -0
  19. package/dist/{chunk-W4MXOBEW.js → chunk-EHPQKXXW.js} +3 -3
  20. package/dist/{chunk-LBZEPB4Y.cjs → chunk-GV4FDDFV.cjs} +18 -18
  21. package/dist/{chunk-LBZEPB4Y.cjs.map → chunk-GV4FDDFV.cjs.map} +1 -1
  22. package/dist/{chunk-D62VYSTL.js → chunk-JVO5CYMK.js} +8 -8
  23. package/dist/{chunk-BSE2UT6L.js → chunk-K6566PEB.js} +3 -3
  24. package/dist/{chunk-WBYRBTAS.js → chunk-KLFGP6PR.js} +2 -2
  25. package/dist/{chunk-KZ6PWFHK.js → chunk-LIQJAC6Z.js} +2 -2
  26. package/dist/{chunk-MTCU5MQN.js → chunk-LIX5OUHH.js} +2 -2
  27. package/dist/{chunk-RCBRCZUF.js → chunk-LQPHB2YF.js} +2 -2
  28. package/dist/{chunk-BUVD2OAI.js → chunk-MZVFZMQ6.js} +6 -6
  29. package/dist/{chunk-ZLLXYE2G.js → chunk-N3AEJTCK.js} +5 -5
  30. package/dist/{chunk-ONIYUMTO.js → chunk-NB6BP6JB.js} +3 -3
  31. package/dist/{chunk-ZKQXE2P4.cjs → chunk-P65EZEBW.cjs} +12 -12
  32. package/dist/{chunk-ZKQXE2P4.cjs.map → chunk-P65EZEBW.cjs.map} +1 -1
  33. package/dist/{chunk-VYOGAQLM.js → chunk-PLGITXT7.js} +2 -2
  34. package/dist/{chunk-YW2ZU7LC.cjs → chunk-PSBL3S2B.cjs} +7 -7
  35. package/dist/{chunk-YW2ZU7LC.cjs.map → chunk-PSBL3S2B.cjs.map} +1 -1
  36. package/dist/{chunk-H4W4LENJ.js → chunk-QUVIT4JP.js} +6 -6
  37. package/dist/{chunk-2YJ2CVRC.cjs → chunk-RO67OOKJ.cjs} +40 -40
  38. package/dist/{chunk-2YJ2CVRC.cjs.map → chunk-RO67OOKJ.cjs.map} +1 -1
  39. package/dist/{chunk-67MWXMNT.cjs → chunk-TS2EHMEX.cjs} +29 -29
  40. package/dist/{chunk-67MWXMNT.cjs.map → chunk-TS2EHMEX.cjs.map} +1 -1
  41. package/dist/{chunk-UMDGRL6A.cjs → chunk-U2Q7GEX2.cjs} +32 -32
  42. package/dist/{chunk-UMDGRL6A.cjs.map → chunk-U2Q7GEX2.cjs.map} +1 -1
  43. package/dist/{chunk-2ALT6CTY.js → chunk-VPDTDKO5.js} +2 -2
  44. package/dist/{chunk-MLILEBJU.cjs → chunk-Y55XQFJK.cjs} +75 -75
  45. package/dist/{chunk-MLILEBJU.cjs.map → chunk-Y55XQFJK.cjs.map} +1 -1
  46. package/dist/{chunk-POFCTXNH.js → chunk-Y72MXG5N.js} +4 -4
  47. package/dist/{chunk-JELZAWSM.cjs → chunk-ZKKEKG73.cjs} +3 -3
  48. package/dist/{chunk-JELZAWSM.cjs.map → chunk-ZKKEKG73.cjs.map} +1 -1
  49. package/dist/{chunk-EBSZWSC5.cjs → chunk-ZR5CJTNR.cjs} +4 -4
  50. package/dist/{chunk-EBSZWSC5.cjs.map → chunk-ZR5CJTNR.cjs.map} +1 -1
  51. package/dist/components/case-studies/index.cjs +9 -9
  52. package/dist/components/case-studies/index.js +3 -3
  53. package/dist/components/chat/chat-input.d.ts.map +1 -1
  54. package/dist/components/chat/index.cjs +3 -3
  55. package/dist/components/chat/index.js +2 -2
  56. package/dist/components/contact/index.cjs +4 -4
  57. package/dist/components/contact/index.js +3 -3
  58. package/dist/components/docs/index.cjs +6 -6
  59. package/dist/components/docs/index.js +5 -5
  60. package/dist/components/embeds/index.cjs +4 -4
  61. package/dist/components/embeds/index.js +3 -3
  62. package/dist/components/faq/index.cjs +5 -5
  63. package/dist/components/faq/index.js +4 -4
  64. package/dist/components/features/index.cjs +3 -3
  65. package/dist/components/features/index.js +2 -2
  66. package/dist/components/help-center-pages/index.cjs +24 -24
  67. package/dist/components/help-center-pages/index.js +15 -15
  68. package/dist/components/index.cjs +138 -138
  69. package/dist/components/index.js +13 -13
  70. package/dist/components/meeting-scheduler/index.cjs +40 -40
  71. package/dist/components/meeting-scheduler/index.js +4 -4
  72. package/dist/components/navigation/index.cjs +3 -3
  73. package/dist/components/navigation/index.js +2 -2
  74. package/dist/components/onboarding-guides/index.cjs +7 -7
  75. package/dist/components/onboarding-guides/index.js +6 -6
  76. package/dist/components/popover.d.ts.map +1 -1
  77. package/dist/components/related-content/index.cjs +5 -5
  78. package/dist/components/related-content/index.js +4 -4
  79. package/dist/components/tickets/index.cjs +7 -7
  80. package/dist/components/tickets/index.js +6 -6
  81. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  82. package/dist/components/ui/file-upload.d.ts.map +1 -1
  83. package/dist/components/ui/index.cjs +3 -3
  84. package/dist/components/ui/index.js +2 -2
  85. package/dist/components/ui/search-input.d.ts.map +1 -1
  86. package/dist/components/ui/searchable-select.d.ts.map +1 -1
  87. package/dist/components/ui/tag-select-dropdown.d.ts.map +1 -1
  88. package/dist/components/ui/tags-manager.d.ts.map +1 -1
  89. package/dist/hooks/index.cjs +4 -2
  90. package/dist/hooks/index.cjs.map +1 -1
  91. package/dist/hooks/index.js +3 -1
  92. package/dist/hooks/ui/index.d.ts +1 -0
  93. package/dist/hooks/ui/index.d.ts.map +1 -1
  94. package/dist/hooks/ui/use-keyboard-collision-padding.d.ts +2 -0
  95. package/dist/hooks/ui/use-keyboard-collision-padding.d.ts.map +1 -0
  96. package/dist/index.cjs +5 -3
  97. package/dist/index.cjs.map +1 -1
  98. package/dist/index.js +4 -2
  99. package/package.json +1 -1
  100. package/src/components/chat/chat-input.tsx +84 -32
  101. package/src/components/popover.tsx +22 -14
  102. package/src/components/ui/autocomplete.tsx +9 -2
  103. package/src/components/ui/file-upload.tsx +5 -1
  104. package/src/components/ui/search-input.tsx +9 -2
  105. package/src/components/ui/searchable-select.tsx +9 -2
  106. package/src/components/ui/tag-select-dropdown.tsx +8 -2
  107. package/src/components/ui/tags-manager.tsx +9 -2
  108. package/src/hooks/ui/index.ts +1 -0
  109. package/src/hooks/ui/use-keyboard-collision-padding.ts +101 -0
  110. package/dist/chunk-GS4HJA2G.js.map +0 -1
  111. package/dist/chunk-LYRGM25P.cjs.map +0 -1
  112. package/dist/chunk-O6VJZX2N.js.map +0 -1
  113. package/dist/chunk-SXLEPNY2.cjs.map +0 -1
  114. /package/dist/{chunk-W4MXOBEW.js.map → chunk-EHPQKXXW.js.map} +0 -0
  115. /package/dist/{chunk-D62VYSTL.js.map → chunk-JVO5CYMK.js.map} +0 -0
  116. /package/dist/{chunk-BSE2UT6L.js.map → chunk-K6566PEB.js.map} +0 -0
  117. /package/dist/{chunk-WBYRBTAS.js.map → chunk-KLFGP6PR.js.map} +0 -0
  118. /package/dist/{chunk-KZ6PWFHK.js.map → chunk-LIQJAC6Z.js.map} +0 -0
  119. /package/dist/{chunk-MTCU5MQN.js.map → chunk-LIX5OUHH.js.map} +0 -0
  120. /package/dist/{chunk-RCBRCZUF.js.map → chunk-LQPHB2YF.js.map} +0 -0
  121. /package/dist/{chunk-BUVD2OAI.js.map → chunk-MZVFZMQ6.js.map} +0 -0
  122. /package/dist/{chunk-ZLLXYE2G.js.map → chunk-N3AEJTCK.js.map} +0 -0
  123. /package/dist/{chunk-ONIYUMTO.js.map → chunk-NB6BP6JB.js.map} +0 -0
  124. /package/dist/{chunk-VYOGAQLM.js.map → chunk-PLGITXT7.js.map} +0 -0
  125. /package/dist/{chunk-H4W4LENJ.js.map → chunk-QUVIT4JP.js.map} +0 -0
  126. /package/dist/{chunk-2ALT6CTY.js.map → chunk-VPDTDKO5.js.map} +0 -0
  127. /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
- const result = onSend(message)
237
- const done = () => {
238
- metaRef.current = new Map()
239
- applyValue('')
240
- shouldRefocusRef.current = true
241
- focusEditor()
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 !== false) done()
269
+ if (ok === false) restore()
246
270
  })
247
- } else if (result !== false) {
248
- done()
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 = !isDisabled && !!previewText && !previewDismissed
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` (6px) pairs with the
497
- // editor's `leading-9` (36px) → a single line is exactly 48px
498
- // (36 + 2*6). The 36px line box is just barely above the 32px chip
499
- // (incl. its ~3.5px vertical-align overhead), so there's almost no
500
- // slack for the chip to drift in → it reads as centered, and the row
501
- // never overflows past 48px.
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" : !isDisabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover"),
505
- !hideBorder && isDisabled && "!cursor-not-allowed bg-ods-bg",
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 !leading-9 text-ods-text-secondary">
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 !leading-9 text-ods-text-secondary">
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
- contentEditable={!isDisabled}
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 line-box need
562
- // (~35.5px incl. vertical-align overhead): the LINE drives row
563
- // height (deterministic 48px, no overflow) AND the near-zero slack
564
- // keeps the chip centered. ~4px gap between wrapped chip rows.
565
- // `min-h-9` pins that single-line box even when EMPTY: a non-editable
566
- // (`contentEditable=false`, i.e. disabled/sending) empty div would
567
- // otherwise collapse to 0px, and `items-center` would re-center the
568
- // shorter wrapper in the row — dropping the `top-0` placeholder down.
569
- "min-h-9 text-h4 text-ods-text-primary !leading-9",
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
- isDisabled && "cursor-not-allowed",
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
- <PopoverPrimitive.Portal>
17
- <PopoverPrimitive.Content
18
- ref={ref}
19
- align={align}
20
- sideOffset={sideOffset}
21
- className={cn(
22
- "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",
23
- className
24
- )}
25
- {...props}
26
- />
27
- </PopoverPrimitive.Portal>
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
- handleFiles(await pickFiles())
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);
@@ -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
+ }