@oxy.so/bloom 4.22.2 → 4.24.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 (117) hide show
  1. package/docs/adoption-matrix.mdx +10 -10
  2. package/docs/ai-chat.mdx +7 -3
  3. package/docs/bottom-sheet.mdx +2 -0
  4. package/docs/composer-panel.mdx +10 -0
  5. package/docs/dialog.mdx +28 -0
  6. package/docs/sidebar.mdx +2 -0
  7. package/lib/commonjs/ai-chat/AiChatContainer.js +22 -11
  8. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  9. package/lib/commonjs/ai-chat/AiChatShell.js +8 -2
  10. package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
  11. package/lib/commonjs/ai-chat/use-panel-interaction.js +20 -33
  12. package/lib/commonjs/ai-chat/use-panel-interaction.js.map +1 -1
  13. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +10 -3
  14. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  15. package/lib/commonjs/chat-composer/ChatComposer.js +10 -12
  16. package/lib/commonjs/chat-composer/ChatComposer.js.map +1 -1
  17. package/lib/commonjs/composer-panel/ComposerPanelBase.js +9 -11
  18. package/lib/commonjs/composer-panel/ComposerPanelBase.js.map +1 -1
  19. package/lib/commonjs/composer-panel/ComposerPillBase.js +24 -2
  20. package/lib/commonjs/composer-panel/ComposerPillBase.js.map +1 -1
  21. package/lib/commonjs/dialog/Dialog.web.js +28 -22
  22. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  23. package/lib/commonjs/dialog/DialogBottomSheet.js +9 -4
  24. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  25. package/lib/commonjs/hooks/use-textarea-autosize.js +103 -0
  26. package/lib/commonjs/hooks/use-textarea-autosize.js.map +1 -0
  27. package/lib/commonjs/overlay/ModalKeyboard.js +88 -0
  28. package/lib/commonjs/overlay/ModalKeyboard.js.map +1 -0
  29. package/lib/commonjs/overlay/modal-keyboard.js +131 -0
  30. package/lib/commonjs/overlay/modal-keyboard.js.map +1 -0
  31. package/lib/commonjs/overlay/stack.js +16 -0
  32. package/lib/commonjs/overlay/stack.js.map +1 -1
  33. package/lib/commonjs/sidebar/Sidebar.js +12 -4
  34. package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
  35. package/lib/module/ai-chat/AiChatContainer.js +22 -11
  36. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  37. package/lib/module/ai-chat/AiChatShell.js +8 -2
  38. package/lib/module/ai-chat/AiChatShell.js.map +1 -1
  39. package/lib/module/ai-chat/use-panel-interaction.js +20 -32
  40. package/lib/module/ai-chat/use-panel-interaction.js.map +1 -1
  41. package/lib/module/bottom-sheet/BottomSheetBase.js +10 -3
  42. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  43. package/lib/module/chat-composer/ChatComposer.js +11 -13
  44. package/lib/module/chat-composer/ChatComposer.js.map +1 -1
  45. package/lib/module/composer-panel/ComposerPanelBase.js +10 -12
  46. package/lib/module/composer-panel/ComposerPanelBase.js.map +1 -1
  47. package/lib/module/composer-panel/ComposerPillBase.js +24 -2
  48. package/lib/module/composer-panel/ComposerPillBase.js.map +1 -1
  49. package/lib/module/dialog/Dialog.web.js +28 -22
  50. package/lib/module/dialog/Dialog.web.js.map +1 -1
  51. package/lib/module/dialog/DialogBottomSheet.js +9 -4
  52. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  53. package/lib/module/hooks/use-textarea-autosize.js +98 -0
  54. package/lib/module/hooks/use-textarea-autosize.js.map +1 -0
  55. package/lib/module/overlay/ModalKeyboard.js +84 -0
  56. package/lib/module/overlay/ModalKeyboard.js.map +1 -0
  57. package/lib/module/overlay/modal-keyboard.js +124 -0
  58. package/lib/module/overlay/modal-keyboard.js.map +1 -0
  59. package/lib/module/overlay/stack.js +15 -0
  60. package/lib/module/overlay/stack.js.map +1 -1
  61. package/lib/module/sidebar/Sidebar.js +12 -4
  62. package/lib/module/sidebar/Sidebar.js.map +1 -1
  63. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +5 -4
  64. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts +10 -1
  67. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/chat-composer/ChatComposer.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/composer-panel/ComposerPillBase.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/hooks/use-textarea-autosize.d.ts +61 -0
  75. package/lib/typescript/commonjs/hooks/use-textarea-autosize.d.ts.map +1 -0
  76. package/lib/typescript/commonjs/overlay/ModalKeyboard.d.ts +32 -0
  77. package/lib/typescript/commonjs/overlay/ModalKeyboard.d.ts.map +1 -0
  78. package/lib/typescript/commonjs/overlay/modal-keyboard.d.ts +17 -0
  79. package/lib/typescript/commonjs/overlay/modal-keyboard.d.ts.map +1 -0
  80. package/lib/typescript/commonjs/overlay/stack.d.ts +6 -0
  81. package/lib/typescript/commonjs/overlay/stack.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
  83. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +5 -4
  84. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
  85. package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
  86. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts +10 -1
  87. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts.map +1 -1
  88. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  89. package/lib/typescript/module/chat-composer/ChatComposer.d.ts.map +1 -1
  90. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  91. package/lib/typescript/module/composer-panel/ComposerPillBase.d.ts.map +1 -1
  92. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  93. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  94. package/lib/typescript/module/hooks/use-textarea-autosize.d.ts +61 -0
  95. package/lib/typescript/module/hooks/use-textarea-autosize.d.ts.map +1 -0
  96. package/lib/typescript/module/overlay/ModalKeyboard.d.ts +32 -0
  97. package/lib/typescript/module/overlay/ModalKeyboard.d.ts.map +1 -0
  98. package/lib/typescript/module/overlay/modal-keyboard.d.ts +17 -0
  99. package/lib/typescript/module/overlay/modal-keyboard.d.ts.map +1 -0
  100. package/lib/typescript/module/overlay/stack.d.ts +6 -0
  101. package/lib/typescript/module/overlay/stack.d.ts.map +1 -1
  102. package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/src/ai-chat/AiChatContainer.tsx +18 -8
  105. package/src/ai-chat/AiChatShell.tsx +12 -2
  106. package/src/ai-chat/use-panel-interaction.ts +20 -34
  107. package/src/bottom-sheet/BottomSheetBase.tsx +8 -0
  108. package/src/chat-composer/ChatComposer.tsx +11 -13
  109. package/src/composer-panel/ComposerPanelBase.tsx +10 -12
  110. package/src/composer-panel/ComposerPillBase.tsx +28 -2
  111. package/src/dialog/Dialog.web.tsx +22 -18
  112. package/src/dialog/DialogBottomSheet.tsx +9 -4
  113. package/src/hooks/use-textarea-autosize.ts +106 -0
  114. package/src/overlay/ModalKeyboard.tsx +88 -0
  115. package/src/overlay/modal-keyboard.ts +133 -0
  116. package/src/overlay/stack.ts +13 -0
  117. package/src/sidebar/Sidebar.tsx +7 -2
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import type { View } from 'react-native';
3
+ export interface ModalKeyboardProps {
4
+ /** The surface's panel: where focus goes, and what Tab stays inside. */
5
+ panelRef: React.RefObject<View | null>;
6
+ /** The surface has started its exit: focus goes back now, Escape is spent. */
7
+ closing?: boolean;
8
+ /** Whether Escape may dismiss it (a dialog's `dismissOnBackdrop`). */
9
+ dismissible: boolean;
10
+ dismiss: () => void;
11
+ }
12
+ /**
13
+ * An open modal surface's keyboard and focus, WEB. Renders nothing; it must
14
+ * sit INSIDE the surface's `OverlayRoot`, which is how it knows the surface's
15
+ * place in the overlay stack. Used by the web `Dialog` (card and side drawers)
16
+ * and the web `BottomSheet`.
17
+ *
18
+ * - Focus moves INTO the panel when it opens — to whatever the content
19
+ * focused itself (an `autoFocus` field), else the first tabbable, else the
20
+ * panel — and goes BACK to the element that had it once the surface starts
21
+ * closing (or unmounts), unless the host has already put it somewhere else.
22
+ * - Tab and Shift+Tab wrap at the panel's edges.
23
+ * - Escape dismisses (when `dismissible`), from anywhere — a text field of the
24
+ * surface included, which react-native-web keeps every keydown from
25
+ * bubbling out of (`./modal-keyboard.ts`).
26
+ *
27
+ * All three belong to the TOP-MOST surface only: a menu or a second dialog
28
+ * opened from this one takes them until it closes, so Escape closes one layer
29
+ * at a time. Inert without a DOM, so native never runs any of it.
30
+ */
31
+ export declare function ModalKeyboard({ panelRef, closing, dismissible, dismiss }: ModalKeyboardProps): null;
32
+ //# sourceMappingURL=ModalKeyboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModalKeyboard.d.ts","sourceRoot":"","sources":["../../../../src/overlay/ModalKeyboard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAyC,MAAM,OAAO,CAAC;AAC9D,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAMzC,MAAM,WAAW,kBAAkB;IACjC,wEAAwE;IACxE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC;IACvC,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,sEAAsE;IACtE,WAAW,EAAE,OAAO,CAAC;IACrB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,OAAe,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,kBAAkB,QAmDpG"}
@@ -0,0 +1,17 @@
1
+ /** The elements inside `node` that Tab can land on, in document order. */
2
+ export declare function tabbablesWithin(node: HTMLElement | null): HTMLElement[];
3
+ /**
4
+ * Keep Tab inside `node`: from the last tabbable (or from outside) forward to
5
+ * the first, from the first (or from outside) backward to the last. Returns
6
+ * whether it moved focus.
7
+ */
8
+ export declare function wrapTab(event: KeyboardEvent, node: HTMLElement | null): boolean;
9
+ /**
10
+ * Dismiss on Escape — see the file comment for the three steps. `claim` runs
11
+ * in the capture phase and says whether this surface is the one the key is
12
+ * for (the top-most, nothing anchored open above it). An open anchored
13
+ * surface (a menu, a select's list) always keeps the key: it is the top layer
14
+ * whatever the caller's own bookkeeping says.
15
+ */
16
+ export declare function listenForEscape(claim: () => boolean, dismiss: () => void): () => void;
17
+ //# sourceMappingURL=modal-keyboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modal-keyboard.d.ts","sourceRoot":"","sources":["../../../../src/overlay/modal-keyboard.ts"],"names":[],"mappings":"AAiDA,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,GAAG,WAAW,EAAE,CASvE;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,GAAG,OAAO,CAa/E;AAYD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,OAAO,EAAE,OAAO,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAkCrF"}
@@ -120,5 +120,11 @@ export declare function releaseOverlayRank(rank: number): void;
120
120
  export declare function resetOverlayStack(): void;
121
121
  /** In-flow shell drawers yield interaction to any portaled surface above them. */
122
122
  export declare function hasActiveOverlays(): boolean;
123
+ /**
124
+ * Whether the surface painting at `layer` is the top-most one open — the one
125
+ * Escape and Tab belong to. A menu opened from inside a dialog takes a higher
126
+ * rank than the dialog, so the dialog stands aside until it closes.
127
+ */
128
+ export declare function isTopmostOverlayLayer(layer: OverlayLayer): boolean;
123
129
  export {};
124
130
  //# sourceMappingURL=stack.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"stack.d.ts","sourceRoot":"","sources":["../../../../src/overlay/stack.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;GAGG;AACH,eAAO,MAAM,aAAa,QAC8C,CAAC;AAEzE,0EAA0E;AAC1E,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACnB;AAED,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,8BAA8B,EAAE,QAAQ,GAAG,SAAS,CAAC;CAC1D;AAgBD,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAIvD;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAI3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAItD;AAED,+DAA+D;AAC/D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAIrD;AAED,4CAA4C;AAC5C,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C"}
1
+ {"version":3,"file":"stack.d.ts","sourceRoot":"","sources":["../../../../src/overlay/stack.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;GAGG;AACH,eAAO,MAAM,aAAa,QAC8C,CAAC;AAEzE,0EAA0E;AAC1E,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACnB;AAED,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,8BAA8B,EAAE,QAAQ,GAAG,SAAS,CAAC;CAC1D;AAgBD,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAIvD;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAI3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAItD;AAED,+DAA+D;AAC/D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAIrD;AAED,4CAA4C;AAC5C,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAMlE"}
@@ -1 +1 @@
1
- {"version":3,"file":"Sidebar.d.ts","sourceRoot":"","sources":["../../../../src/sidebar/Sidebar.tsx"],"names":[],"mappings":"AACA,OAAO,KAAkE,MAAM,OAAO,CAAC;AA4CvF,OAAO,KAAK,EAAkB,YAAY,EAAE,MAAM,SAAS,CAAC;AA4tB5D,eAAO,MAAM,OAAO,0CAAyB,CAAC"}
1
+ {"version":3,"file":"Sidebar.d.ts","sourceRoot":"","sources":["../../../../src/sidebar/Sidebar.tsx"],"names":[],"mappings":"AACA,OAAO,KAAkE,MAAM,OAAO,CAAC;AA4CvF,OAAO,KAAK,EAAkB,YAAY,EAAE,MAAM,SAAS,CAAC;AAiuB5D,eAAO,MAAM,OAAO,0CAAyB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/bloom",
3
- "version": "4.22.2",
3
+ "version": "4.24.0",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
6
6
  "main": "lib/commonjs/index.js",
@@ -92,10 +92,11 @@ const HIDDEN_FROM_A11Y = {
92
92
  * `floatingChrome` changes the geometry, not the parts:
93
93
  *
94
94
  * thread the `children` fill the whole card, under everything below
95
- * header the `header` slot + breadcrumb row, absolute at the top, no band;
96
- * an edge scrim of the surface (opaque at the top, gone
97
- * `SCRIM_TAIL_RATIO` below the block) fades in over the first 16px
98
- * of transcript scrolled under it
95
+ * header the `header` slot + breadcrumb row, absolute at the top, no band
96
+ * at rest; once the transcript scrolls under it, the surface fades
97
+ * in behind it over the first 16px — solid across the whole block,
98
+ * then a ramp to nothing `SCRIM_TAIL_RATIO` below it — so no turn
99
+ * ever shows through the title or the crumbs
99
100
  * footer absolute at the bottom, with no band of its own: the transcript
100
101
  * passes behind the composer and stays in sight around it
101
102
  *
@@ -340,6 +341,7 @@ function FloatingChrome({
340
341
  const alpha = parsed?.a ?? 1;
341
342
  const topStyle = useAnimatedStyle(() => ({ opacity: topFade.value * alpha }), [topFade, alpha]);
342
343
  const prefix = testID ?? 'ai-chat';
344
+ const scrimTail = Math.round(headerHeight * SCRIM_TAIL_RATIO);
343
345
 
344
346
  // Absolute over a card of fixed height; sticky in the flow of a card that
345
347
  // grows with the document, pinned to the screen's gutter.
@@ -363,9 +365,7 @@ function FloatingChrome({
363
365
  top: 0,
364
366
  left: 0,
365
367
  right: 0,
366
- // Past the block's own bottom: a ramp that ends where the layout
367
- // does is a line across the transcript again.
368
- height: Math.round(headerHeight * (1 + SCRIM_TAIL_RATIO)),
368
+ height: headerHeight + scrimTail,
369
369
  // The card's own corners, since nothing clips a document card.
370
370
  ...(documentScroll
371
371
  ? { borderTopLeftRadius: DOCUMENT_CARD_RADIUS, borderTopRightRadius: DOCUMENT_CARD_RADIUS, overflow: 'hidden' as const }
@@ -373,7 +373,17 @@ function FloatingChrome({
373
373
  },
374
374
  topStyle,
375
375
  ]}>
376
- <EdgeScrim color={solid} />
376
+ {/* SOLID behind the header's own block. A ramp that starts fading
377
+ inside the block leaves the breadcrumb row over half-covered
378
+ turns — measured in a browser, a code block's lines read straight
379
+ through the chat's title and crumbs at every width. The ramp
380
+ only starts where the block ends. */}
381
+ <View testID={`${prefix}-fade-top-surface`} style={{ height: headerHeight, backgroundColor: solid }} />
382
+ {/* Past the block's own bottom: a ramp that ends where the layout
383
+ does is a line across the transcript again. */}
384
+ <View style={{ height: scrimTail }}>
385
+ <EdgeScrim color={solid} />
386
+ </View>
377
387
  </Animated.View>
378
388
  ) : null}
379
389
  {header}
@@ -368,6 +368,10 @@ export function AiChatShell({
368
368
  const panelOpen = panelOpenState && !wide && !!panel;
369
369
  const dismissPanel = useCallback(() => setPanelOpen(false), [setPanelOpen]);
370
370
  const panelRef = usePanelInteraction(panelOpenState && !!panel, panelOpen, dismissPanel);
371
+ const dismissNav = useCallback(() => setNavOpen(false), [setNavOpen]);
372
+ // The nav drawer covers the workspace whenever it is open: Escape closes it,
373
+ // focus moves in and is kept there, and returns to the opener on close.
374
+ const navRef = usePanelInteraction(navOpen, navOpen, dismissNav);
371
375
  // The swipe's handlers are built once and read the drawer's state from here.
372
376
  const navOpenRef = useRef(navOpen);
373
377
  navOpenRef.current = navOpen;
@@ -608,6 +612,7 @@ export function AiChatShell({
608
612
  ) : null}
609
613
  {!navInFlow && mobileSidebar ? (
610
614
  <View
615
+ ref={navRef}
611
616
  {...(navOpen ? null : CLOSED_LAYER)}
612
617
  pointerEvents={navOpen ? 'box-none' : 'none'}
613
618
  style={{ position: overlayPosition, top: 0, bottom: 0, left: 0, zIndex: 10, width: 272, paddingTop: 12, paddingBottom: 12, paddingLeft: 6 }}>
@@ -652,12 +657,17 @@ export function AiChatShell({
652
657
  ]}>
653
658
  {!navInFlow ? (
654
659
  <Animated.View
660
+ {...(navOpen ? null : CLOSED_LAYER)}
655
661
  pointerEvents={navOpen ? 'auto' : 'none'}
656
662
  style={[{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 50 }, veilStyle]}>
663
+ {/* Never a Tab stop: open, focus is kept in the drawer and Escape
664
+ closes it; closed, the veil is not there at all. `tabIndex`,
665
+ not `focusable` — react-native-web's Pressable writes its own
666
+ tabIndex 0 over `focusable`. */}
657
667
  <Pressable
658
668
  accessibilityRole="button"
659
669
  accessibilityLabel={l.closeNavigation}
660
- focusable={navOpen}
670
+ tabIndex={-1}
661
671
  onPress={() => setNavOpen(false)}
662
672
  style={{ flex: 1, backgroundColor: veil }}
663
673
  />
@@ -701,7 +711,7 @@ export function AiChatShell({
701
711
  <Pressable
702
712
  accessibilityRole="button"
703
713
  accessibilityLabel={l.closePanel(panelLabel)}
704
- focusable={panelOpen}
714
+ tabIndex={panelOpen ? 0 : -1}
705
715
  onPress={() => setPanelOpen(false)}
706
716
  style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.4)' }}
707
717
  />
@@ -1,19 +1,19 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { BackHandler, Platform, type View } from 'react-native';
3
3
  import { useAccessibilityFocus } from '../hooks/use-accessibility-focus';
4
+ import { listenForEscape, tabbablesWithin, wrapTab } from '../overlay/modal-keyboard';
4
5
  import { hasActiveOverlays } from '../overlay/stack';
5
6
 
6
- function visible(element: HTMLElement): boolean {
7
- for (let current: HTMLElement | null = element; current; current = current.parentElement) {
8
- const style = getComputedStyle(current);
9
- if (style.display === 'none' || style.visibility === 'hidden') return false;
10
- }
11
- return true;
12
- }
13
-
14
- const FOCUSABLE = 'button, a[href], input, select, textarea, [tabindex]';
15
-
16
- /** Interaction only: the shell continues to own the drawer's layout and motion. */
7
+ /**
8
+ * Interaction only: the shell continues to own the drawer's layout and motion.
9
+ * Used by both shell drawers — the panel and the navigation.
10
+ *
11
+ * open Escape (web) and back (Android) close it
12
+ * modal it covers the workspace: focus moves in on open, Tab is kept
13
+ * inside, and focus goes back to the opener on close
14
+ *
15
+ * Any portaled overlay above (a dialog, a menu) takes all of it first.
16
+ */
17
17
  export function usePanelInteraction(open: boolean, modal: boolean, close: () => void) {
18
18
  const ref = useAccessibilityFocus<View>(modal);
19
19
  const closeRef = useRef(close);
@@ -33,36 +33,22 @@ export function usePanelInteraction(open: boolean, modal: boolean, close: () =>
33
33
  // This ref is a native View on native, a DOM host on web.
34
34
  const node = ref.current as unknown as HTMLElement | null;
35
35
  const opener = document.activeElement as HTMLElement | null;
36
- const candidates = () => Array.from(node?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])
37
- .filter(element => element.tabIndex >= 0 && !element.matches(':disabled, [aria-disabled="true"]') &&
38
- !element.closest('[hidden], [aria-hidden="true"]') && visible(element));
39
- const focusFirst = () => candidates()[0]?.focus();
36
+ const focusFirst = () => tabbablesWithin(node)[0]?.focus();
40
37
  if (modal) focusFirst();
41
38
  const onFocus = (event: FocusEvent) => {
42
39
  if (modal && !hasActiveOverlays() && node && !node.contains(event.target as Node)) focusFirst();
43
40
  };
44
- const onKey = (event: KeyboardEvent) => {
45
- if (event.defaultPrevented || hasActiveOverlays()) return;
46
- if (event.key === 'Escape') {
47
- event.preventDefault();
48
- event.stopPropagation();
49
- closeRef.current();
50
- } else if (modal && event.key === 'Tab') {
51
- const items = candidates();
52
- const first = items[0];
53
- const last = items[items.length - 1];
54
- if (event.shiftKey && (document.activeElement === first || !node?.contains(document.activeElement))) {
55
- event.preventDefault(); last?.focus();
56
- } else if (!event.shiftKey && (document.activeElement === last || !node?.contains(document.activeElement))) {
57
- event.preventDefault(); first?.focus();
58
- }
59
- }
41
+ const onTab = (event: KeyboardEvent) => {
42
+ if (modal && !event.defaultPrevented && !hasActiveOverlays()) wrapTab(event, node);
60
43
  };
61
- // Menus/dialogs consume Escape on document; shell listens after them.
62
- window.addEventListener('keydown', onKey);
44
+ const stopEscape = listenForEscape(() => !hasActiveOverlays(), () => closeRef.current());
45
+ // Capture: a text field inside the drawer stops its own keydowns (see
46
+ // `overlay/modal-keyboard.ts`), and Tab must wrap from there too.
47
+ window.addEventListener('keydown', onTab, true);
63
48
  document.addEventListener('focusin', onFocus);
64
49
  return () => {
65
- window.removeEventListener('keydown', onKey);
50
+ stopEscape();
51
+ window.removeEventListener('keydown', onTab, true);
66
52
  document.removeEventListener('focusin', onFocus);
67
53
  if (opener?.isConnected) opener.focus();
68
54
  };
@@ -32,6 +32,7 @@ import Animated, {
32
32
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
33
33
  import { EDGE_GAP, windowEdgeGap } from '../layout/edge';
34
34
  import { Backdrop, BACKDROP_DIM_OPACITY } from '../overlay';
35
+ import { ModalKeyboard } from '../overlay/ModalKeyboard';
35
36
  import { useTheme } from '../theme/use-theme';
36
37
  import type { BottomSheetRef, BottomSheetProps, BottomSheetShellProps, BottomSheetBaseProps } from './types';
37
38
 
@@ -92,6 +93,7 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
92
93
  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
93
94
  const hasClosedRef = useRef(false);
94
95
  const scrollViewRef = useRef<Animated.ScrollView>(null);
96
+ const sheetRef = useRef<View>(null);
95
97
  /**
96
98
  * Monotonically increasing counter that identifies "the current close
97
99
  * attempt". Bumped every time the sheet re-opens (`present()`), so any
@@ -696,6 +698,11 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
696
698
 
697
699
  return (
698
700
  <Shell visible={rendered} onRequestClose={dismiss} keyboardHeight={keyboardHeight}>
701
+ {/* Web keyboard and focus: in on open, back on close, Tab kept in
702
+ the sheet, Escape a USER dismissal — through `onDismissAttempt`,
703
+ exactly like the backdrop. The sheet had no keyboard path at
704
+ all. Inside the shell's `OverlayRoot`; inert on native. */}
705
+ <ModalKeyboard panelRef={sheetRef} dismissible dismiss={handleBackdropPress} />
699
706
  <View style={StyleSheet.absoluteFill}>
700
707
  {backdropComponent ? (
701
708
  <Animated.View style={[StyleSheet.absoluteFill, customBackdropStyle]}>
@@ -723,6 +730,7 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
723
730
 
724
731
  <GestureDetector gesture={panGesture}>
725
732
  <Animated.View
733
+ ref={sheetRef}
726
734
  onLayout={onLayout}
727
735
  style={[
728
736
  dynamicStyles.sheet,
@@ -8,7 +8,7 @@
8
8
  * app owns drafts per conversation and a composer that kept its own would lose
9
9
  * them on every navigation.
10
10
  */
11
- import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
11
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
12
12
  import {
13
13
  TextInput,
14
14
  View,
@@ -31,6 +31,7 @@ import { RiForbidLine } from '../icons/remix/RiForbidLine';
31
31
  import { RiMic2Line } from '../icons/remix/RiMic2Line';
32
32
  import { RiSendPlaneLine } from '../icons/remix/RiSendPlaneLine';
33
33
  import { useControllableState } from '../hooks/use-controllable-state';
34
+ import { useTextareaAutosize } from '../hooks/use-textarea-autosize';
34
35
  import type { WebCssStyle } from '../styles/web-view-style';
35
36
  import { useTheme } from '../theme/use-theme';
36
37
  import { Text } from '../typography';
@@ -175,19 +176,16 @@ export function ChatComposer({
175
176
  *
176
177
  * They are not interchangeable: react-native-web reports `scrollHeight`,
177
178
  * which never falls below the height already applied, so a field grown to six
178
- * lines and then emptied would stay six lines tall. Collapsing the node to
179
- * zero first and restoring it in the same frame is what makes it SHRINK.
179
+ * lines and then emptied would stay six lines tall. `useTextareaAutosize`
180
+ * collapses the node to measure it only when the draft may have got SHORTER —
181
+ * collapsing on every keystroke re-laid-out the whole page three times a key.
180
182
  */
181
- useLayoutEffect(() => {
182
- if (!IS_WEB) return;
183
- const node = localRef.current as unknown as HTMLTextAreaElement | null;
184
- if (!node || typeof node.scrollHeight !== 'number' || !node.style) return;
185
- const previous = node.style.height;
186
- node.style.height = '0px';
187
- const measured = node.scrollHeight;
188
- node.style.height = previous;
189
- if (measured > 0) setContentHeight(measured);
190
- }, [text, minHeight]);
183
+ useTextareaAutosize(localRef, text, {
184
+ enabled: IS_WEB,
185
+ height,
186
+ minHeight,
187
+ onMeasure: setContentHeight,
188
+ });
191
189
 
192
190
  const onContentSizeChange = useCallback(
193
191
  (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  TextInput,
4
4
  View,
@@ -17,6 +17,7 @@ import Animated, {
17
17
  } from 'react-native-reanimated';
18
18
 
19
19
  import { useControllableState } from '../hooks/use-controllable-state';
20
+ import { useTextareaAutosize } from '../hooks/use-textarea-autosize';
20
21
  import type { WebCssStyle } from '../styles/web-view-style';
21
22
  import { useTheme } from '../theme/use-theme';
22
23
  import { TYPE_SCALE } from '../typography';
@@ -175,18 +176,15 @@ export function ComposerPanelBase({
175
176
  );
176
177
 
177
178
  // The prompt grows with its text, one 20px line at a time, up to 200 before it
178
- // scrolls. Web measures the textarea from zero (so it also shrinks back);
179
- // native reads the content size.
179
+ // scrolls. Web measures the textarea, collapsing it only when the draft may
180
+ // have got shorter (`useTextareaAutosize`); native reads the content size.
180
181
  const [height, setHeight] = useState(PROMPT_LINE);
181
- useLayoutEffect(() => {
182
- if (!IS_WEB) return;
183
- const node = fieldRef.current as unknown as { style?: { height: string }; scrollHeight?: number } | null;
184
- if (!node?.style || typeof node.scrollHeight !== 'number') return;
185
- node.style.height = '0px';
186
- const next = Math.min(PROMPT_MAX_HEIGHT, Math.max(PROMPT_LINE, node.scrollHeight));
187
- node.style.height = `${next}px`;
188
- setHeight(next);
189
- }, [text]);
182
+ useTextareaAutosize(fieldRef, text, {
183
+ enabled: IS_WEB,
184
+ height,
185
+ minHeight: PROMPT_LINE,
186
+ onMeasure: (content) => setHeight(Math.min(PROMPT_MAX_HEIGHT, Math.max(PROMPT_LINE, content))),
187
+ });
190
188
  const onContentSizeChange = useCallback(
191
189
  (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
192
190
  if (IS_WEB) return;
@@ -16,6 +16,7 @@ import Animated, {
16
16
  } from 'react-native-reanimated';
17
17
 
18
18
  import { useControllableState } from '../hooks/use-controllable-state';
19
+ import { useTextareaAutosize } from '../hooks/use-textarea-autosize';
19
20
  import { RiArrowDownSLine } from '../icons/remix/RiArrowDownSLine';
20
21
  import { RadioIndicator } from '../radio-indicator';
21
22
  import type { WebCssStyle } from '../styles/web-view-style';
@@ -459,6 +460,15 @@ export function ComposerPillBase({
459
460
  const [contentHeight, setContentHeight] = useState(LINE_HEIGHT);
460
461
  const [modelWidth, setModelWidth] = useState(0);
461
462
 
463
+ const fieldRef = useRef<TextInput | null>(null);
464
+ const setFieldRef = useCallback(
465
+ (node: TextInput | null) => {
466
+ fieldRef.current = node;
467
+ if (inputRef) (inputRef as React.MutableRefObject<TextInput | null>).current = node;
468
+ },
469
+ [inputRef],
470
+ );
471
+
462
472
  // The add control shares the canonical inset and hover surfaces.
463
473
  const addPalette: ComposerPalette = useMemo(() => {
464
474
  if (glass) return { ...palette, add: 'transparent', addHover: 'transparent' };
@@ -547,6 +557,22 @@ export function ComposerPillBase({
547
557
  const pillHeight = PILL_HEIGHT + (lines - 1) * LINE_HEIGHT;
548
558
  const multiLine = lines > 1;
549
559
 
560
+ /*
561
+ * On web the field is measured, not reported. react-native-web's
562
+ * `onContentSizeChange` reads `scrollHeight` on input only, and that never
563
+ * falls below the height already applied: a pill grown to four lines stayed
564
+ * four lines tall after the draft was deleted, and after send — a
565
+ * programmatic clear fires no input at all. `useTextareaAutosize` measures on
566
+ * every change of the draft and collapses the field only when it may have
567
+ * got shorter.
568
+ */
569
+ useTextareaAutosize(fieldRef, text, {
570
+ enabled: IS_WEB,
571
+ height: fieldHeight,
572
+ minHeight: LINE_HEIGHT,
573
+ onMeasure: setContentHeight,
574
+ });
575
+
550
576
  const pillStyle: WebCssStyle = {
551
577
  width: '100%',
552
578
  height: pillHeight,
@@ -593,7 +619,7 @@ export function ComposerPillBase({
593
619
 
594
620
  <View style={fieldStyle}>
595
621
  <TextInput
596
- ref={inputRef}
622
+ ref={setFieldRef}
597
623
  {...dataHook('bloomComposerInput')}
598
624
  testID={testID ? `${testID}-input` : undefined}
599
625
  accessibilityLabel={labels.message}
@@ -609,7 +635,7 @@ export function ComposerPillBase({
609
635
  cursorColor={palette.accent500}
610
636
  returnKeyType="send"
611
637
  multiline
612
- onContentSizeChange={(event) => setContentHeight(event.nativeEvent.contentSize.height)}
638
+ onContentSizeChange={IS_WEB ? undefined : (event) => setContentHeight(event.nativeEvent.contentSize.height)}
613
639
  style={{
614
640
  width: '100%',
615
641
  height: fieldHeight,
@@ -37,6 +37,7 @@ import Animated, {
37
37
  import { RemoveScrollBar } from 'react-remove-scroll-bar';
38
38
 
39
39
  import { Backdrop, OverlayRoot } from '../overlay';
40
+ import { ModalKeyboard } from '../overlay/ModalKeyboard';
40
41
  import { StyledView } from '../styles/styled-primitives';
41
42
  import { Portal } from '../portal/index.web';
42
43
  import { adoptStyleSheet } from '../styles/adopt-style-sheet';
@@ -219,6 +220,7 @@ function CenterOrSideDialog({
219
220
  // the imperative `control.close()` still drives the exit + post-exit onClose.
220
221
  const [isOpen, setIsOpen] = useState(() => controlledOpen ?? startOpen ?? false);
221
222
  const [isClosing, setIsClosing] = useState(false);
223
+ const panelRef = useRef<View>(null);
222
224
  const closeCallbacksRef = useRef<(() => void)[]>([]);
223
225
  // Read the latest controlled flag inside stable callbacks without re-binding.
224
226
  const isControlledRef = useRef(isControlled);
@@ -314,24 +316,6 @@ function CenterOrSideDialog({
314
316
  : withTiming(1, { duration: BACKDROP_FADE_IN_DURATION, easing: Easing.out(Easing.ease) });
315
317
  }, [backdropFade, isOpen, isClosing]);
316
318
 
317
- // Escape-to-close while open. The listener is intentionally scoped to the
318
- // open lifetime so stacked dialogs don't fight for the keydown — the
319
- // top-most one wins via document-level event order. Escape honors
320
- // `dismissOnBackdrop`: a blocking dialog (e.g. an unanswered confirm) is not
321
- // dismissible by Escape, matching the backdrop's behavior. The default
322
- // (`dismissOnBackdrop` true) is unchanged — Escape still closes.
323
- useEffect(() => {
324
- if (!isOpen || !dismissOnBackdrop || typeof document === 'undefined') return;
325
- const handler = (e: KeyboardEvent) => {
326
- if (e.key === 'Escape') {
327
- e.stopPropagation();
328
- close();
329
- }
330
- };
331
- document.addEventListener('keydown', handler);
332
- return () => document.removeEventListener('keydown', handler);
333
- }, [close, dismissOnBackdrop, isOpen]);
334
-
335
319
  useImperativeHandle(
336
320
  control?.ref,
337
321
  () => ({ open, close }),
@@ -359,6 +343,12 @@ function CenterOrSideDialog({
359
343
  `OverlayInertBoundary` take the page out of the tab order and
360
344
  the accessibility tree while it is open. */}
361
345
  <OverlayRoot modal>
346
+ <ModalKeyboard
347
+ panelRef={panelRef}
348
+ closing={isClosing}
349
+ dismissible={dismissOnBackdrop}
350
+ dismiss={close}
351
+ />
362
352
  {/* The press target IS the full-viewport box, so it uses
363
353
  `Backdrop` (which opts back in from the Portal root's
364
354
  `pointer-events: none` via the `pointerEvents` PROP — the style
@@ -380,6 +370,7 @@ function CenterOrSideDialog({
380
370
  }}
381
371
  >
382
372
  <DialogPanel
373
+ panelRef={panelRef}
383
374
  testID={testID}
384
375
  label={label}
385
376
  title={title}
@@ -410,6 +401,7 @@ function CenterOrSideDialog({
410
401
  <ClosingContext.Provider value={isClosing}>
411
402
  <RemoveScrollBar />
412
403
  <SheetSurface
404
+ panelRef={panelRef}
413
405
  testID={testID}
414
406
  label={label}
415
407
  title={title}
@@ -439,6 +431,7 @@ function CenterOrSideDialog({
439
431
  }
440
432
 
441
433
  function DialogPanel({
434
+ panelRef,
442
435
  testID,
443
436
  label,
444
437
  title,
@@ -454,6 +447,7 @@ function DialogPanel({
454
447
  isClosing,
455
448
  children,
456
449
  }: {
450
+ panelRef: React.RefObject<View | null>;
457
451
  testID?: string;
458
452
  label?: string;
459
453
  title?: string;
@@ -508,7 +502,11 @@ function DialogPanel({
508
502
 
509
503
  return (
510
504
  <Animated.View
505
+ ref={panelRef}
511
506
  role="dialog"
507
+ aria-modal
508
+ // Focus lands on the panel itself when it holds nothing tabbable.
509
+ tabIndex={-1}
512
510
  aria-label={label}
513
511
  aria-labelledby={title ? titleId : undefined}
514
512
  aria-describedby={description ? descriptionId : undefined}
@@ -625,6 +623,7 @@ function DialogPanel({
625
623
  * implementation with native and supports drag-to-dismiss on web.
626
624
  */
627
625
  function SheetSurface({
626
+ panelRef,
628
627
  testID,
629
628
  label,
630
629
  title,
@@ -646,6 +645,7 @@ function SheetSurface({
646
645
  style,
647
646
  children,
648
647
  }: {
648
+ panelRef: React.RefObject<View | null>;
649
649
  testID?: string;
650
650
  label?: string;
651
651
  title?: string;
@@ -750,6 +750,7 @@ function SheetSurface({
750
750
 
751
751
  return (
752
752
  <OverlayRoot style={[sheetStyles.root, containerStyle]} className={containerClassName} modal>
753
+ <ModalKeyboard panelRef={panelRef} closing={!shown} dismissible={dismissOnBackdrop} dismiss={onDismiss} />
753
754
  <Backdrop
754
755
  testID={testID ? `${testID}-backdrop` : DIALOG_SHEET_BACKDROP_TESTID}
755
756
  accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
@@ -763,7 +764,10 @@ function SheetSurface({
763
764
  />
764
765
 
765
766
  <StyledView
767
+ ref={panelRef}
766
768
  role="dialog"
769
+ aria-modal
770
+ tabIndex={-1}
767
771
  aria-label={label}
768
772
  aria-labelledby={title ? titleId : undefined}
769
773
  aria-describedby={description ? descriptionId : undefined}
@@ -100,6 +100,8 @@ export function DialogBottomSheet({
100
100
  isControlledRef.current = isControlled;
101
101
  const onCloseRef = useRef(onClose);
102
102
  onCloseRef.current = onClose;
103
+ const controlledOpenRef = useRef(controlledOpen);
104
+ controlledOpenRef.current = controlledOpen;
103
105
 
104
106
  // Drain queued close callbacks atomically — capturing the list and resetting
105
107
  // it before invocation ensures a callback that synchronously re-opens the
@@ -139,12 +141,15 @@ export function DialogBottomSheet({
139
141
  }, []);
140
142
 
141
143
  // Fired when the sheet has finished closing. Drains queued callbacks, then
142
- // fires `onClose` ONLY in imperative mode — controlled mode already requested
143
- // the close through `onClose` (the host then flipped `open`), so firing it
144
- // again here would double-call it.
144
+ // fires `onClose` in imperative mode. In controlled mode a close the HOST
145
+ // made (it flipped `open` to false, usually after `close()` asked it to) was
146
+ // already requested through `onClose`, so firing it again would double-call
147
+ // it — but a close the SHEET made on its own (the backdrop, a drag, Escape)
148
+ // arrives with `open` still true, and the host has to hear about it or its
149
+ // state says open over a closed sheet.
145
150
  const handleDismiss = useCallback(() => {
146
151
  callQueuedCallbacks();
147
- if (!isControlledRef.current) onCloseRef.current?.();
152
+ if (!isControlledRef.current || controlledOpenRef.current) onCloseRef.current?.();
148
153
  }, [callQueuedCallbacks]);
149
154
 
150
155
  // In controlled mode, mirror the `open` prop onto the underlying sheet so both