@duro-app/ui 5.11.0 → 5.13.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 (331) hide show
  1. package/dist/Table-Bms2ftt3.js +1694 -0
  2. package/dist/Table-Bms2ftt3.js.map +1 -0
  3. package/dist/compiler-runtime-BuugS4D0.js.map +1 -1
  4. package/dist/components/Alert/Alert.d.ts.map +1 -1
  5. package/dist/components/AppShell/native.css.d.ts +53 -0
  6. package/dist/components/AppShell/native.css.d.ts.map +1 -0
  7. package/dist/components/Button/Button.d.ts +2 -1
  8. package/dist/components/Button/Button.d.ts.map +1 -1
  9. package/dist/components/Button/styles.css.d.ts +1 -0
  10. package/dist/components/Button/styles.css.d.ts.map +1 -1
  11. package/dist/components/Callout/Callout.d.ts.map +1 -1
  12. package/dist/components/Callout/styles.css.d.ts +3 -0
  13. package/dist/components/Callout/styles.css.d.ts.map +1 -1
  14. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
  15. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
  16. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  17. package/dist/components/Dialog/Dialog.d.ts +2 -17
  18. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  19. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  20. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  21. package/dist/components/Dialog/native.css.d.ts +82 -0
  22. package/dist/components/Dialog/native.css.d.ts.map +1 -0
  23. package/dist/components/Dialog/parts.css.d.ts +82 -0
  24. package/dist/components/Dialog/parts.css.d.ts.map +1 -0
  25. package/dist/components/Dialog/parts.d.ts +35 -0
  26. package/dist/components/Dialog/parts.d.ts.map +1 -0
  27. package/dist/components/Dialog/styles.css.d.ts +1 -74
  28. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  29. package/dist/components/Drawer/Drawer.d.ts +7 -35
  30. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  31. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  32. package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
  33. package/dist/components/Drawer/DrawerSnap.stories.d.ts +20 -0
  34. package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
  35. package/dist/components/Drawer/native.css.d.ts +55 -0
  36. package/dist/components/Drawer/native.css.d.ts.map +1 -0
  37. package/dist/components/Drawer/parts.css.d.ts +96 -0
  38. package/dist/components/Drawer/parts.css.d.ts.map +1 -0
  39. package/dist/components/Drawer/parts.d.ts +56 -0
  40. package/dist/components/Drawer/parts.d.ts.map +1 -0
  41. package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
  42. package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
  43. package/dist/components/Drawer/styles.css.d.ts +15 -58
  44. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  45. package/dist/components/Drawer/touchInput.d.ts +35 -0
  46. package/dist/components/Drawer/touchInput.d.ts.map +1 -0
  47. package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
  48. package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
  49. package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
  50. package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
  51. package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
  52. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
  53. package/dist/components/Grid/styles.css.d.ts +1 -0
  54. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  55. package/dist/components/Icon/Icon.d.ts +3 -9
  56. package/dist/components/Icon/Icon.d.ts.map +1 -1
  57. package/dist/components/Icon/geometry.d.ts +60 -0
  58. package/dist/components/Icon/geometry.d.ts.map +1 -0
  59. package/dist/components/Icon/types.d.ts +15 -0
  60. package/dist/components/Icon/types.d.ts.map +1 -0
  61. package/dist/components/Input/Input.d.ts +5 -1
  62. package/dist/components/Input/Input.d.ts.map +1 -1
  63. package/dist/components/Input/styles.css.d.ts +1 -0
  64. package/dist/components/Input/styles.css.d.ts.map +1 -1
  65. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  66. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  67. package/dist/components/Menu/Menu.d.ts +7 -1
  68. package/dist/components/Menu/Menu.d.ts.map +1 -1
  69. package/dist/components/Menu/native.css.d.ts +14 -0
  70. package/dist/components/Menu/native.css.d.ts.map +1 -0
  71. package/dist/components/Menu/useMenuOpen.d.ts +16 -0
  72. package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
  73. package/dist/components/Menu/useMenuRoot.d.ts +3 -6
  74. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  75. package/dist/components/Meter/styles.css.d.ts +1 -0
  76. package/dist/components/Meter/styles.css.d.ts.map +1 -1
  77. package/dist/components/PageShell/styles.css.d.ts +2 -0
  78. package/dist/components/PageShell/styles.css.d.ts.map +1 -1
  79. package/dist/components/Popover/native.css.d.ts +11 -0
  80. package/dist/components/Popover/native.css.d.ts.map +1 -0
  81. package/dist/components/RadioGroup/RadioGroup.d.ts +1 -17
  82. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  83. package/dist/components/RadioGroup/RadioGroup.stories.d.ts +3 -0
  84. package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -1
  85. package/dist/components/RadioGroup/group.d.ts +31 -0
  86. package/dist/components/RadioGroup/group.d.ts.map +1 -0
  87. package/dist/components/RadioGroup/styles.css.d.ts +10 -0
  88. package/dist/components/RadioGroup/styles.css.d.ts.map +1 -1
  89. package/dist/components/ScrollArea/styles.css.d.ts +1 -0
  90. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  91. package/dist/components/SideNav/Chevron.d.ts +4 -0
  92. package/dist/components/SideNav/Chevron.d.ts.map +1 -0
  93. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  94. package/dist/components/SideNav/styles.css.d.ts +6 -0
  95. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  96. package/dist/components/SideNav/types.d.ts +6 -0
  97. package/dist/components/SideNav/types.d.ts.map +1 -0
  98. package/dist/components/Spinner/Spinner.d.ts +2 -6
  99. package/dist/components/Spinner/Spinner.d.ts.map +1 -1
  100. package/dist/components/Spinner/Spinner.types.d.ts +6 -0
  101. package/dist/components/Spinner/Spinner.types.d.ts.map +1 -0
  102. package/dist/components/Spinner/ring.css.d.ts +39 -0
  103. package/dist/components/Spinner/ring.css.d.ts.map +1 -0
  104. package/dist/components/Spinner/styles.css.d.ts +1 -28
  105. package/dist/components/Spinner/styles.css.d.ts.map +1 -1
  106. package/dist/components/SplitPane/SplitPane.d.ts +38 -0
  107. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
  108. package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
  109. package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
  110. package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
  111. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
  112. package/dist/components/SplitPane/styles.css.d.ts +78 -0
  113. package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
  114. package/dist/components/StatusIcon/StatusIcon.d.ts.map +1 -1
  115. package/dist/components/StatusIcon/styles.css.d.ts +3 -0
  116. package/dist/components/StatusIcon/styles.css.d.ts.map +1 -1
  117. package/dist/components/Switch/Switch.d.ts +2 -18
  118. package/dist/components/Switch/Switch.d.ts.map +1 -1
  119. package/dist/components/Switch/Switch.types.d.ts +18 -0
  120. package/dist/components/Switch/Switch.types.d.ts.map +1 -0
  121. package/dist/components/TableOfContents/styles.css.d.ts +1 -0
  122. package/dist/components/TableOfContents/styles.css.d.ts.map +1 -1
  123. package/dist/components/Tabs/TabLayout.d.ts +7 -0
  124. package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
  125. package/dist/components/Tabs/Tabs.d.ts.map +1 -1
  126. package/dist/components/Tabs/indicator.d.ts +28 -0
  127. package/dist/components/Tabs/indicator.d.ts.map +1 -0
  128. package/dist/components/Tabs/styles.css.d.ts +4 -0
  129. package/dist/components/Tabs/styles.css.d.ts.map +1 -1
  130. package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
  131. package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
  132. package/dist/components/Tabs/useTabKeys.d.ts +13 -0
  133. package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
  134. package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
  135. package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
  136. package/dist/components/Tag/Tag.d.ts +2 -24
  137. package/dist/components/Tag/Tag.d.ts.map +1 -1
  138. package/dist/components/Tag/styles.css.d.ts +8 -0
  139. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  140. package/dist/components/Tag/types.d.ts +39 -0
  141. package/dist/components/Tag/types.d.ts.map +1 -0
  142. package/dist/components/Textarea/styles.css.d.ts +1 -0
  143. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  144. package/dist/components/Toast/ToastProvider.d.ts +2 -21
  145. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  146. package/dist/components/Toast/native.css.d.ts +81 -0
  147. package/dist/components/Toast/native.css.d.ts.map +1 -0
  148. package/dist/components/Toast/store.d.ts +47 -0
  149. package/dist/components/Toast/store.d.ts.map +1 -0
  150. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  151. package/dist/components/Toggle/styles.css.d.ts +11 -2
  152. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  153. package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
  154. package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
  155. package/dist/components/Tree/Tree.d.ts.map +1 -1
  156. package/dist/components/Tree/styles.css.d.ts +6 -0
  157. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  158. package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
  159. package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
  160. package/dist/components/VirtualList/VirtualList.d.ts +33 -0
  161. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
  162. package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
  163. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
  164. package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
  165. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
  166. package/dist/components/VirtualList/styles.css.d.ts +28 -0
  167. package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
  168. package/dist/components/VirtualTable/styles.css.d.ts +6 -0
  169. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  170. package/dist/index.css +1 -1
  171. package/dist/index.d.ts +3 -1
  172. package/dist/index.d.ts.map +1 -1
  173. package/dist/index.js +6202 -4794
  174. package/dist/index.js.map +1 -1
  175. package/dist/shared/ButtonHost.d.ts +11 -0
  176. package/dist/shared/ButtonHost.d.ts.map +1 -0
  177. package/dist/shared/portal/hostRegistry.d.ts +33 -0
  178. package/dist/shared/portal/hostRegistry.d.ts.map +1 -0
  179. package/dist/shared/pressableChild.d.ts +14 -0
  180. package/dist/shared/pressableChild.d.ts.map +1 -0
  181. package/dist/shared/useOverlayOpenState.d.ts +40 -0
  182. package/dist/shared/useOverlayOpenState.d.ts.map +1 -0
  183. package/dist/table.js +141 -135
  184. package/dist/table.js.map +1 -1
  185. package/package.json +34 -4
  186. package/src/components/Alert/Alert.tsx +14 -2
  187. package/src/components/AppShell/AppShell.meta.ts +1 -1
  188. package/src/components/AppShell/AppShell.native.tsx +174 -0
  189. package/src/components/AppShell/native.css.ts +56 -0
  190. package/src/components/Button/Button.tsx +6 -5
  191. package/src/components/Button/styles.css.ts +2 -0
  192. package/src/components/Callout/Callout.tsx +17 -3
  193. package/src/components/Callout/styles.css.ts +5 -0
  194. package/src/components/Checkbox/Checkbox.native.tsx +67 -0
  195. package/src/components/Checkbox/Checkbox.tsx +1 -50
  196. package/src/components/ConfirmDialog/ConfirmDialog.tsx +3 -0
  197. package/src/components/DetailPanel/DetailPanel.tsx +9 -45
  198. package/src/components/Dialog/Dialog.native.tsx +296 -0
  199. package/src/components/Dialog/Dialog.stories.tsx +71 -0
  200. package/src/components/Dialog/Dialog.tsx +41 -130
  201. package/src/components/Dialog/native.css.ts +78 -0
  202. package/src/components/Dialog/parts.css.ts +101 -0
  203. package/src/components/Dialog/parts.tsx +61 -0
  204. package/src/components/Dialog/styles.css.ts +2 -89
  205. package/src/components/Drawer/Drawer.meta.ts +2 -1
  206. package/src/components/Drawer/Drawer.native.tsx +469 -0
  207. package/src/components/Drawer/Drawer.tsx +63 -179
  208. package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
  209. package/src/components/Drawer/DrawerSnap.stories.tsx +205 -0
  210. package/src/components/Drawer/native.css.ts +58 -0
  211. package/src/components/Drawer/parts.css.ts +125 -0
  212. package/src/components/Drawer/parts.tsx +110 -0
  213. package/src/components/Drawer/sheetPhysics.ts +103 -0
  214. package/src/components/Drawer/styles.css.ts +19 -74
  215. package/src/components/Drawer/touchInput.ts +81 -0
  216. package/src/components/Drawer/useDrawerRoot.ts +90 -0
  217. package/src/components/Drawer/useSheetHeights.ts +42 -0
  218. package/src/components/Drawer/useSwipeDismiss.ts +146 -23
  219. package/src/components/Grid/styles.css.ts +2 -0
  220. package/src/components/Icon/Icon.native.tsx +54 -0
  221. package/src/components/Icon/Icon.tsx +19 -430
  222. package/src/components/Icon/geometry.ts +456 -0
  223. package/src/components/Icon/types.ts +74 -0
  224. package/src/components/Input/Input.tsx +8 -0
  225. package/src/components/Input/styles.css.ts +2 -0
  226. package/src/components/LinkButton/styles.css.ts +2 -0
  227. package/src/components/Menu/Menu.meta.ts +1 -1
  228. package/src/components/Menu/Menu.native.tsx +268 -0
  229. package/src/components/Menu/Menu.tsx +11 -2
  230. package/src/components/Menu/native.css.ts +18 -0
  231. package/src/components/Menu/useMenuOpen.ts +33 -0
  232. package/src/components/Menu/useMenuRoot.ts +7 -26
  233. package/src/components/Meter/styles.css.ts +2 -0
  234. package/src/components/PageShell/styles.css.ts +4 -0
  235. package/src/components/Popover/Popover.meta.ts +1 -1
  236. package/src/components/Popover/Popover.native.tsx +205 -0
  237. package/src/components/Popover/native.css.ts +13 -0
  238. package/src/components/RadioGroup/RadioGroup.meta.ts +1 -1
  239. package/src/components/RadioGroup/RadioGroup.native.tsx +48 -0
  240. package/src/components/RadioGroup/RadioGroup.stories.tsx +64 -1
  241. package/src/components/RadioGroup/RadioGroup.tsx +8 -83
  242. package/src/components/RadioGroup/group.tsx +85 -0
  243. package/src/components/RadioGroup/styles.css.ts +13 -0
  244. package/src/components/ScrollArea/styles.css.ts +2 -0
  245. package/src/components/SideNav/Chevron.native.tsx +20 -0
  246. package/src/components/SideNav/Chevron.tsx +20 -0
  247. package/src/components/SideNav/SideNav.meta.ts +1 -1
  248. package/src/components/SideNav/SideNav.tsx +57 -41
  249. package/src/components/SideNav/styles.css.ts +8 -0
  250. package/src/components/SideNav/types.ts +6 -0
  251. package/src/components/Spinner/Spinner.native.tsx +37 -0
  252. package/src/components/Spinner/Spinner.tsx +6 -15
  253. package/src/components/Spinner/Spinner.types.ts +6 -0
  254. package/src/components/Spinner/ring.css.ts +45 -0
  255. package/src/components/Spinner/styles.css.ts +2 -32
  256. package/src/components/SplitPane/SplitPane.meta.ts +40 -0
  257. package/src/components/SplitPane/SplitPane.native.tsx +47 -0
  258. package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
  259. package/src/components/SplitPane/SplitPane.tsx +218 -0
  260. package/src/components/SplitPane/styles.css.ts +81 -0
  261. package/src/components/StatusIcon/StatusIcon.tsx +13 -2
  262. package/src/components/StatusIcon/styles.css.ts +5 -0
  263. package/src/components/Switch/Switch.native.tsx +45 -0
  264. package/src/components/Switch/Switch.tsx +22 -44
  265. package/src/components/Switch/Switch.types.ts +18 -0
  266. package/src/components/TableOfContents/styles.css.ts +2 -0
  267. package/src/components/Tabs/TabLayout.native.tsx +9 -0
  268. package/src/components/Tabs/TabLayout.tsx +6 -0
  269. package/src/components/Tabs/Tabs.meta.ts +1 -1
  270. package/src/components/Tabs/Tabs.tsx +38 -136
  271. package/src/components/Tabs/indicator.ts +36 -0
  272. package/src/components/Tabs/styles.css.ts +7 -0
  273. package/src/components/Tabs/useTabIndicator.native.ts +35 -0
  274. package/src/components/Tabs/useTabIndicator.ts +49 -0
  275. package/src/components/Tabs/useTabKeys.native.ts +2 -0
  276. package/src/components/Tabs/useTabKeys.ts +99 -0
  277. package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
  278. package/src/components/Tag/RemoveGlyph.tsx +10 -0
  279. package/src/components/Tag/Tag.meta.ts +1 -1
  280. package/src/components/Tag/Tag.native.tsx +70 -0
  281. package/src/components/Tag/Tag.tsx +9 -37
  282. package/src/components/Tag/styles.css.ts +9 -0
  283. package/src/components/Tag/types.ts +43 -0
  284. package/src/components/Textarea/styles.css.ts +2 -0
  285. package/src/components/ThemeProvider/ThemeProvider.native.tsx +70 -10
  286. package/src/components/Toast/Toast.meta.ts +1 -1
  287. package/src/components/Toast/ToastProvider.native.tsx +226 -0
  288. package/src/components/Toast/ToastProvider.tsx +22 -84
  289. package/src/components/Toast/native.css.ts +78 -0
  290. package/src/components/Toast/store.ts +96 -0
  291. package/src/components/Toggle/Toggle.tsx +5 -3
  292. package/src/components/Toggle/styles.css.ts +14 -2
  293. package/src/components/Tooltip/Tooltip.meta.ts +1 -1
  294. package/src/components/Tooltip/Tooltip.native.tsx +36 -0
  295. package/src/components/Tooltip/TooltipHint.ts +13 -0
  296. package/src/components/Tree/Tree.meta.ts +1 -1
  297. package/src/components/Tree/Tree.tsx +23 -135
  298. package/src/components/Tree/styles.css.ts +6 -0
  299. package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
  300. package/src/components/Tree/useTreeKeyboard.ts +152 -0
  301. package/src/components/VirtualList/VirtualList.meta.ts +39 -0
  302. package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
  303. package/src/components/VirtualList/VirtualList.tsx +103 -0
  304. package/src/components/VirtualList/styles.css.ts +31 -0
  305. package/src/components/VirtualTable/styles.css.ts +12 -0
  306. package/src/hooks/useContainerBelow.native.ts +30 -0
  307. package/src/index.native.ts +61 -1
  308. package/src/index.ts +8 -1
  309. package/src/native-guard/globals.d.ts +10 -0
  310. package/src/native-guard/react-dom.d.ts +3 -0
  311. package/src/shared/ButtonHost.native.tsx +70 -0
  312. package/src/shared/ButtonHost.tsx +12 -0
  313. package/src/shared/accessibilityFocus.native.ts +23 -0
  314. package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
  315. package/src/shared/portal/OverlayModal.native.tsx +51 -0
  316. package/src/shared/portal/PortalHost.native.tsx +102 -0
  317. package/src/shared/portal/hostRegistry.ts +76 -0
  318. package/src/shared/portal/themeScope.native.ts +18 -0
  319. package/src/shared/pressableChild.ts +33 -0
  320. package/src/shared/safeArea.native.ts +22 -0
  321. package/src/shared/useKeyboardHeight.native.ts +18 -0
  322. package/src/shared/useOverlayOpenState.ts +128 -0
  323. package/src/shared/useScreenReaderEnabled.native.ts +19 -0
  324. package/dist/Table-ColfpgKV.js +0 -1552
  325. package/dist/Table-ColfpgKV.js.map +0 -1
  326. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts +0 -10
  327. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts.map +0 -1
  328. package/dist/index.native.d.ts +0 -33
  329. package/dist/index.native.d.ts.map +0 -1
  330. package/dist/platform.native.d.ts +0 -2
  331. package/dist/platform.native.d.ts.map +0 -1
@@ -0,0 +1,70 @@
1
+ import {Children, type ComponentProps, type ReactNode, type Ref} from 'react'
2
+ import {Pressable} from 'react-native'
3
+ import {html} from 'react-strict-dom'
4
+ import {useTooltipHint} from '../components/Tooltip/TooltipHint'
5
+
6
+ export type ButtonHostProps = ComponentProps<typeof html.button>
7
+
8
+ /**
9
+ * The host element of Button and Toggle on native. Inside a Tooltip it is an
10
+ * RN Pressable, which carries the Tooltip's text as its own accessibility hint
11
+ * (plan N0, Decision 5: RSD drops `accessibilityHint` on html.*); the visual
12
+ * styles move to a div inside it. Elsewhere it is RSD's button, as before.
13
+ *
14
+ * holds-until: react-strict-dom maps aria-description to accessibilityHint
15
+ * (https://github.com/react/react-strict-dom/issues/524); then html.button
16
+ * carries the hint itself and this swap goes.
17
+ */
18
+ export function ButtonHost(props: ButtonHostProps) {
19
+ const hint = useTooltipHint()
20
+ const content = textInSpans(props.children)
21
+ if (!hint) {
22
+ return (
23
+ <html.button {...props} style={props.style}>
24
+ {content}
25
+ </html.button>
26
+ )
27
+ }
28
+ const {
29
+ ref,
30
+ style,
31
+ disabled,
32
+ onClick,
33
+ 'aria-label': label,
34
+ 'aria-pressed': pressed,
35
+ 'aria-expanded': expanded,
36
+ } = props
37
+ return (
38
+ <Pressable
39
+ ref={ref as Ref<never>}
40
+ onPress={disabled ? undefined : () => onClick?.(undefined as never)}
41
+ disabled={disabled}
42
+ accessibilityRole="button"
43
+ accessibilityLabel={label ?? undefined}
44
+ accessibilityHint={hint}
45
+ accessibilityState={{
46
+ disabled: !!disabled,
47
+ ...(pressed !== undefined ? {checked: !!pressed} : {}),
48
+ ...(expanded !== undefined ? {expanded: !!expanded} : {}),
49
+ }}
50
+ >
51
+ <html.div style={style}>{content}</html.div>
52
+ </Pressable>
53
+ )
54
+ }
55
+
56
+ /**
57
+ * A label beside an icon (`<Icon/> Menu`): RSD wraps a button's text only
58
+ * when it is the sole child, and React Native refuses bare text in a view.
59
+ */
60
+ function textInSpans(children: ReactNode): ReactNode {
61
+ const items = Children.toArray(children)
62
+ if (items.length < 2) return children
63
+ return items.map((child, i) =>
64
+ typeof child === 'string' || typeof child === 'number' ? (
65
+ <html.span key={i}>{child}</html.span>
66
+ ) : (
67
+ child
68
+ ),
69
+ )
70
+ }
@@ -0,0 +1,12 @@
1
+ import type {ComponentProps} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+
4
+ export type ButtonHostProps = ComponentProps<typeof html.button>
5
+
6
+ /**
7
+ * The host element of Button and Toggle. On the web it is the <button>
8
+ * itself, with no component in between (a wrapper changed what a Form's
9
+ * submit focuses); the native variant (ButtonHost.native.tsx) can swap in an
10
+ * RN Pressable to carry a Tooltip's accessibility hint.
11
+ */
12
+ export const ButtonHost: typeof html.button = html.button
@@ -0,0 +1,23 @@
1
+ import {AccessibilityInfo} from 'react-native'
2
+
3
+ const reported = new Set<string>()
4
+
5
+ type FocusTarget = Parameters<typeof AccessibilityInfo.sendAccessibilityEvent>[0]
6
+
7
+ /**
8
+ * Moves the screen reader's focus to `target` (a host view, or a
9
+ * react-strict-dom element ref). Native overlays call it on open and, for the
10
+ * trigger, on close: the screen-reader contract that stands in for the web's
11
+ * DOM focus (plan N0, Decision 6). A target the platform refuses is logged
12
+ * once per caller, in release builds too, rather than dropped.
13
+ */
14
+ export function focusForAccessibility(target: unknown, from: string): void {
15
+ if (target == null) return
16
+ try {
17
+ AccessibilityInfo.sendAccessibilityEvent(target as FocusTarget, 'focus')
18
+ } catch (error) {
19
+ if (reported.has(from)) return
20
+ reported.add(from)
21
+ console.warn(`[duro] ${from}: could not move accessibility focus: ${String(error)}`)
22
+ }
23
+ }
@@ -0,0 +1,117 @@
1
+ import {type ReactNode, type RefObject, useEffect, useState} from 'react'
2
+ import {
3
+ type LayoutChangeEvent,
4
+ Pressable,
5
+ StyleSheet,
6
+ useWindowDimensions,
7
+ View,
8
+ } from 'react-native'
9
+ import {SPACING_PX} from '@duro-app/tokens/keys'
10
+ import {
11
+ computePopoverPosition,
12
+ type PopoverAlign,
13
+ type PopoverRect,
14
+ type PopoverSide,
15
+ type PopoverSize,
16
+ } from '../../components/Popover/position'
17
+ import {OverlayModal} from './OverlayModal'
18
+
19
+ interface Measurable {
20
+ measureInWindow?: (cb: (x: number, y: number, width: number, height: number) => void) => void
21
+ }
22
+
23
+ interface AnchoredOverlayProps {
24
+ visible: boolean
25
+ /** The trigger's host: it is measured in window coordinates when the overlay opens */
26
+ anchorRef: RefObject<unknown>
27
+ side: PopoverSide
28
+ align: PopoverAlign
29
+ /** A backdrop press or Android's back button */
30
+ onRequestClose: () => void
31
+ /** The Modal has been presented: move accessibility focus in */
32
+ onShow?: () => void
33
+ /** iOS: the Modal has finished dismissing */
34
+ onDismiss?: () => void
35
+ children: ReactNode
36
+ }
37
+
38
+ /**
39
+ * A popup placed against its trigger on native (plan N0, Decision 3): the
40
+ * trigger is measured with `measureInWindow`, the popup with `onLayout`, and
41
+ * `computePopoverPosition` places it exactly as on the web. The OverlayModal
42
+ * is transparent and translucent, so its coordinates are window coordinates.
43
+ * Until both are measured the popup is laid out but not shown.
44
+ */
45
+ export function AnchoredOverlay({
46
+ visible,
47
+ anchorRef,
48
+ side,
49
+ align,
50
+ onRequestClose,
51
+ onShow,
52
+ onDismiss,
53
+ children,
54
+ }: AnchoredOverlayProps) {
55
+ const window = useWindowDimensions()
56
+ const [anchor, setAnchor] = useState<PopoverRect | null>(null)
57
+ const [size, setSize] = useState<PopoverSize | null>(null)
58
+
59
+ useEffect(() => {
60
+ if (!visible) {
61
+ setAnchor(null)
62
+ setSize(null)
63
+ return
64
+ }
65
+ const host = anchorRef.current as Measurable | null
66
+ host?.measureInWindow?.((left, top, width, height) => setAnchor({top, left, width, height}))
67
+ }, [visible, anchorRef, window.width, window.height])
68
+
69
+ const onLayout = (e: LayoutChangeEvent) => {
70
+ const {width, height} = e.nativeEvent.layout
71
+ setSize((prev) => (prev?.width === width && prev.height === height ? prev : {width, height}))
72
+ }
73
+
74
+ const position =
75
+ anchor && size
76
+ ? computePopoverPosition({
77
+ anchor,
78
+ popup: size,
79
+ viewport: {width: window.width, height: window.height},
80
+ side,
81
+ align,
82
+ offset: SPACING_PX.xs,
83
+ })
84
+ : null
85
+
86
+ return (
87
+ <OverlayModal
88
+ visible={visible}
89
+ onRequestClose={onRequestClose}
90
+ onShow={onShow}
91
+ onDismiss={onDismiss}
92
+ >
93
+ <Pressable
94
+ style={StyleSheet.absoluteFill}
95
+ onPress={onRequestClose}
96
+ accessible={false}
97
+ importantForAccessibility="no"
98
+ />
99
+ <View
100
+ onLayout={onLayout}
101
+ style={[
102
+ layout.popup,
103
+ {maxWidth: window.width - 2 * SPACING_PX.sm},
104
+ position ? {top: position.top, left: position.left} : layout.unplaced,
105
+ ]}
106
+ >
107
+ {children}
108
+ </View>
109
+ </OverlayModal>
110
+ )
111
+ }
112
+
113
+ // Layout-only: where the popup sits, and hidden until it has been placed.
114
+ const layout = StyleSheet.create({
115
+ popup: {position: 'absolute'},
116
+ unplaced: {top: 0, left: 0, opacity: 0},
117
+ })
@@ -0,0 +1,51 @@
1
+ import type {ReactNode} from 'react'
2
+ import {Modal, StyleSheet} from 'react-native'
3
+ import {GestureHandlerRootView} from 'react-native-gesture-handler'
4
+ import {PortalHost} from './PortalHost'
5
+
6
+ interface OverlayModalProps {
7
+ visible: boolean
8
+ /** Android back button: the dismissal Escape is on the web. */
9
+ onRequestClose: () => void
10
+ /** The Modal has been presented: move accessibility focus in. */
11
+ onShow?: () => void
12
+ /** iOS: the Modal has finished dismissing (presenting another one is safe). */
13
+ onDismiss?: () => void
14
+ children: ReactNode
15
+ }
16
+
17
+ /**
18
+ * The window every interactive native overlay renders in (plan N0,
19
+ * Decision 2). RN `Modal` is a React portal, so the app's contexts reach the
20
+ * content, and it presents above natively presented routes. Duro draws its own
21
+ * backdrop and motion, so the Modal's own animation is off.
22
+ *
23
+ * Content gets its own GestureHandlerRootView: on Android a Modal is a
24
+ * separate window that the app's root view does not cover. A PortalHost on
25
+ * top lets toasts raised while it is open draw above it.
26
+ */
27
+ export function OverlayModal({
28
+ visible,
29
+ onRequestClose,
30
+ onShow,
31
+ onDismiss,
32
+ children,
33
+ }: OverlayModalProps) {
34
+ return (
35
+ <Modal
36
+ visible={visible}
37
+ transparent
38
+ statusBarTranslucent
39
+ navigationBarTranslucent
40
+ animationType="none"
41
+ onRequestClose={onRequestClose}
42
+ onShow={onShow}
43
+ onDismiss={onDismiss}
44
+ >
45
+ <GestureHandlerRootView style={StyleSheet.absoluteFill}>
46
+ {children}
47
+ <PortalHost />
48
+ </GestureHandlerRootView>
49
+ </Modal>
50
+ )
51
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useSyncExternalStore,
9
+ } from 'react'
10
+ import {StyleSheet, View} from 'react-native'
11
+ import {css, html} from 'react-strict-dom'
12
+ import type {HostRegistry} from './hostRegistry'
13
+ import {ResolvedColorsContext, ThemeScopeContext} from './themeScope'
14
+
15
+ /** The registry the outermost ThemeProvider owns; null outside every provider. */
16
+ export const HostRegistryContext = createContext<HostRegistry | null>(null)
17
+
18
+ function useHostRegistry(component: string): HostRegistry {
19
+ const registry = useContext(HostRegistryContext)
20
+ if (!registry) throw new Error(`${component} must be rendered inside a Duro ThemeProvider`)
21
+ return registry
22
+ }
23
+
24
+ const styles = css.create({
25
+ // An entry covers its host without taking touches the entry does not draw on.
26
+ entry: {
27
+ position: 'absolute',
28
+ top: 0,
29
+ left: 0,
30
+ right: 0,
31
+ bottom: 0,
32
+ pointerEvents: 'box-none',
33
+ },
34
+ })
35
+
36
+ type DivStyle = Parameters<typeof html.div>[0]['style']
37
+
38
+ interface PortalHostProps {
39
+ /**
40
+ * `base`: the app root, always below every other host (the ThemeProvider
41
+ * draws it). `presented` (the default): a natively presented route, which
42
+ * a consumer mounts in that route's layout so toasts draw above it.
43
+ */
44
+ placement?: 'base' | 'presented'
45
+ }
46
+
47
+ /**
48
+ * Where passive overlays (toasts) draw on native. The ThemeProvider draws the
49
+ * root host and each open Duro Modal draws its own; mount one inside a
50
+ * natively presented route (`presentation: 'modal'`), or toasts raised there
51
+ * draw behind it.
52
+ */
53
+ export function PortalHost({placement = 'presented'}: PortalHostProps) {
54
+ const registry = useHostRegistry('PortalHost')
55
+ const id = useId()
56
+ useLayoutEffect(() => registry.addHost(id, placement), [registry, id, placement])
57
+ const {topHost, entries} = useSyncExternalStore(registry.subscribe, registry.getSnapshot)
58
+ if (topHost !== id) return null
59
+ return (
60
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
61
+ {entries.map((entry) => (
62
+ <View key={entry.key} style={StyleSheet.absoluteFill} pointerEvents="box-none">
63
+ {entry.node}
64
+ </View>
65
+ ))}
66
+ </View>
67
+ )
68
+ }
69
+
70
+ interface PortalProps {
71
+ /** Numeric layer from `LAYERS` in `@duro-app/tokens/keys`. */
72
+ layer: number
73
+ children: ReactNode
74
+ }
75
+
76
+ /**
77
+ * Draws its children in the top host, above the app and every open Duro
78
+ * Modal. The children keep the source's theme and resolved colours; no other
79
+ * context crosses, so pass data, not components that read the app's contexts.
80
+ */
81
+ export function Portal({layer, children}: PortalProps) {
82
+ const registry = useHostRegistry('Portal')
83
+ const key = useId()
84
+ const scope = useContext(ThemeScopeContext)
85
+ const resolvedColors = useContext(ResolvedColorsContext)
86
+
87
+ useLayoutEffect(() => {
88
+ registry.setEntry({
89
+ key,
90
+ layer,
91
+ node: (
92
+ <ThemeScopeContext.Provider value={scope}>
93
+ <ResolvedColorsContext.Provider value={resolvedColors}>
94
+ <html.div style={[...scope.styles, styles.entry] as DivStyle}>{children}</html.div>
95
+ </ResolvedColorsContext.Provider>
96
+ </ThemeScopeContext.Provider>
97
+ ),
98
+ })
99
+ })
100
+ useEffect(() => () => registry.removeEntry(key), [registry, key])
101
+ return null
102
+ }
@@ -0,0 +1,76 @@
1
+ import type {ReactNode} from 'react'
2
+
3
+ /** One passive surface (a toast region) waiting for a host to draw it. */
4
+ export interface PortalEntry {
5
+ readonly key: string
6
+ /** Numeric layer from `LAYERS` in `@duro-app/tokens/keys`; higher draws on top. */
7
+ readonly layer: number
8
+ readonly node: ReactNode
9
+ }
10
+
11
+ export interface HostRegistrySnapshot {
12
+ /** The host that draws every entry: the last one presented, else the root. */
13
+ readonly topHost: string | undefined
14
+ /** Entries ordered by layer, lowest first (equal layers keep insertion order). */
15
+ readonly entries: readonly PortalEntry[]
16
+ }
17
+
18
+ /**
19
+ * The hosts that passive surfaces draw into, on native: the app root, each
20
+ * open Duro Modal and each `PortalHost` a consumer mounts in a natively
21
+ * presented route. Entries render in the top host only, so a toast raised
22
+ * under an open Dialog draws above it.
23
+ *
24
+ * A plain store (subscribe / getSnapshot) for `useSyncExternalStore`; it holds
25
+ * no React state and imports nothing from React Native.
26
+ */
27
+ export interface HostRegistry {
28
+ /** Adds a host. A `base` host (the app root) always sits below the others. */
29
+ addHost(id: string, placement: 'base' | 'presented'): () => void
30
+ setEntry(entry: PortalEntry): void
31
+ removeEntry(key: string): void
32
+ getSnapshot(): HostRegistrySnapshot
33
+ subscribe(listener: () => void): () => void
34
+ }
35
+
36
+ export function createHostRegistry(): HostRegistry {
37
+ let hosts: readonly string[] = []
38
+ let entries: readonly PortalEntry[] = []
39
+ let snapshot: HostRegistrySnapshot = {topHost: undefined, entries}
40
+ const listeners = new Set<() => void>()
41
+
42
+ const publish = () => {
43
+ snapshot = {topHost: hosts[hosts.length - 1], entries}
44
+ for (const listener of listeners) listener()
45
+ }
46
+
47
+ return {
48
+ addHost(id, placement) {
49
+ hosts = placement === 'base' ? [id, ...hosts] : [...hosts, id]
50
+ publish()
51
+ return () => {
52
+ hosts = hosts.filter((host) => host !== id)
53
+ publish()
54
+ }
55
+ },
56
+ setEntry(entry) {
57
+ const others = entries.filter((existing) => existing.key !== entry.key)
58
+ const at = others.findIndex((existing) => existing.layer > entry.layer)
59
+ entries =
60
+ at === -1 ? [...others, entry] : [...others.slice(0, at), entry, ...others.slice(at)]
61
+ publish()
62
+ },
63
+ removeEntry(key) {
64
+ if (!entries.some((entry) => entry.key === key)) return
65
+ entries = entries.filter((entry) => entry.key !== key)
66
+ publish()
67
+ },
68
+ getSnapshot: () => snapshot,
69
+ subscribe(listener) {
70
+ listeners.add(listener)
71
+ return () => {
72
+ listeners.delete(listener)
73
+ }
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,18 @@
1
+ import {createContext} from 'react'
2
+ import type {ColorToken} from '@duro-app/tokens/keys'
3
+ import {darkColors} from '@duro-app/tokens/raw'
4
+
5
+ /**
6
+ * The theme styles in force where a component renders: every enclosing
7
+ * ThemeProvider's createTheme objects, outermost first. A native Portal
8
+ * re-applies them around its entry, because the host it draws into sits
9
+ * outside the source's ThemeProviders.
10
+ */
11
+ export interface ThemeScope {
12
+ readonly styles: readonly unknown[]
13
+ }
14
+
15
+ export const ThemeScopeContext = createContext<ThemeScope>({styles: []})
16
+
17
+ /** The nearest ThemeProvider's palette as colour strings (native). */
18
+ export const ResolvedColorsContext = createContext<Record<ColorToken, string>>(darkColors)
@@ -0,0 +1,33 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ isValidElement,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ type Ref,
8
+ } from 'react'
9
+ import {mergeRefs} from './mergeRefs'
10
+
11
+ interface PressableChildProps {
12
+ onClick?: () => void
13
+ ref?: Ref<unknown>
14
+ }
15
+
16
+ /**
17
+ * Native overlay triggers and closes: native presses do not bubble to a
18
+ * wrapper as web clicks do, so the one child (a Button) gets the handler
19
+ * itself, after its own. `ref` also receives the child's host, where focus
20
+ * returns when the overlay closes.
21
+ */
22
+ export function withPress(children: ReactNode, onPress: () => void, ref?: Ref<unknown>) {
23
+ const child = Children.only(children)
24
+ if (!isValidElement<PressableChildProps>(child)) return null
25
+ const own = child.props.onClick
26
+ return cloneElement(child as ReactElement<PressableChildProps>, {
27
+ ...(ref ? {ref: mergeRefs(child.props.ref, ref)} : {}),
28
+ onClick: () => {
29
+ own?.()
30
+ onPress()
31
+ },
32
+ })
33
+ }
@@ -0,0 +1,22 @@
1
+ import {useContext} from 'react'
2
+ import {SafeAreaInsetsContext, type EdgeInsets} from 'react-native-safe-area-context'
3
+ import {devWarnOnce} from './devWarnOnce'
4
+
5
+ const NO_INSETS: EdgeInsets = {top: 0, right: 0, bottom: 0, left: 0}
6
+
7
+ /**
8
+ * The window's safe-area insets from the app's SafeAreaProvider (expo-router
9
+ * mounts one). Modal content is a React portal, so overlays inside an
10
+ * OverlayModal read the same insets. Without a provider, no insets, and a
11
+ * development warning names the missing provider.
12
+ */
13
+ export function useSafeAreaInsets(): EdgeInsets {
14
+ const insets = useContext(SafeAreaInsetsContext)
15
+ if (insets) return insets
16
+ devWarnOnce(
17
+ 'safeArea',
18
+ 'no-provider',
19
+ 'No SafeAreaProvider above this Duro overlay: it draws without safe-area insets. Wrap the app in react-native-safe-area-context’s SafeAreaProvider.',
20
+ )
21
+ return NO_INSETS
22
+ }
@@ -0,0 +1,18 @@
1
+ import {useEffect, useState} from 'react'
2
+ import {Keyboard, Platform} from 'react-native'
3
+
4
+ /** The on-screen keyboard's height while it is shown, else 0. */
5
+ export function useKeyboardHeight(): number {
6
+ const [height, setHeight] = useState(0)
7
+ useEffect(() => {
8
+ // iOS announces the keyboard before it moves, Android only after.
9
+ const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
10
+ const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
11
+ const subs = [
12
+ Keyboard.addListener(show, (e) => setHeight(e.endCoordinates.height)),
13
+ Keyboard.addListener(hide, () => setHeight(0)),
14
+ ]
15
+ return () => subs.forEach((s) => s.remove())
16
+ }, [])
17
+ return height
18
+ }
@@ -0,0 +1,128 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
+ import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
3
+
4
+ /**
5
+ * When the owner hears that the overlay closed.
6
+ * - `after-exit`: once the exit animation has played (Dialog, Drawer): the
7
+ * overlay stays logically open while it animates out.
8
+ * - `at-once`: as soon as the close is asked for (DetailPanel); the panel
9
+ * still animates out while `present`.
10
+ */
11
+ export type CloseNotice = 'after-exit' | 'at-once'
12
+
13
+ interface Options {
14
+ /** Controlled open state. */
15
+ open: boolean | undefined
16
+ /** Initial open state when uncontrolled. */
17
+ defaultOpen: boolean
18
+ onOpenChange: ((open: boolean) => void) | undefined
19
+ /** Motion token the exit animation lasts. */
20
+ exitDuration: DurationToken
21
+ closeNotice: CloseNotice
22
+ }
23
+
24
+ export interface OverlayOpenState {
25
+ /** Logically open (what `open` / the uncontrolled state says). */
26
+ open: boolean
27
+ /** Rendered: open, or animating out. */
28
+ present: boolean
29
+ /** Playing the exit animation. */
30
+ closing: boolean
31
+ requestOpen: () => void
32
+ /** Animated close. Does nothing while a close is already playing. */
33
+ requestClose: () => void
34
+ /** Close without the exit animation (a swipe that already moved the panel out). */
35
+ closeNow: () => void
36
+ }
37
+
38
+ /**
39
+ * Open, presence and exit-animation state shared by Dialog, Drawer and
40
+ * DetailPanel on both platforms. A controlled `open` that turns false from
41
+ * outside plays the exit animation too.
42
+ */
43
+ export function useOverlayOpenState({
44
+ open: controlledOpen,
45
+ defaultOpen,
46
+ onOpenChange,
47
+ exitDuration,
48
+ closeNotice,
49
+ }: Options): OverlayOpenState {
50
+ const isControlled = controlledOpen !== undefined
51
+ const [internalOpen, setInternalOpen] = useState(defaultOpen)
52
+ const open = isControlled ? controlledOpen : internalOpen
53
+ const [present, setPresent] = useState(open)
54
+ const [closing, setClosing] = useState(false)
55
+ const closingRef = useRef(false)
56
+ // The playing exit was started by the owner's `open` turning false (not by
57
+ // requestClose), so an `open` turning true again cancels it.
58
+ const outsideExitRef = useRef(false)
59
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
60
+
61
+ const cancelExit = useCallback(() => {
62
+ if (timerRef.current) clearTimeout(timerRef.current)
63
+ timerRef.current = null
64
+ closingRef.current = false
65
+ outsideExitRef.current = false
66
+ setClosing(false)
67
+ }, [])
68
+
69
+ const playExit = useCallback(
70
+ (afterExit?: () => void) => {
71
+ closingRef.current = true
72
+ outsideExitRef.current = afterExit === undefined
73
+ setClosing(true)
74
+ timerRef.current = setTimeout(() => {
75
+ timerRef.current = null
76
+ closingRef.current = false
77
+ outsideExitRef.current = false
78
+ setClosing(false)
79
+ setPresent(false)
80
+ afterExit?.()
81
+ }, DURATION_MS[exitDuration])
82
+ },
83
+ [exitDuration],
84
+ )
85
+
86
+ const notifyClosed = useCallback(() => {
87
+ if (!isControlled) setInternalOpen(false)
88
+ onOpenChange?.(false)
89
+ }, [isControlled, onOpenChange])
90
+
91
+ const requestOpen = useCallback(() => {
92
+ cancelExit()
93
+ setPresent(true)
94
+ if (!isControlled) setInternalOpen(true)
95
+ onOpenChange?.(true)
96
+ }, [cancelExit, isControlled, onOpenChange])
97
+
98
+ const requestClose = useCallback(() => {
99
+ if (closingRef.current) return
100
+ if (closeNotice === 'after-exit') playExit(notifyClosed)
101
+ else notifyClosed()
102
+ }, [closeNotice, playExit, notifyClosed])
103
+
104
+ const closeNow = useCallback(() => {
105
+ cancelExit()
106
+ setPresent(false)
107
+ notifyClosed()
108
+ }, [cancelExit, notifyClosed])
109
+
110
+ // Presence follows the logical state: an open from outside shows the
111
+ // overlay, a close from outside (or an `at-once` close) animates it out.
112
+ useEffect(() => {
113
+ if (open && outsideExitRef.current) cancelExit()
114
+ else if (open && !present && !closingRef.current) setPresent(true)
115
+ else if (!open && present && !closingRef.current) playExit()
116
+ }, [open, present, playExit, cancelExit])
117
+
118
+ useEffect(
119
+ () => () => {
120
+ if (timerRef.current) clearTimeout(timerRef.current)
121
+ },
122
+ [],
123
+ )
124
+
125
+ // An open is rendered in the same commit (a consumer's effect on `open` can
126
+ // already reach the overlay's content); the state catches up in the effect.
127
+ return {open, present: open || present, closing, requestOpen, requestClose, closeNow}
128
+ }