@multiplatform.one/forms 7.45.0 → 7.46.0

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 (241) hide show
  1. package/dist/cjs/AdaptivePopup/index.native.js +1 -1
  2. package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
  3. package/dist/cjs/Button/index.native.js +1 -1
  4. package/dist/cjs/Button/index.native.js.map +1 -1
  5. package/dist/cjs/Chip/index.native.js.map +1 -1
  6. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js +1 -1
  7. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  8. package/dist/cjs/ContextualSaveBar/clientKnownIdentity.native.js.map +1 -1
  9. package/dist/cjs/ContextualSaveBar/commitImmediateField.native.js.map +1 -1
  10. package/dist/cjs/ContextualSaveBar/index.native.js +3 -3
  11. package/dist/cjs/ContextualSaveBar/index.native.js.map +1 -1
  12. package/dist/cjs/ContextualSaveBar/useUnsavedChangesGuard.native.js.map +1 -1
  13. package/dist/cjs/ControlGroup/index.native.js +1 -1
  14. package/dist/cjs/ControlGroup/index.native.js.map +1 -1
  15. package/dist/cjs/DescriptionList/index.native.js +2 -2
  16. package/dist/cjs/DescriptionList/index.native.js.map +1 -1
  17. package/dist/cjs/ErrorSummary.native.js.map +1 -1
  18. package/dist/cjs/FieldDisplay/editMode.native.js.map +1 -1
  19. package/dist/cjs/FieldDisplay/formatters.native.js.map +1 -1
  20. package/dist/cjs/FieldDisplay/index.native.js +1 -1
  21. package/dist/cjs/FieldDisplay/index.native.js.map +1 -1
  22. package/dist/cjs/FieldGroup.native.js.map +1 -1
  23. package/dist/cjs/FloatingPanel/index.cjs +158 -40
  24. package/dist/cjs/FloatingPanel/nestedAction.native.js.map +1 -1
  25. package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -1
  26. package/dist/cjs/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  27. package/dist/cjs/Form/index.native.js +1 -1
  28. package/dist/cjs/Form/index.native.js.map +1 -1
  29. package/dist/cjs/Form/submissionController.native.js.map +1 -1
  30. package/dist/cjs/Form/validateOn.native.js.map +1 -1
  31. package/dist/cjs/FormGrid.native.js.map +1 -1
  32. package/dist/cjs/FormSection.native.js.map +1 -1
  33. package/dist/cjs/InputParts/index.native.js.map +1 -1
  34. package/dist/cjs/Label/index.native.js.map +1 -1
  35. package/dist/cjs/MediaPicker/base64.native.js.map +1 -1
  36. package/dist/cjs/MediaPicker/payload.native.js.map +1 -1
  37. package/dist/cjs/MediaPicker/types.native.js.map +1 -1
  38. package/dist/cjs/MediaPicker/useMediaPicker.native.js.map +1 -1
  39. package/dist/cjs/Skeleton/index.native.js.map +1 -1
  40. package/dist/cjs/Spinner/SpinnerGlyph.native.js.map +1 -1
  41. package/dist/cjs/Spinner/spinnerSize.native.js.map +1 -1
  42. package/dist/cjs/Table/index.native.js.map +1 -1
  43. package/dist/cjs/Wheel/haptic.native.js.map +1 -1
  44. package/dist/cjs/Wheel/index.native.js.map +1 -1
  45. package/dist/cjs/Wheel/wheelShared.native.js.map +1 -1
  46. package/dist/cjs/actionLabel.native.js.map +1 -1
  47. package/dist/cjs/fieldDefaults.native.js.map +1 -1
  48. package/dist/cjs/fieldLayout.native.js +1 -1
  49. package/dist/cjs/fieldLayout.native.js.map +1 -1
  50. package/dist/cjs/fields/Barcode/BarcodePreview.native.js.map +1 -1
  51. package/dist/cjs/fields/Barcode/BarcodeScanner.native.js +1 -1
  52. package/dist/cjs/fields/Barcode/code128.native.js.map +1 -1
  53. package/dist/cjs/fields/Barcode/index.native.js +2 -2
  54. package/dist/cjs/fields/Barcode/index.native.js.map +1 -1
  55. package/dist/cjs/fields/Barcode/qr.native.js.map +1 -1
  56. package/dist/cjs/fields/Barcode/scannerTypes.native.js.map +1 -1
  57. package/dist/cjs/fields/Checkbox/Checkbox.native.js +1 -1
  58. package/dist/cjs/fields/Checkbox/Checkbox.native.js.map +1 -1
  59. package/dist/cjs/fields/Checkbox/CheckboxBox.native.js.map +1 -1
  60. package/dist/cjs/fields/Checkbox/CheckboxCard.native.js.map +1 -1
  61. package/dist/cjs/fields/Checkbox/CheckboxFieldFrame.native.js.map +1 -1
  62. package/dist/cjs/fields/Checkbox/CheckboxGroup.native.js +1 -1
  63. package/dist/cjs/fields/Checkbox/CheckboxGroup.native.js.map +1 -1
  64. package/dist/cjs/fields/Checkbox/checkboxGroupContext.native.js.map +1 -1
  65. package/dist/cjs/fields/Checkbox/index.native.js.map +1 -1
  66. package/dist/cjs/fields/ColorPicker/index.native.js +3 -3
  67. package/dist/cjs/fields/ColorPicker/index.native.js.map +1 -1
  68. package/dist/cjs/fields/ColorPicker/parts.native.js +2 -2
  69. package/dist/cjs/fields/ColorPicker/parts.native.js.map +1 -1
  70. package/dist/cjs/fields/Combobox/authorOptions.native.js.map +1 -1
  71. package/dist/cjs/fields/Combobox/index.native.js +6 -6
  72. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  73. package/dist/cjs/fields/DatePicker/Calendar.native.js.map +1 -1
  74. package/dist/cjs/fields/DatePicker/DateRangePicker.native.js +2 -2
  75. package/dist/cjs/fields/DatePicker/DateRangePicker.native.js.map +1 -1
  76. package/dist/cjs/fields/DatePicker/DatetimePicker.catalog.native.js +3 -3
  77. package/dist/cjs/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
  78. package/dist/cjs/fields/DatePicker/DatetimePicker.native.js +2 -2
  79. package/dist/cjs/fields/DatePicker/MonthPicker.native.js +2 -2
  80. package/dist/cjs/fields/DatePicker/MonthPicker.native.js.map +1 -1
  81. package/dist/cjs/fields/DatePicker/MultiDatePicker.native.js +2 -2
  82. package/dist/cjs/fields/DatePicker/MultiDatePicker.native.js.map +1 -1
  83. package/dist/cjs/fields/DatePicker/catalog.native.js +2 -2
  84. package/dist/cjs/fields/DatePicker/catalog.native.js.map +1 -1
  85. package/dist/cjs/fields/DatePicker/fieldValueSync.native.js.map +1 -1
  86. package/dist/cjs/fields/DatePicker/index.native.js +2 -2
  87. package/dist/cjs/fields/DatePicker/parts.native.js +2 -2
  88. package/dist/cjs/fields/DatePicker/parts.native.js.map +1 -1
  89. package/dist/cjs/fields/DatePicker/utils.native.js.map +1 -1
  90. package/dist/cjs/fields/Duration/durationMath.native.js.map +1 -1
  91. package/dist/cjs/fields/Duration/index.native.js +3 -3
  92. package/dist/cjs/fields/Duration/index.native.js.map +1 -1
  93. package/dist/cjs/fields/FileUpload/filePickerTypes.native.js.map +1 -1
  94. package/dist/cjs/fields/FileUpload/index.native.js +2 -2
  95. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  96. package/dist/cjs/fields/FileUpload/nativePickerLoader.native.js.map +1 -1
  97. package/dist/cjs/fields/Geolocation/expoMapsLoader.native.js.map +1 -1
  98. package/dist/cjs/fields/Geolocation/geoValue.native.js.map +1 -1
  99. package/dist/cjs/fields/Geolocation/geocoder.native.js.map +1 -1
  100. package/dist/cjs/fields/Geolocation/geocoderContext.native.js.map +1 -1
  101. package/dist/cjs/fields/Geolocation/index.native.js +5 -5
  102. package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
  103. package/dist/cjs/fields/Geolocation/mapMath.native.js.map +1 -1
  104. package/dist/cjs/fields/Geolocation/mapTypes.native.js.map +1 -1
  105. package/dist/cjs/fields/Geolocation/nativeSearchFocus.native.js.map +1 -1
  106. package/dist/cjs/fields/Geolocation/photonGeocoder.native.js.map +1 -1
  107. package/dist/cjs/fields/Icon/drawnSet.native.js.map +1 -1
  108. package/dist/cjs/fields/Icon/index.native.js +3 -3
  109. package/dist/cjs/fields/Icon/index.native.js.map +1 -1
  110. package/dist/cjs/fields/ImageField/index.native.js +2 -2
  111. package/dist/cjs/fields/ImageField/index.native.js.map +1 -1
  112. package/dist/cjs/fields/Input/index.native.js +3 -3
  113. package/dist/cjs/fields/Input/index.native.js.map +1 -1
  114. package/dist/cjs/fields/MentionInput/index.native.js +3 -3
  115. package/dist/cjs/fields/MentionInput/index.native.js.map +1 -1
  116. package/dist/cjs/fields/OTPInput/index.native.js +3 -3
  117. package/dist/cjs/fields/OTPInput/index.native.js.map +1 -1
  118. package/dist/cjs/fields/OTPInput/otpCellCss.native.js.map +1 -1
  119. package/dist/cjs/fields/PhoneInput/dialCodes.native.js.map +1 -1
  120. package/dist/cjs/fields/PhoneInput/index.native.js +3 -3
  121. package/dist/cjs/fields/PhoneInput/index.native.js.map +1 -1
  122. package/dist/cjs/fields/PhoneInput/phone.native.js.map +1 -1
  123. package/dist/cjs/fields/PhoneInput/regionNames.native.js.map +1 -1
  124. package/dist/cjs/fields/Progress/index.native.js +1 -1
  125. package/dist/cjs/fields/Progress/index.native.js.map +1 -1
  126. package/dist/cjs/fields/RadioGroup/index.native.js +1 -1
  127. package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
  128. package/dist/cjs/fields/Rating/index.native.js +1 -1
  129. package/dist/cjs/fields/Rating/index.native.js.map +1 -1
  130. package/dist/cjs/fields/Rating/ratingMath.native.js.map +1 -1
  131. package/dist/cjs/fields/Rating/types.native.js.map +1 -1
  132. package/dist/cjs/fields/SearchInput/index.native.js +2 -2
  133. package/dist/cjs/fields/SearchInput/index.native.js.map +1 -1
  134. package/dist/cjs/fields/Select/index.native.js +4 -4
  135. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  136. package/dist/cjs/fields/Signature/index.native.js +2 -2
  137. package/dist/cjs/fields/Signature/index.native.js.map +1 -1
  138. package/dist/cjs/fields/Slider/index.native.js +1 -1
  139. package/dist/cjs/fields/Slider/index.native.js.map +1 -1
  140. package/dist/cjs/fields/Slider/jumpMotion.native.js.map +1 -1
  141. package/dist/cjs/fields/Stepper/index.native.js +2 -2
  142. package/dist/cjs/fields/Stepper/index.native.js.map +1 -1
  143. package/dist/cjs/fields/Stepper/resolveStepperPlacement.native.js.map +1 -1
  144. package/dist/cjs/fields/Switch/footprint.native.js.map +1 -1
  145. package/dist/cjs/fields/Switch/index.native.js +2 -2
  146. package/dist/cjs/fields/Switch/index.native.js.map +1 -1
  147. package/dist/cjs/fields/TextArea/index.native.js +2 -2
  148. package/dist/cjs/fields/TextArea/index.native.js.map +1 -1
  149. package/dist/cjs/fields/TimePicker/catalog.native.js +3 -3
  150. package/dist/cjs/fields/TimePicker/catalog.native.js.map +1 -1
  151. package/dist/cjs/fields/TimePicker/index.native.js +2 -2
  152. package/dist/cjs/fields/TimePicker/parts.native.js +1 -1
  153. package/dist/cjs/fields/TimePicker/parts.native.js.map +1 -1
  154. package/dist/cjs/fields/TimeRangeScrubber/geometry.native.js.map +1 -1
  155. package/dist/cjs/fields/TimeRangeScrubber/index.native.js +2 -2
  156. package/dist/cjs/fields/TimeRangeScrubber/index.native.js.map +1 -1
  157. package/dist/cjs/fields/ToggleGroup/index.native.js +1 -1
  158. package/dist/cjs/fields/ToggleGroup/index.native.js.map +1 -1
  159. package/dist/cjs/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
  160. package/dist/cjs/fields/nativeDateTime/dateTimePickerLoader.native.js.map +1 -1
  161. package/dist/cjs/fields/nativeDateTime/osDateTimeIosDisplay.native.js.map +1 -1
  162. package/dist/cjs/fields/nativeSelect.native.js.map +1 -1
  163. package/dist/cjs/fields/selectedOrder.native.js.map +1 -1
  164. package/dist/cjs/formSpacing.native.js.map +1 -1
  165. package/dist/cjs/hooks/useFocusManagement.native.js.map +1 -1
  166. package/dist/cjs/index.native.js +44 -44
  167. package/dist/cjs/index.native.js.map +1 -1
  168. package/dist/cjs/keyboard/KeyboardDoneBar.native.js +1 -1
  169. package/dist/cjs/keyboard/chain.native.js.map +1 -1
  170. package/dist/cjs/keyboard/index.native.js.map +1 -1
  171. package/dist/cjs/keyboard/types.native.js.map +1 -1
  172. package/dist/cjs/requiredMarking.native.js.map +1 -1
  173. package/dist/cjs/shared/TimeColumn.native.js +1 -1
  174. package/dist/cjs/shared/TimeColumn.native.js.map +1 -1
  175. package/dist/cjs/shared/capabilityPreflight.native.js.map +1 -1
  176. package/dist/cjs/shared/colorRamps.native.js.map +1 -1
  177. package/dist/cjs/shared/devWarn.native.js.map +1 -1
  178. package/dist/cjs/shared/floatingList.native.js +1 -1
  179. package/dist/cjs/shared/floatingList.native.js.map +1 -1
  180. package/dist/cjs/shared/focusManagement.cjs +3 -3
  181. package/dist/cjs/shared/focusManagement.native.js +3 -3
  182. package/dist/cjs/shared/focusManagement.native.js.map +1 -1
  183. package/dist/cjs/shared/groupContext.native.js.map +1 -1
  184. package/dist/cjs/shared/i18n.native.js.map +1 -1
  185. package/dist/cjs/shared/instantMotion.native.js.map +1 -1
  186. package/dist/cjs/shared/nativeA11y.native.js.map +1 -1
  187. package/dist/cjs/shared/nativeAutoFocus.native.js.map +1 -1
  188. package/dist/cjs/shared/panelPortal.native.js.map +1 -1
  189. package/dist/cjs/shared/pickerTriggerContract.native.js.map +1 -1
  190. package/dist/cjs/shared/pressSlopProps.native.js.map +1 -1
  191. package/dist/cjs/shared/t.native.js.map +1 -1
  192. package/dist/cjs/shared/tableCellContext.native.js.map +1 -1
  193. package/dist/cjs/shared/useDragScroll.native.js.map +1 -1
  194. package/dist/cjs/shared/useKeyboardInset.native.js.map +1 -1
  195. package/dist/cjs/shared/useSheetEscapeDismiss.cjs +5 -4
  196. package/dist/cjs/shared/useSheetEscapeDismiss.native.js +5 -3
  197. package/dist/cjs/shared/useSheetEscapeDismiss.native.js.map +1 -1
  198. package/dist/cjs/shared/useWheelIncrement.native.js.map +1 -1
  199. package/dist/cjs/shared/utils.native.js +1 -1
  200. package/dist/cjs/shared/utils.native.js.map +1 -1
  201. package/dist/cjs/shared/zIndex.native.js.map +1 -1
  202. package/dist/cjs/themedPrimitives.native.js.map +1 -1
  203. package/dist/cjs/types.native.js.map +1 -1
  204. package/dist/cjs/utils/LinkCell.native.js.map +1 -1
  205. package/dist/esm/FloatingPanel/index.mjs +160 -42
  206. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  207. package/dist/esm/shared/focusManagement.mjs +3 -3
  208. package/dist/esm/shared/focusManagement.mjs.map +1 -1
  209. package/dist/esm/shared/focusManagement.native.js +3 -3
  210. package/dist/esm/shared/focusManagement.native.js.map +1 -1
  211. package/dist/esm/shared/useSheetEscapeDismiss.mjs +5 -4
  212. package/dist/esm/shared/useSheetEscapeDismiss.mjs.map +1 -1
  213. package/dist/esm/shared/useSheetEscapeDismiss.native.js +5 -3
  214. package/dist/esm/shared/useSheetEscapeDismiss.native.js.map +1 -1
  215. package/dist/jsx/FloatingPanel/index.mjs +160 -42
  216. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  217. package/dist/jsx/index.native.js.map +1 -1
  218. package/dist/jsx/shared/focusManagement.mjs +3 -3
  219. package/dist/jsx/shared/focusManagement.mjs.map +1 -1
  220. package/dist/jsx/shared/focusManagement.native.js +3 -3
  221. package/dist/jsx/shared/focusManagement.native.js.map +1 -1
  222. package/dist/jsx/shared/useSheetEscapeDismiss.mjs +5 -4
  223. package/dist/jsx/shared/useSheetEscapeDismiss.mjs.map +1 -1
  224. package/dist/jsx/shared/useSheetEscapeDismiss.native.js +5 -3
  225. package/dist/jsx/shared/useSheetEscapeDismiss.native.js.map +1 -1
  226. package/package.json +8 -8
  227. package/src/FloatingPanel/FloatingPanel.coverTrigger.spec.tsx +184 -0
  228. package/src/FloatingPanel/FloatingPanel.spec.tsx +3 -5
  229. package/src/FloatingPanel/FloatingPanel.stories.tsx +26 -0
  230. package/src/FloatingPanel/index.tsx +255 -59
  231. package/src/FloatingPanel/useFloatingPanel.spec.tsx +4 -2
  232. package/src/shared/focusManagement.spec.tsx +11 -1
  233. package/src/shared/focusManagement.ts +16 -7
  234. package/src/shared/interactionHooks.spec.tsx +13 -0
  235. package/src/shared/useSheetEscapeDismiss.ts +9 -4
  236. package/types/FloatingPanel/index.d.ts +9 -1
  237. package/types/FloatingPanel/index.d.ts.map +1 -1
  238. package/types/shared/focusManagement.d.ts +10 -5
  239. package/types/shared/focusManagement.d.ts.map +1 -1
  240. package/types/shared/useSheetEscapeDismiss.d.ts +1 -1
  241. package/types/shared/useSheetEscapeDismiss.d.ts.map +1 -1
@@ -1,6 +1,8 @@
1
1
  import { transitionProps } from "@multiplatform.one/theme";
2
2
  import type { ReactNode, RefObject, UIEvent } from "react";
3
3
  import {
4
+ cloneElement,
5
+ isValidElement,
4
6
  useCallback,
5
7
  useEffect,
6
8
  useLayoutEffect,
@@ -16,6 +18,7 @@ import {
16
18
  useResolvedKnobs,
17
19
  wasKeyboardFocus,
18
20
  ensureKeyboardModalityTracking,
21
+ motionDurationMs,
19
22
  } from "@multiplatform.one/theme";
20
23
  import { isWeb, Sheet, View, YStack } from "tamagui";
21
24
  import { useClick, useDismiss, useInteractions } from "@floating-ui/react";
@@ -66,6 +69,9 @@ export interface FloatingPanelProps {
66
69
  /** Suppress delayed trigger focus when a consumer handles close focus itself. Web only. */
67
70
  skipReturnFocusRef?: RefObject<boolean>;
68
71
  trigger: ReactNode;
72
+ anchorMode?: "cover-trigger" | "attach";
73
+ triggerRow?: ReactNode;
74
+ anchorRect?: { x: number; y: number; width: number; height: number };
69
75
  triggerEcho?: boolean;
70
76
  /**
71
77
  * Optional control rendered beside the trigger but OUTSIDE the native press
@@ -168,7 +174,7 @@ export interface FloatingPanelProps {
168
174
  * COVER-TRIGGER (LC-105): the panel's top edge sits on the trigger's TOP
169
175
  * edge instead of attaching below it, so the trigger becomes the panel's
170
176
  * first row by default. Set triggerEcho=false for a custom first row. The panel scales from
171
- * the trigger's top start corner. Dropping up, the bottom edges coincide.
177
+ * the trigger's top start corner. Viewport collisions use the bottom sheet.
172
178
  * Enabled by default. Web float only; the native and narrow-web sheet is unchanged.
173
179
  */
174
180
  cover?: boolean;
@@ -201,7 +207,7 @@ function useKeyboardFocusIntoPanel(
201
207
  if (!wasKeyboardFocus() && !keyboardOpenRef?.current) return;
202
208
  const active = document.activeElement;
203
209
  if (active instanceof HTMLElement && frame.contains(active) && active !== frame) return;
204
- // The trigger echo row is presentation only (inert): skip its controls.
210
+ // Initial keyboard focus belongs to an action, not the repeated close row.
205
211
  const first = Array.from(frame.querySelectorAll<HTMLElement>(PANEL_FOCUSABLE)).find(
206
212
  (el) => !el.closest("[data-fp-trigger-row]"),
207
213
  );
@@ -254,12 +260,12 @@ function fixedContainingBlockOrigin(element: HTMLElement): { top: number; left:
254
260
  return { top: 0, left: 0 };
255
261
  }
256
262
 
257
- function isRtl(): boolean {
258
- return (
259
- isWeb &&
260
- typeof document !== "undefined" &&
261
- document.documentElement.getAttribute("dir") === "rtl"
262
- );
263
+ function isRtl(element?: HTMLElement | null): boolean {
264
+ if (!isWeb || typeof document === "undefined") return false;
265
+ const direction = element ? getComputedStyle(element).direction : "";
266
+ return direction
267
+ ? direction === "rtl"
268
+ : (element?.closest("[dir]") ?? document.documentElement).getAttribute("dir") === "rtl";
263
269
  }
264
270
 
265
271
  function panelTransformOrigin(placement: string): string {
@@ -297,41 +303,94 @@ const ECHO_STRIPPED_ATTRIBUTES = [
297
303
  function TriggerEchoRow({
298
304
  sourceRef,
299
305
  onPress,
306
+ children,
307
+ anchorRect,
308
+ source,
300
309
  }: {
301
310
  sourceRef: RefObject<HTMLElement | null>;
302
311
  onPress: () => void;
312
+ children?: ReactNode;
313
+ anchorRect?: FloatingPanelProps["anchorRect"];
314
+ source: ReactNode;
303
315
  }) {
304
316
  const rowRef = useRef<HTMLElement | null>(null);
317
+ const visualRef = useRef<HTMLElement | null>(null);
305
318
  useLayoutEffect(() => {
306
319
  const row = rowRef.current;
307
320
  const source = sourceRef.current;
308
321
  if (!row || !source || typeof source.cloneNode !== "function") return;
309
- const snapshot = source.cloneNode(true) as HTMLElement;
310
- const sourceFields = source.querySelectorAll<HTMLInputElement>("input, textarea");
311
- const snapshotFields = snapshot.querySelectorAll<HTMLInputElement>("input, textarea");
312
- sourceFields.forEach((field, index) => {
313
- const copy = snapshotFields[index];
314
- if (copy) copy.value = field.value;
315
- });
316
- for (const node of [snapshot, ...Array.from(snapshot.querySelectorAll<HTMLElement>("*"))]) {
317
- for (const name of ECHO_STRIPPED_ATTRIBUTES) node.removeAttribute(name);
318
- if (node.hasAttribute("tabindex") || node.matches("input, textarea, button, a, select")) {
319
- node.setAttribute("tabindex", "-1");
322
+ const sync = () => {
323
+ const control = source.matches(PANEL_FOCUSABLE)
324
+ ? source
325
+ : source.querySelector<HTMLElement>(PANEL_FOCUSABLE);
326
+ const labelledBy = control?.getAttribute("aria-labelledby");
327
+ const label =
328
+ control?.getAttribute("aria-label") ||
329
+ labelledBy
330
+ ?.split(/\s+/)
331
+ .map((id) => document.getElementById(id)?.textContent || "")
332
+ .join(" ") ||
333
+ control?.textContent ||
334
+ source.textContent ||
335
+ "";
336
+ row.setAttribute("aria-label", label);
337
+ const rect = anchorRect ?? source.getBoundingClientRect();
338
+ row.style.width = `${rect.width}px`;
339
+ row.style.height = `${rect.height}px`;
340
+ const rtl = isRtl(source);
341
+ row.style.alignSelf = "flex-start";
342
+ const frameStyle = row.parentElement ? getComputedStyle(row.parentElement) : null;
343
+ row.style.marginTop = `${-(parseFloat(frameStyle?.borderTopWidth || "0") || 0)}px`;
344
+ row.style.position = "sticky";
345
+ row.style.top = row.style.marginTop;
346
+ row.style.zIndex = "1";
347
+ row.style.marginLeft = rtl
348
+ ? ""
349
+ : `${-(parseFloat(frameStyle?.borderLeftWidth || "0") || 0)}px`;
350
+ row.style.marginRight = rtl
351
+ ? `${-(parseFloat(frameStyle?.borderRightWidth || "0") || 0)}px`
352
+ : "";
353
+ const snapshot =
354
+ children == null ? (source.cloneNode(true) as HTMLElement) : visualRef.current;
355
+ if (!snapshot) return;
356
+ const sourceFields = source.querySelectorAll<HTMLInputElement>("input, textarea");
357
+ const snapshotFields = snapshot.querySelectorAll<HTMLInputElement>("input, textarea");
358
+ sourceFields.forEach((field, index) => {
359
+ const copy = snapshotFields[index];
360
+ if (copy) copy.value = field.value;
361
+ });
362
+ for (const node of [snapshot, ...Array.from(snapshot.querySelectorAll<HTMLElement>("*"))]) {
363
+ for (const name of ECHO_STRIPPED_ATTRIBUTES) node.removeAttribute(name);
364
+ if (node.hasAttribute("tabindex") || node.matches("input, textarea, button, a, select")) {
365
+ node.setAttribute("tabindex", "-1");
366
+ }
320
367
  }
321
- }
322
- snapshot.setAttribute("inert", "");
323
- row.replaceChildren(snapshot);
324
- });
368
+ snapshot.setAttribute("inert", "");
369
+ snapshot.setAttribute("aria-hidden", "true");
370
+ if (children == null) visualRef.current?.replaceChildren(snapshot);
371
+ };
372
+ sync();
373
+ if (typeof ResizeObserver === "undefined") return;
374
+ const observer = new ResizeObserver(sync);
375
+ observer.observe(source);
376
+ return () => observer.disconnect();
377
+ }, [sourceRef, source, children, anchorRect]);
325
378
  return (
326
379
  <View
327
380
  ref={rowRef as any}
328
381
  data-fp-trigger-row=""
329
- aria-hidden
382
+ role="button"
383
+ aria-expanded={true}
384
+ tabIndex={-1}
330
385
  cursor="pointer"
331
386
  onPress={onPress}
332
387
  flexShrink={0}
333
388
  pointerEvents="auto"
334
- />
389
+ >
390
+ <View ref={visualRef as any} pointerEvents="none" aria-hidden>
391
+ {isValidElement(children) ? cloneElement(children, { ref: null } as any) : children}
392
+ </View>
393
+ </View>
335
394
  );
336
395
  }
337
396
 
@@ -373,7 +432,11 @@ export function FloatingPanel(props: FloatingPanelProps) {
373
432
  const shouldUseSheet = props.sheet ?? (!isWeb || !viewportGtSm);
374
433
  // The panel floats over the page: its content never sits on a painted
375
434
  // ancestor, so a Card inside it paints as top-level.
376
- const panelProps = { ...props, children: <Surface painted={false}>{props.children}</Surface> };
435
+ const panelProps = {
436
+ ...props,
437
+ cover: props.anchorMode ? props.anchorMode === "cover-trigger" : props.cover,
438
+ children: <Surface painted={false}>{props.children}</Surface>,
439
+ };
377
440
 
378
441
  if (shouldUseSheet) return <FloatingPanelSheet {...panelProps} />;
379
442
  return <FloatingPanelWeb {...panelProps} />;
@@ -416,7 +479,8 @@ function FloatingPanelSheet({
416
479
  header,
417
480
  scrollViewProps,
418
481
  openOn = "press",
419
- }: FloatingPanelProps) {
482
+ escapeTarget = "window",
483
+ }: FloatingPanelProps & { escapeTarget?: "window" | "document" }) {
420
484
  const { knobProps } = useResolvedKnobs({ painted: false });
421
485
  // LC-24 / A-ENTEREXIT: honor animation=none (undefined) — do not hardcode "medium".
422
486
  const slideMotion = {
@@ -438,7 +502,7 @@ function FloatingPanelSheet({
438
502
  };
439
503
  // Tamagui Sheet has no key handling (unlike the web floating mode, which
440
504
  // dismisses on Escape) — wire the dismissal intent here too.
441
- useSheetEscapeDismiss(open, () => handleOpenChange(false));
505
+ useSheetEscapeDismiss(open, () => handleOpenChange(false), escapeTarget);
442
506
  // DG-A11Y-04 / Axiom 7: Sheet has no focus scope — move focus into the
443
507
  // frame on open, trap Tab while open, return focus to the trigger on close.
444
508
  const sheetFrameRef = useRef<HTMLElement | null>(null);
@@ -484,23 +548,25 @@ function FloatingPanelSheet({
484
548
 
485
549
  return (
486
550
  <>
487
- <View
488
- flexDirection="row"
489
- alignItems="center"
490
- width={triggerSizing === "content" ? "auto" : "100%"}
491
- >
551
+ {trigger != null && (
492
552
  <View
493
- flex={triggerSizing === "content" ? undefined : 1}
494
- minWidth={0}
495
- {...(isContextMenu ? undefined : { onPress: openFromTrigger })}
496
- cursor={disabled ? "not-allowed" : "pointer"}
497
- opacity={disabled ? 0.5 : 1}
498
- {...triggerActivation}
553
+ flexDirection="row"
554
+ alignItems="center"
555
+ width={triggerSizing === "content" ? "auto" : "100%"}
499
556
  >
500
- {trigger}
557
+ <View
558
+ flex={triggerSizing === "content" ? undefined : 1}
559
+ minWidth={0}
560
+ {...(isContextMenu ? undefined : { onPress: openFromTrigger })}
561
+ cursor={disabled ? "not-allowed" : "pointer"}
562
+ opacity={disabled ? 0.5 : 1}
563
+ {...triggerActivation}
564
+ >
565
+ {trigger}
566
+ </View>
567
+ {triggerEnd}
501
568
  </View>
502
- {triggerEnd}
503
- </View>
569
+ )}
504
570
  <Sheet
505
571
  modal
506
572
  open={open}
@@ -521,6 +587,7 @@ function FloatingPanelSheet({
521
587
  />
522
588
  <Sheet.Handle />
523
589
  <Sheet.Frame
590
+ data-testid="floating-panel-sheet"
524
591
  // Sheet.Frame forwards its ref at runtime; the styled prop type
525
592
  // just omits `ref`, hence the cast. tabIndex -1 makes the frame a
526
593
  // programmatic focus target without joining the tab order.
@@ -562,7 +629,11 @@ function FloatingPanelWeb({
562
629
  openOn = "press",
563
630
  cover = true,
564
631
  triggerEcho = true,
632
+ triggerRow,
633
+ anchorRect,
634
+ ...sheetProps
565
635
  }: FloatingPanelProps) {
636
+ const [collisionSheet, setCollisionSheet] = useState(false);
566
637
  const isFill = sizing === "fill";
567
638
  const isContextMenu = openOn === "contextmenu";
568
639
  const overlayStyleRef = useRef<{ top: number; left?: number; maxHeight: number } | null>(null);
@@ -591,7 +662,7 @@ function FloatingPanelWeb({
591
662
  };
592
663
 
593
664
  const panel = useFloatingPanel({
594
- open,
665
+ open: open && !collisionSheet,
595
666
  onOpenChange,
596
667
  disabled,
597
668
  repositionRef: repositionFnRef,
@@ -607,14 +678,27 @@ function FloatingPanelWeb({
607
678
  // Escape (or outside click on empty space) closes the panel while focus is
608
679
  // inside it, focus falls to <body>. Return it to the pre-open element
609
680
  // (the trigger's focusable). Deliberate focus moves elsewhere are kept.
610
- useReturnFocusOnClose(open, skipReturnFocusRef);
611
- useKeyboardFocusIntoPanel(open && panel.mounted, panel.refs.floating, keyboardOpenRef);
681
+ useReturnFocusOnClose(open, skipReturnFocusRef, panel.refs.floating);
682
+ useKeyboardFocusIntoPanel(
683
+ open && panel.mounted && !collisionSheet,
684
+ panel.refs.floating,
685
+ keyboardOpenRef,
686
+ );
612
687
  useEffect(() => {
613
688
  if (open) return;
614
689
  keyboardOpenRef.current = false;
615
690
  anchorPointRef.current = null;
616
691
  }, [open]);
617
692
 
693
+ useEffect(() => {
694
+ if (open || !collisionSheet) return;
695
+ const timer = setTimeout(
696
+ () => setCollisionSheet(false),
697
+ motionDurationMs(panel.knobProps.motion.slide.transition),
698
+ );
699
+ return () => clearTimeout(timer);
700
+ }, [open, collisionSheet, panel.knobProps.motion.slide.transition]);
701
+
618
702
  const repositionOverlay = useCallback(() => {
619
703
  const dropdownElement = panel.refs.floating.current;
620
704
  const trig = panel.triggerRef.current;
@@ -630,7 +714,16 @@ function FloatingPanelWeb({
630
714
  const point = isContextMenu
631
715
  ? (anchorPointRef.current ?? referenceCornerPoint(trig, isRtl()))
632
716
  : null;
633
- const triggerBounds = point ? { top: point.y, bottom: point.y } : trig.getBoundingClientRect();
717
+ const measured = anchorRect
718
+ ? {
719
+ ...anchorRect,
720
+ top: anchorRect.y,
721
+ left: anchorRect.x,
722
+ right: anchorRect.x + anchorRect.width,
723
+ bottom: anchorRect.y + anchorRect.height,
724
+ }
725
+ : trig.getBoundingClientRect();
726
+ const triggerBounds = point ? { top: point.y, bottom: point.y } : measured;
634
727
  const viewportHeight = window.innerHeight;
635
728
  const cs = getComputedStyle(dropdownElement);
636
729
  const borderTopWidth = parseFloat(cs.borderTopWidth) || 0;
@@ -641,6 +734,70 @@ function FloatingPanelWeb({
641
734
  const liveTransition = panel.panelTransition;
642
735
  const containingBlock = fixedContainingBlockOrigin(dropdownElement);
643
736
 
737
+ if (cover && !point) {
738
+ const rtl = isRtl(trig);
739
+ const viewport = window.visualViewport;
740
+ const viewportLeft = viewport?.offsetLeft ?? 0;
741
+ const viewportTop = viewport?.offsetTop ?? 0;
742
+ const viewportRight =
743
+ viewportLeft +
744
+ (viewport?.width ?? (document.documentElement.clientWidth || window.innerWidth));
745
+ const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
746
+ dropdownElement.style.left = `${viewportLeft - containingBlock.left}px`;
747
+ const layoutStyle = getComputedStyle(dropdownElement);
748
+ const scale =
749
+ layoutStyle.transform &&
750
+ layoutStyle.transform !== "none" &&
751
+ typeof DOMMatrixReadOnly !== "undefined"
752
+ ? Math.abs(new DOMMatrixReadOnly(layoutStyle.transform).a)
753
+ : 1;
754
+ const layoutWidth = dropdownElement.getBoundingClientRect().width / (scale || 1);
755
+ const width = Math.max(layoutWidth || dropdownElement.offsetWidth, measured.width);
756
+ const left = rtl ? measured.right - width : measured.left;
757
+ const firstActionHeight =
758
+ contentRef.current?.querySelector<HTMLElement>(PANEL_FOCUSABLE)?.getBoundingClientRect()
759
+ .height ||
760
+ Number(panel.knobProps.menuRow.minHeight) ||
761
+ totalContentHeight;
762
+ const bodyStyle = contentRef.current ? getComputedStyle(contentRef.current) : null;
763
+ const bodyPadding =
764
+ (parseFloat(bodyStyle?.paddingTop || "0") || 0) +
765
+ (parseFloat(bodyStyle?.paddingBottom || "0") || 0);
766
+ const neededHeight = sheetProps.scrollable
767
+ ? Math.min(
768
+ totalContentHeight,
769
+ measured.height + firstActionHeight + bodyPadding + totalBorderWidth,
770
+ )
771
+ : totalContentHeight;
772
+ if (
773
+ measured.width > 0 &&
774
+ measured.height > 0 &&
775
+ (left < viewportLeft ||
776
+ left + width > Math.max(measured.right, viewportRight - panelViewportPadding) ||
777
+ measured.right > viewportRight ||
778
+ measured.top < viewportTop ||
779
+ measured.top + Math.max(neededHeight, measured.height) >
780
+ viewportBottom - panelViewportPadding)
781
+ ) {
782
+ dropdownElement.style.visibility = "hidden";
783
+ setCollisionSheet(true);
784
+ return;
785
+ }
786
+ const top = measured.top - containingBlock.top;
787
+ const cssLeft = left - containingBlock.left;
788
+ const maxHeight = Math.max(0, viewportBottom - panelViewportPadding - measured.top);
789
+ dropdownElement.style.top = `${top}px`;
790
+ dropdownElement.style.left = `${cssLeft}px`;
791
+ dropdownElement.style.maxHeight = `${maxHeight}px`;
792
+ dropdownElement.dir = rtl ? "rtl" : "ltr";
793
+ dropdownElement.style.transformOrigin = rtl ? "top right" : "top left";
794
+ dropdownElement.style.visibility = "visible";
795
+ overlayStyleRef.current = { top, left: cssLeft, maxHeight };
796
+ panel.initializingRef.current = false;
797
+ if (isInitial) dropdownElement.style.transition = liveTransition;
798
+ return;
799
+ }
800
+
644
801
  let isDropup = panel.placement.startsWith("top");
645
802
  let cssLeft: number | undefined;
646
803
  if (point) {
@@ -794,6 +951,8 @@ function FloatingPanelWeb({
794
951
  isFill,
795
952
  isContextMenu,
796
953
  cover,
954
+ anchorRect,
955
+ sheetProps.scrollable,
797
956
  panel.placement,
798
957
  panel.refs,
799
958
  panel.triggerRef,
@@ -805,9 +964,10 @@ function FloatingPanelWeb({
805
964
 
806
965
  const click = useClick(panel.context, {
807
966
  event: "mousedown",
808
- enabled: !isContextMenu,
967
+ enabled: !isContextMenu && !collisionSheet,
809
968
  });
810
969
  const dismiss = useDismiss(panel.context, {
970
+ enabled: !collisionSheet,
811
971
  // Use 'click' instead of default 'pointerdown' so scrolling to the bottom
812
972
  // (e.g. via wheel/trackpad or scroll arrow hover) doesn't trigger outside-press.
813
973
  outsidePressEvent: "click",
@@ -820,11 +980,17 @@ function FloatingPanelWeb({
820
980
  useLayoutEffect(() => {
821
981
  if (!open || !panel.mounted) return;
822
982
  repositionOverlay();
823
- }, [open, panel.mounted, repositionOverlay, panel.y]);
983
+ }, [open, panel.mounted, repositionOverlay, panel.y, panel.x]);
824
984
 
825
985
  useLayoutEffect(() => {
826
986
  const content = contentRef.current;
827
- if (!open || !panel.mounted || !isFill || !content || typeof ResizeObserver === "undefined") {
987
+ if (
988
+ !open ||
989
+ !panel.mounted ||
990
+ (!isFill && !cover) ||
991
+ !content ||
992
+ typeof ResizeObserver === "undefined"
993
+ ) {
828
994
  return;
829
995
  }
830
996
  // Matching the trigger width can rewrap content after the panel's first
@@ -839,17 +1005,18 @@ function FloatingPanelWeb({
839
1005
  });
840
1006
  });
841
1007
  observer.observe(content);
1008
+ if (panel.triggerRef.current) observer.observe(panel.triggerRef.current);
842
1009
  return () => {
843
1010
  observer.disconnect();
844
1011
  if (frame) cancelAnimationFrame(frame);
845
1012
  };
846
- }, [open, panel.mounted, isFill, repositionOverlay]);
1013
+ }, [open, panel.mounted, isFill, cover, repositionOverlay, panel.triggerRef]);
847
1014
 
848
1015
  const handleScroll = useCallback(
849
1016
  (event: UIEvent) => {
850
1017
  const el = event.target as HTMLElement;
851
1018
  panel.updateArrows(el);
852
- if (isFill) return;
1019
+ if (isFill || (cover && !isContextMenu)) return;
853
1020
  if (panel.initializingRef.current) {
854
1021
  lastScrollTopRef.current = el.scrollTop;
855
1022
  return;
@@ -873,7 +1040,7 @@ function FloatingPanelWeb({
873
1040
  repositionFnRef.current?.();
874
1041
  }
875
1042
  },
876
- [isFill, panel.updateArrows],
1043
+ [isFill, cover, isContextMenu, panel.updateArrows],
877
1044
  );
878
1045
 
879
1046
  const webTransition = panel.panelTransition;
@@ -976,6 +1143,7 @@ function FloatingPanelWeb({
976
1143
  <>
977
1144
  <View
978
1145
  {...referenceProps}
1146
+ alignSelf={sheetProps.triggerSizing === "content" ? "flex-start" : undefined}
979
1147
  cursor={disabled ? "not-allowed" : "pointer"}
980
1148
  opacity={disabled ? 0.5 : 1}
981
1149
  // The cover contract is stated as a relation between the panel's top
@@ -989,7 +1157,22 @@ function FloatingPanelWeb({
989
1157
  {trigger}
990
1158
  </View>
991
1159
 
992
- {panel.mounted && (
1160
+ {collisionSheet && (
1161
+ <FloatingPanelSheet
1162
+ {...sheetProps}
1163
+ open={open}
1164
+ onOpenChange={onOpenChange}
1165
+ trigger={null}
1166
+ // The trigger's Tooltip stops Escape at document. The float's
1167
+ // useDismiss listens there too, so the fallback must as well.
1168
+ escapeTarget="document"
1169
+ skipReturnFocusRef={skipReturnFocusRef}
1170
+ contentPadding={contentPadding}
1171
+ >
1172
+ {children}
1173
+ </FloatingPanelSheet>
1174
+ )}
1175
+ {panel.mounted && !collisionSheet && (
993
1176
  <PanelPortal anchor={panel.triggerRef.current}>
994
1177
  <YStack
995
1178
  {...getFloatingProps({
@@ -1002,6 +1185,7 @@ function FloatingPanelWeb({
1002
1185
  scrollbarWidth: "none" as any,
1003
1186
  zIndex: zIndex.dropdown,
1004
1187
  overflow: "auto",
1188
+ ...(cover && !isContextMenu ? { overflowX: "hidden" as const } : {}),
1005
1189
  ...panel.floatingStyleRef.current,
1006
1190
  ...(overlayStyleRef.current
1007
1191
  ? { maxHeight: overlayStyleRef.current.maxHeight }
@@ -1012,11 +1196,16 @@ function FloatingPanelWeb({
1012
1196
  // then positions against this box and is clipped by its scroll.
1013
1197
  transform: panel.visible ? "none" : "scale(0.96)",
1014
1198
  transition: webTransition,
1015
- transformOrigin: panelTransformOrigin(
1016
- pointPlacementRef.current
1017
- ? `${pointPlacementRef.current.dropup ? "top" : "bottom"}-${pointPlacementRef.current.alignEnd ? "end" : "start"}`
1018
- : panel.placement,
1019
- ),
1199
+ transformOrigin:
1200
+ cover && !isContextMenu
1201
+ ? isRtl(panel.triggerRef.current)
1202
+ ? "top right"
1203
+ : "top left"
1204
+ : panelTransformOrigin(
1205
+ pointPlacementRef.current
1206
+ ? `${pointPlacementRef.current.dropup ? "top" : "bottom"}-${pointPlacementRef.current.alignEnd ? "end" : "start"}`
1207
+ : panel.placement,
1208
+ ),
1020
1209
  },
1021
1210
  onScroll: handleScroll,
1022
1211
  onTransitionEnd: (event) => {
@@ -1042,7 +1231,14 @@ function FloatingPanelWeb({
1042
1231
  paddingVertical={0}
1043
1232
  >
1044
1233
  {isContextMenu || !cover || !triggerEcho ? null : (
1045
- <TriggerEchoRow sourceRef={panel.triggerRef} onPress={() => onOpenChange(false)} />
1234
+ <TriggerEchoRow
1235
+ sourceRef={panel.triggerRef}
1236
+ source={trigger}
1237
+ anchorRect={anchorRect}
1238
+ onPress={() => onOpenChange(false)}
1239
+ >
1240
+ {triggerRow}
1241
+ </TriggerEchoRow>
1046
1242
  )}
1047
1243
  <ScrollArrow
1048
1244
  direction="up"
@@ -158,12 +158,14 @@ describe("FloatingPanel and DropdownMenu open over their trigger (MPO-38 cover r
158
158
  expect(viewport.style.transformOrigin.startsWith("top")).toBe(true);
159
159
  });
160
160
 
161
- it("the trigger's own label is the panel's first row, presentation only", async () => {
161
+ it("the trigger's own label is the panel's first close row", async () => {
162
162
  const viewport = await openPanel({});
163
163
  const echo = viewport.querySelector("[data-fp-trigger-row]") as HTMLElement;
164
164
  expect(echo).toBeTruthy();
165
165
  expect(echo.textContent).toContain("Actions");
166
- expect(echo.getAttribute("aria-hidden")).toBe("true");
166
+ expect(echo.getAttribute("aria-hidden")).toBeNull();
167
+ expect(echo.getAttribute("role")).toBe("button");
168
+ expect(echo.getAttribute("aria-label")).toBe("Actions");
167
169
  const firstRow = Array.from(viewport.children).find((el) =>
168
170
  (el as HTMLElement).textContent?.trim(),
169
171
  );
@@ -33,7 +33,7 @@ for (const mode of ["floating", "sheet"] as const) {
33
33
  const hook = renderHook(
34
34
  ({ open }) => {
35
35
  if (mode === "sheet") useSheetModalFocus(open, frameRef, skipReturnFocusRef);
36
- else useReturnFocusOnClose(open, skipReturnFocusRef);
36
+ else useReturnFocusOnClose(open, skipReturnFocusRef, frameRef);
37
37
  },
38
38
  { initialProps: { open: true }, wrapper: Wrapper },
39
39
  );
@@ -76,6 +76,16 @@ for (const mode of ["floating", "sheet"] as const) {
76
76
  expect(document.activeElement).toBe(h.trigger);
77
77
  });
78
78
 
79
+ it("returns focus left inside the closing frame before the frame unmounts", async () => {
80
+ const h = setup();
81
+ h.setAnimation("none");
82
+ h.rerender({ open: true });
83
+ h.item.focus();
84
+ h.rerender({ open: false });
85
+ await act(() => vi.advanceTimersByTimeAsync(50));
86
+ expect(document.activeElement).toBe(h.trigger);
87
+ });
88
+
79
89
  it("honors suppression set after close, before the delayed restore", async () => {
80
90
  const h = setup();
81
91
  h.trigger.blur();
@@ -6,9 +6,9 @@
6
6
  * only remembers a `Dialog.Trigger` mounted inside the Dialog. These hooks
7
7
  * close that gap:
8
8
  *
9
- * - `useReturnFocusOnClose(open)` — capture the focused element when `open`
10
- * flips true; when it flips false, return focus to it IF focus was dropped
11
- * (on body, or stranded inside a hidden/removed overlay). Never steals
9
+ * - `useReturnFocusOnClose(open, skip?, insideRef?)` — capture the focused
10
+ * element when `open` flips true; when it flips false, return focus to it
11
+ * IF focus was dropped (on body, or stranded inside the closing overlay). Never steals
12
12
  * focus the user deliberately moved elsewhere (outside-click onto another
13
13
  * control).
14
14
  * - `useSheetModalFocus(open, frameRef)` — sheet-mode complement: also moves
@@ -96,8 +96,17 @@ function restoreLater(
96
96
  setTimeout(restore, delayMs);
97
97
  }
98
98
 
99
- /** Restore focus to the element focused at open time, on close (web only). */
100
- export function useReturnFocusOnClose(open: boolean, skipReturnFocusRef?: RefObject<boolean>) {
99
+ /**
100
+ * Restore focus to the element focused at open time, on close (web only).
101
+ * `insideRef` is the closing overlay's frame: focus still inside it counts as
102
+ * dropped, because it falls to <body> when the frame unmounts after the exit
103
+ * and no retry runs that late.
104
+ */
105
+ export function useReturnFocusOnClose(
106
+ open: boolean,
107
+ skipReturnFocusRef?: RefObject<boolean>,
108
+ insideRef?: RefObject<HTMLElement | null>,
109
+ ) {
101
110
  const { prevRef, generationRef } = useCaptureOnOpen(open);
102
111
  const { knobProps } = useResolvedKnobs();
103
112
  const restoreDelayMsRef = useRef(0);
@@ -109,10 +118,10 @@ export function useReturnFocusOnClose(open: boolean, skipReturnFocusRef?: RefObj
109
118
  prevRef,
110
119
  generationRef,
111
120
  restoreDelayMsRef.current,
112
- undefined,
121
+ insideRef,
113
122
  skipReturnFocusRef,
114
123
  );
115
- }, [open, prevRef, generationRef, skipReturnFocusRef]);
124
+ }, [open, prevRef, generationRef, skipReturnFocusRef, insideRef]);
116
125
  }
117
126
 
118
127
  /**
@@ -61,6 +61,19 @@ describe("useSheetEscapeDismiss", () => {
61
61
  expect(second).toHaveBeenCalledTimes(1);
62
62
  });
63
63
 
64
+ it("listens on document when asked, so a document-level stopPropagation cannot block it", () => {
65
+ const onDismiss = vi.fn();
66
+ const stop = (event: Event) => event.stopPropagation();
67
+ document.addEventListener("keydown", stop);
68
+ try {
69
+ renderHook(() => useSheetEscapeDismiss(true, onDismiss, "document"));
70
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
71
+ expect(onDismiss).toHaveBeenCalledTimes(1);
72
+ } finally {
73
+ document.removeEventListener("keydown", stop);
74
+ }
75
+ });
76
+
64
77
  it("detaches the listener on unmount", () => {
65
78
  const onDismiss = vi.fn();
66
79
  const { unmount } = renderHook(() => useSheetEscapeDismiss(true, onDismiss));
@@ -11,7 +11,11 @@ import { isWeb } from "tamagui";
11
11
  * comboboxes) that own Escape call stopPropagation()/preventDefault() and
12
12
  * keep the sheet open.
13
13
  */
14
- export function useSheetEscapeDismiss(enabled: boolean, onDismiss: () => void) {
14
+ export function useSheetEscapeDismiss(
15
+ enabled: boolean,
16
+ onDismiss: () => void,
17
+ target: "window" | "document" = "window",
18
+ ) {
15
19
  const onDismissRef = useRef(onDismiss);
16
20
  onDismissRef.current = onDismiss;
17
21
  useEffect(() => {
@@ -20,7 +24,8 @@ export function useSheetEscapeDismiss(enabled: boolean, onDismiss: () => void) {
20
24
  if (event.key !== "Escape" || event.defaultPrevented) return;
21
25
  onDismissRef.current();
22
26
  };
23
- window.addEventListener("keydown", onKeyDown);
24
- return () => window.removeEventListener("keydown", onKeyDown);
25
- }, [enabled]);
27
+ const node = target === "document" ? document : window;
28
+ node.addEventListener("keydown", onKeyDown as EventListener);
29
+ return () => node.removeEventListener("keydown", onKeyDown as EventListener);
30
+ }, [enabled, target]);
26
31
  }