@flamingo-stack/openframe-frontend-core 0.0.535-1762.4618.1 → 0.0.535

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 (122) hide show
  1. package/dist/{chunk-GF5A23J3.cjs → chunk-2P4J2YLI.cjs} +98 -98
  2. package/dist/{chunk-GF5A23J3.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-GBWS62LR.js → chunk-4DTUUM5V.js} +45 -25
  6. package/dist/{chunk-GBWS62LR.js.map → chunk-4DTUUM5V.js.map} +1 -1
  7. package/dist/{chunk-JW4BTXVH.cjs → chunk-4HQFV4PK.cjs} +14 -14
  8. package/dist/{chunk-JW4BTXVH.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-T743Z3KN.cjs → chunk-5OLF6WXF.cjs} +40 -40
  12. package/dist/{chunk-T743Z3KN.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-ZUVBTDLG.cjs → chunk-7MHYRBBQ.cjs} +5 -5
  16. package/dist/{chunk-ZUVBTDLG.cjs.map → chunk-7MHYRBBQ.cjs.map} +1 -1
  17. package/dist/{chunk-T4GNFVT3.cjs → chunk-E3YB55RR.cjs} +173 -153
  18. package/dist/chunk-E3YB55RR.cjs.map +1 -0
  19. package/dist/{chunk-JWWZFEFI.js → chunk-EHPQKXXW.js} +3 -3
  20. package/dist/{chunk-JZMQQBUC.cjs → chunk-GV4FDDFV.cjs} +18 -18
  21. package/dist/{chunk-JZMQQBUC.cjs.map → chunk-GV4FDDFV.cjs.map} +1 -1
  22. package/dist/{chunk-XPKCOZQE.js → chunk-JVO5CYMK.js} +8 -8
  23. package/dist/{chunk-6OZWDOP5.js → chunk-K6566PEB.js} +3 -3
  24. package/dist/{chunk-WXRQYGE5.js → chunk-KLFGP6PR.js} +2 -2
  25. package/dist/{chunk-7TQDW3PE.js → chunk-LIQJAC6Z.js} +2 -2
  26. package/dist/{chunk-GS4R5UQ7.js → chunk-LIX5OUHH.js} +2 -2
  27. package/dist/{chunk-RCBRCZUF.js → chunk-LQPHB2YF.js} +2 -2
  28. package/dist/{chunk-5OPVJBHQ.js → chunk-MZVFZMQ6.js} +6 -6
  29. package/dist/{chunk-URBYMQ4Z.js → chunk-N3AEJTCK.js} +5 -5
  30. package/dist/{chunk-DRJC7TTW.js → chunk-NB6BP6JB.js} +3 -3
  31. package/dist/{chunk-DK6IYFLD.cjs → chunk-P65EZEBW.cjs} +12 -12
  32. package/dist/{chunk-DK6IYFLD.cjs.map → chunk-P65EZEBW.cjs.map} +1 -1
  33. package/dist/{chunk-JWJQHURJ.js → chunk-PLGITXT7.js} +2 -2
  34. package/dist/{chunk-HSRHPF32.cjs → chunk-PSBL3S2B.cjs} +7 -7
  35. package/dist/{chunk-HSRHPF32.cjs.map → chunk-PSBL3S2B.cjs.map} +1 -1
  36. package/dist/{chunk-A5EA3YO7.js → chunk-QUVIT4JP.js} +6 -6
  37. package/dist/{chunk-GRGFMXNO.cjs → chunk-RO67OOKJ.cjs} +40 -40
  38. package/dist/{chunk-GRGFMXNO.cjs.map → chunk-RO67OOKJ.cjs.map} +1 -1
  39. package/dist/{chunk-2AMYXNQ2.cjs → chunk-TS2EHMEX.cjs} +29 -29
  40. package/dist/{chunk-2AMYXNQ2.cjs.map → chunk-TS2EHMEX.cjs.map} +1 -1
  41. package/dist/{chunk-WL3PP4D6.cjs → chunk-U2Q7GEX2.cjs} +32 -32
  42. package/dist/{chunk-WL3PP4D6.cjs.map → chunk-U2Q7GEX2.cjs.map} +1 -1
  43. package/dist/{chunk-QHOVACZK.js → chunk-VPDTDKO5.js} +2 -2
  44. package/dist/{chunk-ENOER2QA.cjs → chunk-Y55XQFJK.cjs} +75 -75
  45. package/dist/{chunk-ENOER2QA.cjs.map → chunk-Y55XQFJK.cjs.map} +1 -1
  46. package/dist/{chunk-HNZDQ4SH.js → chunk-Y72MXG5N.js} +4 -4
  47. package/dist/{chunk-CMIHA6TT.cjs → chunk-ZKKEKG73.cjs} +3 -3
  48. package/dist/{chunk-CMIHA6TT.cjs.map → chunk-ZKKEKG73.cjs.map} +1 -1
  49. package/dist/{chunk-TOWXHZF5.cjs → chunk-ZR5CJTNR.cjs} +4 -4
  50. package/dist/{chunk-TOWXHZF5.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/index.cjs +3 -3
  54. package/dist/components/chat/index.js +2 -2
  55. package/dist/components/contact/index.cjs +4 -4
  56. package/dist/components/contact/index.js +3 -3
  57. package/dist/components/docs/index.cjs +6 -6
  58. package/dist/components/docs/index.js +5 -5
  59. package/dist/components/embeds/index.cjs +4 -4
  60. package/dist/components/embeds/index.js +3 -3
  61. package/dist/components/faq/index.cjs +5 -5
  62. package/dist/components/faq/index.js +4 -4
  63. package/dist/components/features/index.cjs +3 -3
  64. package/dist/components/features/index.js +2 -2
  65. package/dist/components/help-center-pages/index.cjs +24 -24
  66. package/dist/components/help-center-pages/index.js +15 -15
  67. package/dist/components/index.cjs +138 -138
  68. package/dist/components/index.js +13 -13
  69. package/dist/components/meeting-scheduler/index.cjs +40 -40
  70. package/dist/components/meeting-scheduler/index.js +4 -4
  71. package/dist/components/navigation/index.cjs +3 -3
  72. package/dist/components/navigation/index.js +2 -2
  73. package/dist/components/onboarding-guides/index.cjs +7 -7
  74. package/dist/components/onboarding-guides/index.js +6 -6
  75. package/dist/components/popover.d.ts.map +1 -1
  76. package/dist/components/related-content/index.cjs +5 -5
  77. package/dist/components/related-content/index.js +4 -4
  78. package/dist/components/tickets/index.cjs +7 -7
  79. package/dist/components/tickets/index.js +6 -6
  80. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  81. package/dist/components/ui/index.cjs +3 -3
  82. package/dist/components/ui/index.js +2 -2
  83. package/dist/components/ui/search-input.d.ts.map +1 -1
  84. package/dist/components/ui/searchable-select.d.ts.map +1 -1
  85. package/dist/components/ui/tag-select-dropdown.d.ts.map +1 -1
  86. package/dist/components/ui/tags-manager.d.ts.map +1 -1
  87. package/dist/hooks/index.cjs +4 -2
  88. package/dist/hooks/index.cjs.map +1 -1
  89. package/dist/hooks/index.js +3 -1
  90. package/dist/hooks/ui/index.d.ts +1 -0
  91. package/dist/hooks/ui/index.d.ts.map +1 -1
  92. package/dist/hooks/ui/use-keyboard-collision-padding.d.ts +2 -0
  93. package/dist/hooks/ui/use-keyboard-collision-padding.d.ts.map +1 -0
  94. package/dist/index.cjs +5 -3
  95. package/dist/index.cjs.map +1 -1
  96. package/dist/index.js +4 -2
  97. package/package.json +1 -1
  98. package/src/components/popover.tsx +22 -14
  99. package/src/components/ui/autocomplete.tsx +9 -2
  100. package/src/components/ui/search-input.tsx +9 -2
  101. package/src/components/ui/searchable-select.tsx +9 -2
  102. package/src/components/ui/tag-select-dropdown.tsx +8 -2
  103. package/src/components/ui/tags-manager.tsx +9 -2
  104. package/src/hooks/ui/index.ts +1 -0
  105. package/src/hooks/ui/use-keyboard-collision-padding.ts +101 -0
  106. package/dist/chunk-GS4HJA2G.js.map +0 -1
  107. package/dist/chunk-SXLEPNY2.cjs.map +0 -1
  108. package/dist/chunk-T4GNFVT3.cjs.map +0 -1
  109. /package/dist/{chunk-JWWZFEFI.js.map → chunk-EHPQKXXW.js.map} +0 -0
  110. /package/dist/{chunk-XPKCOZQE.js.map → chunk-JVO5CYMK.js.map} +0 -0
  111. /package/dist/{chunk-6OZWDOP5.js.map → chunk-K6566PEB.js.map} +0 -0
  112. /package/dist/{chunk-WXRQYGE5.js.map → chunk-KLFGP6PR.js.map} +0 -0
  113. /package/dist/{chunk-7TQDW3PE.js.map → chunk-LIQJAC6Z.js.map} +0 -0
  114. /package/dist/{chunk-GS4R5UQ7.js.map → chunk-LIX5OUHH.js.map} +0 -0
  115. /package/dist/{chunk-RCBRCZUF.js.map → chunk-LQPHB2YF.js.map} +0 -0
  116. /package/dist/{chunk-5OPVJBHQ.js.map → chunk-MZVFZMQ6.js.map} +0 -0
  117. /package/dist/{chunk-URBYMQ4Z.js.map → chunk-N3AEJTCK.js.map} +0 -0
  118. /package/dist/{chunk-DRJC7TTW.js.map → chunk-NB6BP6JB.js.map} +0 -0
  119. /package/dist/{chunk-JWJQHURJ.js.map → chunk-PLGITXT7.js.map} +0 -0
  120. /package/dist/{chunk-A5EA3YO7.js.map → chunk-QUVIT4JP.js.map} +0 -0
  121. /package/dist/{chunk-QHOVACZK.js.map → chunk-VPDTDKO5.js.map} +0 -0
  122. /package/dist/{chunk-HNZDQ4SH.js.map → chunk-Y72MXG5N.js.map} +0 -0
@@ -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">
@@ -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
+ }