@multiplatform.one/components 7.28.1 → 7.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/dist/cjs/Accordion/index.cjs +26 -17
  2. package/dist/cjs/Accordion/index.native.js +36 -27
  3. package/dist/cjs/Accordion/index.native.js.map +1 -1
  4. package/dist/cjs/Heading.cjs +74 -30
  5. package/dist/cjs/Heading.native.js +81 -30
  6. package/dist/cjs/Heading.native.js.map +1 -1
  7. package/dist/cjs/ProgressSteps/index.native.js.map +1 -1
  8. package/dist/cjs/ScrollView/index.native.js +4 -0
  9. package/dist/cjs/ScrollView/index.native.js.map +1 -1
  10. package/dist/cjs/SortableList/SortableHandle.cjs +1 -1
  11. package/dist/cjs/SortableList/SortableHandle.native.js +1 -1
  12. package/dist/cjs/Text.cjs +58 -21
  13. package/dist/cjs/Text.native.js +62 -21
  14. package/dist/cjs/Text.native.js.map +1 -1
  15. package/dist/cjs/Toast/index.native.js.map +1 -1
  16. package/dist/cjs/charts/ChartDataView.cjs +2 -1
  17. package/dist/cjs/charts/ChartDataView.native.js +2 -1
  18. package/dist/cjs/charts/ChartDataView.native.js.map +1 -1
  19. package/dist/cjs/code/CodeBlock/index.cjs +8 -8
  20. package/dist/cjs/code/CodeBlock/index.native.js +8 -8
  21. package/dist/cjs/code/CodeBlock/index.native.js.map +1 -1
  22. package/dist/cjs/index.cjs +1 -0
  23. package/dist/cjs/index.native.js +1 -0
  24. package/dist/cjs/index.native.js.map +1 -1
  25. package/dist/cjs/layouts/EmptyState.cjs +4 -3
  26. package/dist/cjs/layouts/EmptyState.native.js +14 -9
  27. package/dist/cjs/layouts/EmptyState.native.js.map +1 -1
  28. package/dist/cjs/layouts/page.cjs +2 -1
  29. package/dist/cjs/layouts/page.native.js +2 -1
  30. package/dist/cjs/layouts/page.native.js.map +1 -1
  31. package/dist/cjs/layouts/sidebar.cjs +2 -1
  32. package/dist/cjs/layouts/sidebar.native.js +2 -1
  33. package/dist/cjs/layouts/sidebar.native.js.map +1 -1
  34. package/dist/cjs/layouts/tags.cjs +2 -1
  35. package/dist/cjs/layouts/tags.native.js +2 -1
  36. package/dist/cjs/layouts/tags.native.js.map +1 -1
  37. package/dist/cjs/layouts/toolbar.cjs +2 -1
  38. package/dist/cjs/layouts/toolbar.native.js +2 -1
  39. package/dist/cjs/layouts/toolbar.native.js.map +1 -1
  40. package/dist/cjs/shared/useReturnFocusOnClose.cjs +1 -0
  41. package/dist/cjs/shared/useReturnFocusOnClose.native.js +1 -0
  42. package/dist/cjs/shared/useReturnFocusOnClose.native.js.map +1 -1
  43. package/dist/cjs/surfaces.cjs +61 -0
  44. package/dist/cjs/surfaces.native.js +73 -0
  45. package/dist/cjs/surfaces.native.js.map +1 -1
  46. package/dist/cjs/views/Calendar.cjs +13 -10
  47. package/dist/cjs/views/Calendar.native.js +13 -10
  48. package/dist/cjs/views/Calendar.native.js.map +1 -1
  49. package/dist/cjs/views/Dashboard.cjs +2 -1
  50. package/dist/cjs/views/Dashboard.native.js +2 -1
  51. package/dist/cjs/views/Dashboard.native.js.map +1 -1
  52. package/dist/cjs/views/Gantt.cjs +39 -27
  53. package/dist/cjs/views/Gantt.native.js +38 -23
  54. package/dist/cjs/views/Gantt.native.js.map +1 -1
  55. package/dist/cjs/views/Kanban.cjs +2 -1
  56. package/dist/cjs/views/Kanban.native.js +1 -0
  57. package/dist/cjs/views/Kanban.native.js.map +1 -1
  58. package/dist/cjs/views/List.cjs +1 -0
  59. package/dist/cjs/views/List.native.js +1 -0
  60. package/dist/cjs/views/List.native.js.map +1 -1
  61. package/dist/cjs/views/Matrix.cjs +5 -3
  62. package/dist/cjs/views/Matrix.native.js +5 -3
  63. package/dist/cjs/views/Matrix.native.js.map +1 -1
  64. package/dist/cjs/views/ReportBuilder.cjs +2 -1
  65. package/dist/cjs/views/ReportBuilder.native.js +2 -1
  66. package/dist/cjs/views/ReportBuilder.native.js.map +1 -1
  67. package/dist/cjs/views/TreeView.cjs +2 -1
  68. package/dist/cjs/views/TreeView.native.js +2 -1
  69. package/dist/cjs/views/TreeView.native.js.map +1 -1
  70. package/dist/esm/Accordion/index.mjs +26 -18
  71. package/dist/esm/Accordion/index.mjs.map +1 -1
  72. package/dist/esm/Accordion/index.native.js +36 -28
  73. package/dist/esm/Accordion/index.native.js.map +1 -1
  74. package/dist/esm/Heading.mjs +74 -30
  75. package/dist/esm/Heading.mjs.map +1 -1
  76. package/dist/esm/Heading.native.js +81 -30
  77. package/dist/esm/Heading.native.js.map +1 -1
  78. package/dist/esm/ProgressSteps/index.mjs.map +1 -1
  79. package/dist/esm/ProgressSteps/index.native.js.map +1 -1
  80. package/dist/esm/ScrollView/index.native.js +4 -0
  81. package/dist/esm/ScrollView/index.native.js.map +1 -1
  82. package/dist/esm/SortableList/SortableHandle.mjs +1 -1
  83. package/dist/esm/SortableList/SortableHandle.native.js +1 -1
  84. package/dist/esm/SortableList/SortableHandle.native.js.map +1 -1
  85. package/dist/esm/Text.mjs +58 -21
  86. package/dist/esm/Text.mjs.map +1 -1
  87. package/dist/esm/Text.native.js +62 -21
  88. package/dist/esm/Text.native.js.map +1 -1
  89. package/dist/esm/Toast/index.mjs.map +1 -1
  90. package/dist/esm/Toast/index.native.js.map +1 -1
  91. package/dist/esm/charts/ChartDataView.mjs +2 -1
  92. package/dist/esm/charts/ChartDataView.mjs.map +1 -1
  93. package/dist/esm/charts/ChartDataView.native.js +2 -1
  94. package/dist/esm/charts/ChartDataView.native.js.map +1 -1
  95. package/dist/esm/code/CodeBlock/index.mjs +1 -1
  96. package/dist/esm/code/CodeBlock/index.native.js +1 -1
  97. package/dist/esm/index.mjs +2 -2
  98. package/dist/esm/index.mjs.map +1 -1
  99. package/dist/esm/index.native.js +2 -2
  100. package/dist/esm/index.native.js.map +1 -1
  101. package/dist/esm/layouts/EmptyState.mjs +4 -3
  102. package/dist/esm/layouts/EmptyState.mjs.map +1 -1
  103. package/dist/esm/layouts/EmptyState.native.js +14 -9
  104. package/dist/esm/layouts/EmptyState.native.js.map +1 -1
  105. package/dist/esm/layouts/page.mjs +2 -1
  106. package/dist/esm/layouts/page.mjs.map +1 -1
  107. package/dist/esm/layouts/page.native.js +2 -1
  108. package/dist/esm/layouts/page.native.js.map +1 -1
  109. package/dist/esm/layouts/sidebar.mjs +2 -1
  110. package/dist/esm/layouts/sidebar.mjs.map +1 -1
  111. package/dist/esm/layouts/sidebar.native.js +2 -1
  112. package/dist/esm/layouts/sidebar.native.js.map +1 -1
  113. package/dist/esm/layouts/tags.mjs +2 -1
  114. package/dist/esm/layouts/tags.mjs.map +1 -1
  115. package/dist/esm/layouts/tags.native.js +2 -1
  116. package/dist/esm/layouts/tags.native.js.map +1 -1
  117. package/dist/esm/layouts/toolbar.mjs +2 -1
  118. package/dist/esm/layouts/toolbar.mjs.map +1 -1
  119. package/dist/esm/layouts/toolbar.native.js +2 -1
  120. package/dist/esm/layouts/toolbar.native.js.map +1 -1
  121. package/dist/esm/shared/useReturnFocusOnClose.mjs +1 -0
  122. package/dist/esm/shared/useReturnFocusOnClose.mjs.map +1 -1
  123. package/dist/esm/shared/useReturnFocusOnClose.native.js +1 -0
  124. package/dist/esm/shared/useReturnFocusOnClose.native.js.map +1 -1
  125. package/dist/esm/surfaces.mjs +63 -3
  126. package/dist/esm/surfaces.mjs.map +1 -1
  127. package/dist/esm/surfaces.native.js +75 -3
  128. package/dist/esm/surfaces.native.js.map +1 -1
  129. package/dist/esm/views/Calendar.mjs +12 -9
  130. package/dist/esm/views/Calendar.mjs.map +1 -1
  131. package/dist/esm/views/Calendar.native.js +12 -9
  132. package/dist/esm/views/Calendar.native.js.map +1 -1
  133. package/dist/esm/views/Dashboard.mjs +2 -1
  134. package/dist/esm/views/Dashboard.mjs.map +1 -1
  135. package/dist/esm/views/Dashboard.native.js +2 -1
  136. package/dist/esm/views/Dashboard.native.js.map +1 -1
  137. package/dist/esm/views/Gantt.mjs +37 -25
  138. package/dist/esm/views/Gantt.mjs.map +1 -1
  139. package/dist/esm/views/Gantt.native.js +36 -21
  140. package/dist/esm/views/Gantt.native.js.map +1 -1
  141. package/dist/esm/views/Kanban.mjs +2 -1
  142. package/dist/esm/views/Kanban.mjs.map +1 -1
  143. package/dist/esm/views/Kanban.native.js +1 -0
  144. package/dist/esm/views/Kanban.native.js.map +1 -1
  145. package/dist/esm/views/List.mjs +1 -0
  146. package/dist/esm/views/List.mjs.map +1 -1
  147. package/dist/esm/views/List.native.js +1 -0
  148. package/dist/esm/views/List.native.js.map +1 -1
  149. package/dist/esm/views/Matrix.mjs +3 -1
  150. package/dist/esm/views/Matrix.mjs.map +1 -1
  151. package/dist/esm/views/Matrix.native.js +3 -1
  152. package/dist/esm/views/Matrix.native.js.map +1 -1
  153. package/dist/esm/views/ReportBuilder.mjs +2 -1
  154. package/dist/esm/views/ReportBuilder.mjs.map +1 -1
  155. package/dist/esm/views/ReportBuilder.native.js +2 -1
  156. package/dist/esm/views/ReportBuilder.native.js.map +1 -1
  157. package/dist/esm/views/TreeView.mjs +2 -1
  158. package/dist/esm/views/TreeView.mjs.map +1 -1
  159. package/dist/esm/views/TreeView.native.js +2 -1
  160. package/dist/esm/views/TreeView.native.js.map +1 -1
  161. package/dist/jsx/Accordion/index.mjs +26 -18
  162. package/dist/jsx/Accordion/index.mjs.map +1 -1
  163. package/dist/jsx/Accordion/index.native.js +36 -27
  164. package/dist/jsx/Accordion/index.native.js.map +1 -1
  165. package/dist/jsx/Heading.mjs +74 -30
  166. package/dist/jsx/Heading.mjs.map +1 -1
  167. package/dist/jsx/Heading.native.js +81 -30
  168. package/dist/jsx/Heading.native.js.map +1 -1
  169. package/dist/jsx/ProgressSteps/index.mjs.map +1 -1
  170. package/dist/jsx/ProgressSteps/index.native.js.map +1 -1
  171. package/dist/jsx/ScrollView/index.native.js +4 -0
  172. package/dist/jsx/ScrollView/index.native.js.map +1 -1
  173. package/dist/jsx/SortableList/SortableHandle.mjs +1 -1
  174. package/dist/jsx/SortableList/SortableHandle.native.js +1 -1
  175. package/dist/jsx/Text.mjs +58 -21
  176. package/dist/jsx/Text.mjs.map +1 -1
  177. package/dist/jsx/Text.native.js +62 -21
  178. package/dist/jsx/Text.native.js.map +1 -1
  179. package/dist/jsx/Toast/index.mjs.map +1 -1
  180. package/dist/jsx/Toast/index.native.js.map +1 -1
  181. package/dist/jsx/charts/ChartDataView.mjs +2 -1
  182. package/dist/jsx/charts/ChartDataView.mjs.map +1 -1
  183. package/dist/jsx/charts/ChartDataView.native.js +2 -1
  184. package/dist/jsx/charts/ChartDataView.native.js.map +1 -1
  185. package/dist/jsx/code/CodeBlock/index.mjs +1 -1
  186. package/dist/jsx/code/CodeBlock/index.native.js +8 -8
  187. package/dist/jsx/code/CodeBlock/index.native.js.map +1 -1
  188. package/dist/jsx/index.js +2 -2
  189. package/dist/jsx/index.js.map +1 -1
  190. package/dist/jsx/index.mjs +2 -2
  191. package/dist/jsx/index.mjs.map +1 -1
  192. package/dist/jsx/index.native.js +1 -0
  193. package/dist/jsx/index.native.js.map +1 -1
  194. package/dist/jsx/layouts/EmptyState.mjs +4 -3
  195. package/dist/jsx/layouts/EmptyState.mjs.map +1 -1
  196. package/dist/jsx/layouts/EmptyState.native.js +14 -9
  197. package/dist/jsx/layouts/EmptyState.native.js.map +1 -1
  198. package/dist/jsx/layouts/page.mjs +2 -1
  199. package/dist/jsx/layouts/page.mjs.map +1 -1
  200. package/dist/jsx/layouts/page.native.js +2 -1
  201. package/dist/jsx/layouts/page.native.js.map +1 -1
  202. package/dist/jsx/layouts/sidebar.mjs +2 -1
  203. package/dist/jsx/layouts/sidebar.mjs.map +1 -1
  204. package/dist/jsx/layouts/sidebar.native.js +2 -1
  205. package/dist/jsx/layouts/sidebar.native.js.map +1 -1
  206. package/dist/jsx/layouts/tags.mjs +2 -1
  207. package/dist/jsx/layouts/tags.mjs.map +1 -1
  208. package/dist/jsx/layouts/tags.native.js +2 -1
  209. package/dist/jsx/layouts/tags.native.js.map +1 -1
  210. package/dist/jsx/layouts/toolbar.mjs +2 -1
  211. package/dist/jsx/layouts/toolbar.mjs.map +1 -1
  212. package/dist/jsx/layouts/toolbar.native.js +2 -1
  213. package/dist/jsx/layouts/toolbar.native.js.map +1 -1
  214. package/dist/jsx/shared/useReturnFocusOnClose.mjs +1 -0
  215. package/dist/jsx/shared/useReturnFocusOnClose.mjs.map +1 -1
  216. package/dist/jsx/shared/useReturnFocusOnClose.native.js +1 -0
  217. package/dist/jsx/shared/useReturnFocusOnClose.native.js.map +1 -1
  218. package/dist/jsx/surfaces.mjs +63 -3
  219. package/dist/jsx/surfaces.mjs.map +1 -1
  220. package/dist/jsx/surfaces.native.js +73 -0
  221. package/dist/jsx/surfaces.native.js.map +1 -1
  222. package/dist/jsx/views/Calendar.mjs +12 -9
  223. package/dist/jsx/views/Calendar.mjs.map +1 -1
  224. package/dist/jsx/views/Calendar.native.js +13 -10
  225. package/dist/jsx/views/Calendar.native.js.map +1 -1
  226. package/dist/jsx/views/Dashboard.mjs +2 -1
  227. package/dist/jsx/views/Dashboard.mjs.map +1 -1
  228. package/dist/jsx/views/Dashboard.native.js +2 -1
  229. package/dist/jsx/views/Dashboard.native.js.map +1 -1
  230. package/dist/jsx/views/Gantt.mjs +37 -25
  231. package/dist/jsx/views/Gantt.mjs.map +1 -1
  232. package/dist/jsx/views/Gantt.native.js +38 -23
  233. package/dist/jsx/views/Gantt.native.js.map +1 -1
  234. package/dist/jsx/views/Kanban.mjs +2 -1
  235. package/dist/jsx/views/Kanban.mjs.map +1 -1
  236. package/dist/jsx/views/Kanban.native.js +1 -0
  237. package/dist/jsx/views/Kanban.native.js.map +1 -1
  238. package/dist/jsx/views/List.mjs +1 -0
  239. package/dist/jsx/views/List.mjs.map +1 -1
  240. package/dist/jsx/views/List.native.js +1 -0
  241. package/dist/jsx/views/List.native.js.map +1 -1
  242. package/dist/jsx/views/Matrix.mjs +3 -1
  243. package/dist/jsx/views/Matrix.mjs.map +1 -1
  244. package/dist/jsx/views/Matrix.native.js +5 -3
  245. package/dist/jsx/views/Matrix.native.js.map +1 -1
  246. package/dist/jsx/views/ReportBuilder.mjs +2 -1
  247. package/dist/jsx/views/ReportBuilder.mjs.map +1 -1
  248. package/dist/jsx/views/ReportBuilder.native.js +2 -1
  249. package/dist/jsx/views/ReportBuilder.native.js.map +1 -1
  250. package/dist/jsx/views/TreeView.mjs +2 -1
  251. package/dist/jsx/views/TreeView.mjs.map +1 -1
  252. package/dist/jsx/views/TreeView.native.js +2 -1
  253. package/dist/jsx/views/TreeView.native.js.map +1 -1
  254. package/package.json +9 -9
  255. package/src/Accordion/index.tsx +34 -15
  256. package/src/Heading.stories.tsx +13 -0
  257. package/src/Heading.tsx +62 -21
  258. package/src/ProgressSteps/ProgressSteps.spec.tsx +2 -3
  259. package/src/ProgressSteps/index.tsx +3 -4
  260. package/src/ScrollView/ScrollView.native.spec.tsx +97 -1
  261. package/src/ScrollView/index.native.tsx +4 -0
  262. package/src/SortableList/SortableHandle.tsx +2 -2
  263. package/src/Tabs/Tabs.lifecycle.spec.tsx +3 -1
  264. package/src/Text.tsx +32 -20
  265. package/src/Toast/index.tsx +4 -3
  266. package/src/charts/ChartDataView.tsx +2 -1
  267. package/src/code/CodeBlock/index.tsx +1 -1
  268. package/src/feedback/ConfirmDialog.spec.tsx +2 -2
  269. package/src/icons/phosphorTokenColor.spec.tsx +86 -0
  270. package/src/index.ts +1 -1
  271. package/src/layouts/EmptyState.spec.tsx +17 -0
  272. package/src/layouts/EmptyState.tsx +10 -3
  273. package/src/layouts/Screen.keyboardTaps.spec.tsx +57 -0
  274. package/src/layouts/Screen.stories.tsx +55 -2
  275. package/src/layouts/page.tsx +2 -14
  276. package/src/layouts/sidebar.tsx +2 -1
  277. package/src/layouts/tags.tsx +1 -1
  278. package/src/layouts/toolbar.tsx +2 -1
  279. package/src/shared/useReturnFocusOnClose.spec.tsx +39 -0
  280. package/src/shared/useReturnFocusOnClose.ts +4 -0
  281. package/src/surfaces.spec.tsx +272 -3
  282. package/src/surfaces.tsx +97 -4
  283. package/src/textWeight.houseFonts.spec.tsx +127 -0
  284. package/src/textWeight.native.spec.tsx +27 -0
  285. package/src/textWeight.spec.tsx +15 -0
  286. package/src/views/Calendar.tsx +37 -29
  287. package/src/views/Dashboard.tsx +2 -13
  288. package/src/views/Gantt.scroll.spec.tsx +4 -1
  289. package/src/views/Gantt.tsx +40 -25
  290. package/src/views/Kanban.native.tsx +1 -0
  291. package/src/views/Kanban.tsx +2 -10
  292. package/src/views/List.tsx +1 -0
  293. package/src/views/Matrix.tsx +3 -1
  294. package/src/views/ReportBuilder.tsx +2 -1
  295. package/src/views/TreeView.tsx +2 -1
  296. package/src/views/views.spec.tsx +14 -2
  297. package/types/Accordion/index.d.ts +8 -0
  298. package/types/Accordion/index.d.ts.map +1 -1
  299. package/types/Chip/index.d.ts +1 -1
  300. package/types/Heading.d.ts +7 -7
  301. package/types/Heading.d.ts.map +1 -1
  302. package/types/ProgressSteps/index.d.ts.map +1 -1
  303. package/types/ScrollView/index.native.d.ts.map +1 -1
  304. package/types/SortableList/SortableHandle.d.ts +1 -1
  305. package/types/Tabs/index.d.ts +1 -1
  306. package/types/Text.d.ts +4 -4
  307. package/types/Text.d.ts.map +1 -1
  308. package/types/Toast/index.d.ts.map +1 -1
  309. package/types/charts/ChartDataView.d.ts.map +1 -1
  310. package/types/index.d.ts +1 -1
  311. package/types/index.d.ts.map +1 -1
  312. package/types/layouts/EmptyState.d.ts.map +1 -1
  313. package/types/layouts/page.d.ts.map +1 -1
  314. package/types/layouts/sidebar.d.ts.map +1 -1
  315. package/types/layouts/tags.d.ts.map +1 -1
  316. package/types/layouts/toolbar.d.ts.map +1 -1
  317. package/types/shared/useReturnFocusOnClose.d.ts.map +1 -1
  318. package/types/surfaces.d.ts +14 -0
  319. package/types/surfaces.d.ts.map +1 -1
  320. package/types/views/Calendar.d.ts +18 -1
  321. package/types/views/Calendar.d.ts.map +1 -1
  322. package/types/views/Dashboard.d.ts.map +1 -1
  323. package/types/views/Gantt.d.ts.map +1 -1
  324. package/types/views/Kanban.d.ts.map +1 -1
  325. package/types/views/List.d.ts.map +1 -1
  326. package/types/views/Matrix.d.ts.map +1 -1
  327. package/types/views/ReportBuilder.d.ts.map +1 -1
  328. package/types/views/TreeView.d.ts.map +1 -1
@@ -62,6 +62,12 @@ export function EmptyState({
62
62
  // Polaris/Primer: icon | copy | actions are three bands; title+description
63
63
  // sit tighter than the bands. Compact (table/column) stays on the stepped gap.
64
64
  const frameGap = compact ? knobProps.gap : knobProps.gapLg;
65
+ // One paragraph per line: a web paragraph folds a newline into a space, and
66
+ // a multi-line server reason (LC-96) then reads as one run-on sentence.
67
+ const descriptionLines = (description ?? "")
68
+ .split("\n")
69
+ .map((line) => line.trim())
70
+ .filter(Boolean);
65
71
 
66
72
  if (isError) {
67
73
  warnBannedErrorWords(title, { component: "EmptyState" });
@@ -145,8 +151,9 @@ export function EmptyState({
145
151
  {well && isError ? <Intent name="error">{well}</Intent> : well}
146
152
  <YStack alignItems="center" gap="$1" {...readingWidthStyle()}>
147
153
  {titleNode}
148
- {description ? (
154
+ {descriptionLines.map((line, index) => (
149
155
  <Paragraph
156
+ key={`${index}:${line}`}
150
157
  {...knobProps.body}
151
158
  color={knobProps.textAccentColor as any}
152
159
  fontSize={knobProps.label.fontSize}
@@ -154,9 +161,9 @@ export function EmptyState({
154
161
  margin={0}
155
162
  width="100%"
156
163
  >
157
- {description}
164
+ {line}
158
165
  </Paragraph>
159
- ) : null}
166
+ ))}
160
167
  </YStack>
161
168
  {action ? (
162
169
  <XStack
@@ -0,0 +1,57 @@
1
+ /**
2
+ * MPO-457: a native sheet is an RN Modal, so the Screen's ScrollView is a
3
+ * React ancestor of every row in it. At RN's default keyboardShouldPersistTaps
4
+ * ("never") that ScrollView claims the first tap in the sheet while the
5
+ * keyboard is up and spends it on dismissing the keyboard. The house
6
+ * ScrollView carries "handled" on native (ScrollView.native.spec.tsx), so
7
+ * Screen must scroll through it.
8
+ */
9
+ import { cleanup } from "@testing-library/react";
10
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
11
+ import { afterEach, expect, it, vi } from "vitest";
12
+ import { Screen } from "./page";
13
+
14
+ const houseScrollViews = vi.hoisted(() => [] as Array<Record<string, unknown>>);
15
+
16
+ vi.mock("../ScrollView", async (importOriginal) => {
17
+ const { forwardRef, createElement } = await import("react");
18
+ const { ScrollView } = await importOriginal<typeof import("../ScrollView")>();
19
+ return {
20
+ ScrollView: forwardRef<unknown, Record<string, unknown>>(function HouseScrollProbe(props, ref) {
21
+ houseScrollViews.push(props);
22
+ return createElement(ScrollView as never, { ...props, ref });
23
+ }),
24
+ };
25
+ });
26
+
27
+ afterEach(() => {
28
+ cleanup();
29
+ houseScrollViews.length = 0;
30
+ });
31
+
32
+ it("scrolls through the house ScrollView, which defaults keyboard taps to handled on native", () => {
33
+ renderWithProviders(
34
+ <Screen>
35
+ <span>Page body</span>
36
+ </Screen>,
37
+ );
38
+ expect(houseScrollViews.length).toBeGreaterThan(0);
39
+ });
40
+
41
+ it("still hands scrollViewProps to that ScrollView, so a screen can opt back out", () => {
42
+ renderWithProviders(
43
+ <Screen scrollViewProps={{ keyboardShouldPersistTaps: "never" }}>
44
+ <span>Page body</span>
45
+ </Screen>,
46
+ );
47
+ expect(houseScrollViews.at(-1)?.keyboardShouldPersistTaps).toBe("never");
48
+ });
49
+
50
+ it("renders no ScrollView when scroll is off", () => {
51
+ renderWithProviders(
52
+ <Screen scroll={false}>
53
+ <span>Page body</span>
54
+ </Screen>,
55
+ );
56
+ expect(houseScrollViews).toEqual([]);
57
+ });
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
2
2
  // Catalog Button (G-15 story honesty / LC-18, UX-P06).
3
- import { Button } from "@multiplatform.one/forms";
4
- import { Paragraph, YStack } from "tamagui";
3
+ import { Button, Input } from "@multiplatform.one/forms";
4
+ import { useState } from "react";
5
+ import { Paragraph, SizableText, YStack } from "tamagui";
6
+ import { SheetModal } from "../SheetModal";
5
7
  import { CONTENT_MAX_WIDTH, EmptyState, PageHeader, PageSection, Screen } from "./page";
6
8
 
7
9
  const meta: Meta<typeof Screen> = {
@@ -106,3 +108,54 @@ export const NestedScreens: Story = {
106
108
  </Screen>
107
109
  ),
108
110
  };
111
+
112
+ const PROJECTS = ["Alpha", "Beta", "Gamma", "Delta"] as const;
113
+
114
+ function SheetOverKeyboardHarness(args: Story["args"]) {
115
+ const [open, setOpen] = useState(false);
116
+ const [picks, setPicks] = useState<string[]>([]);
117
+ const readout = `Picks: ${picks.length}${picks.length ? `, last ${picks[picks.length - 1]}` : ""}`;
118
+ return (
119
+ <Screen {...args}>
120
+ <PageHeader
121
+ title="Sheet over the keyboard"
122
+ subtitle="The Screen scrolls at its defaults and the sheet opens over it"
123
+ />
124
+ <Paragraph testID="screen-plain-text">
125
+ With a field focused, a tap on this text dismisses the keyboard.
126
+ </Paragraph>
127
+ <Input label="Screen field" placeholder="Focus to raise the keyboard" />
128
+ <Button onPress={() => setOpen(true)}>Open sheet</Button>
129
+ <Paragraph testID="screen-picks">{readout}</Paragraph>
130
+ <SheetModal
131
+ open={open}
132
+ onOpenChange={setOpen}
133
+ header={<SizableText fontWeight="600">Pick a project</SizableText>}
134
+ >
135
+ <YStack gap="$2">
136
+ <Input label="Search" placeholder="Focus to raise the keyboard" />
137
+ <Paragraph testID="sheet-picks">{readout}</Paragraph>
138
+ {PROJECTS.map((name) => (
139
+ <Button key={name} onPress={() => setPicks((previous) => [...previous, name])}>
140
+ {name}
141
+ </Button>
142
+ ))}
143
+ </YStack>
144
+ </SheetModal>
145
+ </Screen>
146
+ );
147
+ }
148
+
149
+ export const SheetOverKeyboard: Story = {
150
+ name: "Sheet over the keyboard",
151
+ parameters: {
152
+ nativeGallery: { scroll: false },
153
+ docs: {
154
+ description: {
155
+ story:
156
+ "MPO-457: the sheet is an RN Modal, so the Screen's ScrollView is a React ancestor of every row in it. Focus Search, then tap a project: the first tap picks it while the keyboard stays up.",
157
+ },
158
+ },
159
+ },
160
+ render: (args) => <SheetOverKeyboardHarness {...args} />,
161
+ };
@@ -28,21 +28,9 @@ import {
28
28
  } from "@multiplatform.one/theme";
29
29
  import type { ReactNode } from "react";
30
30
  import type { ScrollViewProps as TamaguiScrollViewProps, XStackProps, YStackProps } from "tamagui";
31
- import {
32
- H1,
33
- H2,
34
- H3,
35
- H4,
36
- H5,
37
- H6,
38
- Paragraph,
39
- ScrollView,
40
- Separator,
41
- XStack,
42
- YStack,
43
- isWeb,
44
- } from "tamagui";
31
+ import { H1, H2, H3, H4, H5, H6, Paragraph, Separator, XStack, YStack, isWeb } from "tamagui";
45
32
  import { componentColors, sectionHeading } from "../componentColors";
33
+ import { ScrollView } from "../ScrollView";
46
34
  import {
47
35
  PageHeadingScope,
48
36
  usePageChromeInset,
@@ -12,7 +12,8 @@ import {
12
12
  } from "@multiplatform.one/theme";
13
13
  import type React from "react";
14
14
  import { Fragment, useId, useState } from "react";
15
- import { Label, ScrollView, Separator, Text, XStack, YStack, isWeb } from "tamagui";
15
+ import { Label, Separator, Text, XStack, YStack, isWeb } from "tamagui";
16
+ import { ScrollView } from "../ScrollView";
16
17
  import { componentColors } from "../componentColors";
17
18
 
18
19
  export interface SidebarSection {
@@ -14,7 +14,6 @@ import {
14
14
  Input,
15
15
  Label,
16
16
  Popover,
17
- ScrollView,
18
17
  Text,
19
18
  View,
20
19
  XStack,
@@ -24,6 +23,7 @@ import {
24
23
  type InputProps,
25
24
  type YStackProps,
26
25
  } from "tamagui";
26
+ import { ScrollView } from "../ScrollView";
27
27
  import {
28
28
  Chip,
29
29
  ChipFrame,
@@ -10,7 +10,8 @@ import {
10
10
  import type React from "react";
11
11
  import { useTranslation } from "../shared/i18n";
12
12
  import { Button } from "@multiplatform.one/forms";
13
- import { Group, ScrollView, Separator, Text, XStack, isWeb } from "tamagui";
13
+ import { Group, Separator, Text, XStack, isWeb } from "tamagui";
14
+ import { ScrollView } from "../ScrollView";
14
15
 
15
16
  export interface ToolbarAction {
16
17
  label: string;
@@ -0,0 +1,39 @@
1
+ import { act, cleanup, renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { useReturnFocusOnClose } from "./useReturnFocusOnClose";
4
+
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals();
7
+ cleanup();
8
+ vi.useRealTimers();
9
+ document.body.replaceChildren();
10
+ });
11
+
12
+ describe("useReturnFocusOnClose", () => {
13
+ function setup() {
14
+ vi.useFakeTimers();
15
+ const trigger = document.createElement("button");
16
+ document.body.append(trigger);
17
+ trigger.focus();
18
+ const hook = renderHook(({ open }) => useReturnFocusOnClose(open), {
19
+ initialProps: { open: true },
20
+ });
21
+ return { ...hook, trigger };
22
+ }
23
+
24
+ it("restores dropped focus after the close animation", async () => {
25
+ const h = setup();
26
+ h.trigger.blur();
27
+ h.rerender({ open: false });
28
+ await act(() => vi.advanceTimersByTimeAsync(400));
29
+ expect(document.activeElement).toBe(h.trigger);
30
+ });
31
+
32
+ it("leaves the delayed restore inert once the document is gone", () => {
33
+ const h = setup();
34
+ h.trigger.blur();
35
+ h.rerender({ open: false });
36
+ vi.stubGlobal("document", undefined);
37
+ expect(() => vi.advanceTimersByTime(400)).not.toThrow();
38
+ });
39
+ });
@@ -39,6 +39,10 @@ export function useReturnFocusOnClose(open: boolean) {
39
39
  if (!canUseDom() || !open) return;
40
40
  return () => {
41
41
  const restore = () => {
42
+ // Nothing cancels this after unmount, so the document can be gone by
43
+ // now (a test environment torn down inside the retry window), and
44
+ // `isConnected` stays true on the detached node.
45
+ if (!canUseDom()) return;
42
46
  const target = prevRef.current;
43
47
  if (!target || !target.isConnected) return;
44
48
  if (focusIsDropped()) {
@@ -1,7 +1,7 @@
1
- import { renderWithProviders } from "@multiplatform.one/test-utils";
1
+ import { modelFirstFocusableShow, renderWithProviders } from "@multiplatform.one/test-utils";
2
2
  import { Preset, type Knobs } from "@multiplatform.one/theme";
3
3
  import { defaultConfig } from "@tamagui/config/v5";
4
- import { cleanup, render } from "@testing-library/react";
4
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -14,7 +14,8 @@ import {
14
14
  Sheet,
15
15
  TamaguiProvider,
16
16
  } from "tamagui";
17
- import { afterEach, describe, expect, it } from "vitest";
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+ import { Input } from "@multiplatform.one/forms";
18
19
  import { Button } from "./Button";
19
20
  import * as pkg from "./index";
20
21
  import {
@@ -22,6 +23,7 @@ import {
22
23
  CardFooter,
23
24
  CardHeader,
24
25
  DialogContent,
26
+ DialogShowFocus,
25
27
  AlertDialogContent,
26
28
  AlertDialogOverlay,
27
29
  KnobCard,
@@ -280,3 +282,270 @@ describe("overlay containers take the Axiom 1 child-clip cap (MPO-150)", () => {
280
282
  }
281
283
  }
282
284
  });
285
+
286
+ // MPO-452: tamagui's FocusScope checks "focus already inside?" before an idle
287
+ // wait of up to 200 ms, then focuses the first tabbable that does not hold
288
+ // focus. A click into the name field during that wait had focus moved on to
289
+ // the Save button, so every keystroke after it missed the field.
290
+ describe("PopoverContent open focus (MPO-452)", () => {
291
+ function SaveAsPopover() {
292
+ return (
293
+ <Popover>
294
+ <Popover.Trigger asChild>
295
+ <button type="button">Save As</button>
296
+ </Popover.Trigger>
297
+ <PopoverContent>
298
+ <input aria-label="Report name" />
299
+ <button type="button">Save</button>
300
+ </PopoverContent>
301
+ </Popover>
302
+ );
303
+ }
304
+
305
+ it("keeps focus on a field focused right after open", async () => {
306
+ renderWithProviders(<SaveAsPopover />);
307
+ fireEvent.click(screen.getByRole("button", { name: "Save As" }));
308
+ const field = await waitFor(() => screen.getByLabelText("Report name"));
309
+ field.focus();
310
+ expect(document.activeElement).toBe(field);
311
+ await new Promise((resolve) => setTimeout(resolve, 300));
312
+ expect(document.activeElement).toBe(field);
313
+ });
314
+
315
+ it("focuses the first tabbable on open", async () => {
316
+ renderWithProviders(<SaveAsPopover />);
317
+ fireEvent.click(screen.getByRole("button", { name: "Save As" }));
318
+ const field = await waitFor(() => screen.getByLabelText("Report name"));
319
+ await waitFor(() => expect(document.activeElement).toBe(field));
320
+ });
321
+ });
322
+
323
+ // MPO-466: DialogContent mounts the same FocusScope. In browsers its native
324
+ // <dialog>.show() focuses inside first; jsdom has no show(), so the deferred
325
+ // pass ran and moved focus off a field focused right after open.
326
+ describe("DialogContent open focus (MPO-466)", () => {
327
+ function RenameDialog({ onOpenAutoFocus }: { onOpenAutoFocus?: (event: Event) => void }) {
328
+ return (
329
+ <Dialog modal>
330
+ <Dialog.Trigger asChild>
331
+ <button type="button">Rename</button>
332
+ </Dialog.Trigger>
333
+ <Dialog.Portal>
334
+ <DialogContent onOpenAutoFocus={onOpenAutoFocus}>
335
+ <input aria-label="Name" />
336
+ <input aria-label="Notes" />
337
+ <button type="button">Save</button>
338
+ </DialogContent>
339
+ </Dialog.Portal>
340
+ </Dialog>
341
+ );
342
+ }
343
+
344
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 300));
345
+
346
+ it.each(["Name", "Notes"])(
347
+ "keeps focus on the %s field focused right after open",
348
+ async (label) => {
349
+ renderWithProviders(<RenameDialog />);
350
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
351
+ const field = await waitFor(() => screen.getByLabelText(label));
352
+ field.focus();
353
+ expect(document.activeElement).toBe(field);
354
+ await settle();
355
+ expect(document.activeElement).toBe(field);
356
+ },
357
+ );
358
+
359
+ it("focuses the first tabbable on open", async () => {
360
+ renderWithProviders(<RenameDialog />);
361
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
362
+ const field = await waitFor(() => screen.getByLabelText("Name"));
363
+ await waitFor(() => expect(document.activeElement).toBe(field));
364
+ });
365
+
366
+ it("runs the caller's onOpenAutoFocus first and lets it opt out", async () => {
367
+ const seen: boolean[] = [];
368
+ renderWithProviders(
369
+ <RenameDialog
370
+ onOpenAutoFocus={(event) => {
371
+ seen.push(event.defaultPrevented);
372
+ event.preventDefault();
373
+ }}
374
+ />,
375
+ );
376
+ const trigger = screen.getByRole("button", { name: "Rename" });
377
+ trigger.focus();
378
+ fireEvent.click(trigger);
379
+ await waitFor(() => screen.getByLabelText("Name"));
380
+ await settle();
381
+ expect(seen).toEqual([false]);
382
+ expect(document.activeElement).toBe(trigger);
383
+ });
384
+ });
385
+
386
+ // AlertDialogContent needs no house handler: tamagui's AlertDialog.Content
387
+ // prevents the FocusScope open pass and focuses its Cancel action in the same
388
+ // step, so there is no deferred focus to race.
389
+ describe("AlertDialogContent open focus (MPO-466)", () => {
390
+ function RenameAlertDialog() {
391
+ return (
392
+ <AlertDialog>
393
+ <AlertDialog.Trigger asChild>
394
+ <button type="button">Rename</button>
395
+ </AlertDialog.Trigger>
396
+ <AlertDialog.Portal>
397
+ <AlertDialogContent>
398
+ <input aria-label="Name" />
399
+ <AlertDialog.Cancel asChild>
400
+ <button type="button">Cancel</button>
401
+ </AlertDialog.Cancel>
402
+ <button type="button">Save</button>
403
+ </AlertDialogContent>
404
+ </AlertDialog.Portal>
405
+ </AlertDialog>
406
+ );
407
+ }
408
+
409
+ it("focuses its Cancel action on open", async () => {
410
+ renderWithProviders(<RenameAlertDialog />);
411
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
412
+ const cancel = await waitFor(() => screen.getByText("Cancel"));
413
+ await waitFor(() => expect(document.activeElement).toBe(cancel));
414
+ });
415
+
416
+ it("keeps focus on a field focused right after open", async () => {
417
+ renderWithProviders(<RenameAlertDialog />);
418
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
419
+ const field = await waitFor(() => screen.getByLabelText("Name"));
420
+ field.focus();
421
+ await new Promise((resolve) => setTimeout(resolve, 300));
422
+ expect(document.activeElement).toBe(field);
423
+ });
424
+ });
425
+
426
+ // MPO-470: in Chromium and WebKit, <dialog>.show() focuses the first focusable
427
+ // descendant, a tabindex=-1 frame included, where Firefox and the HTML spec
428
+ // take the first tab stop. The house Input's group frame is tabindex=-1, so it
429
+ // took focus, FocusScope then saw focus inside and never ran the open focus,
430
+ // and typing went nowhere. jsdom has no show(), so modelFirstFocusableShow
431
+ // stands in for Chromium's.
432
+
433
+ describe("DialogContent open focus behind <dialog>.show() (MPO-470)", () => {
434
+ let restoreShow = () => {};
435
+ beforeEach(() => {
436
+ restoreShow = modelFirstFocusableShow();
437
+ });
438
+ afterEach(() => restoreShow());
439
+
440
+ function NameDialog({
441
+ helpFirst,
442
+ onOpenAutoFocus,
443
+ }: {
444
+ helpFirst?: boolean;
445
+ onOpenAutoFocus?: (event: Event) => void;
446
+ }) {
447
+ return (
448
+ <Dialog modal>
449
+ <Dialog.Trigger asChild>
450
+ <button type="button">Rename</button>
451
+ </Dialog.Trigger>
452
+ <Dialog.Portal>
453
+ <DialogContent onOpenAutoFocus={onOpenAutoFocus}>
454
+ {helpFirst ? <button type="button">Help</button> : null}
455
+ <Input inputProps={{ "aria-label": "Name" }} />
456
+ <button type="button">Save</button>
457
+ </DialogContent>
458
+ </Dialog.Portal>
459
+ </Dialog>
460
+ );
461
+ }
462
+
463
+ it("moves focus off the house Input's group frame onto its field", async () => {
464
+ renderWithProviders(<NameDialog />);
465
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
466
+ const field = await waitFor(() => screen.getByLabelText("Name"));
467
+ expect(field.closest("[tabindex='-1']")).not.toBeNull();
468
+ await waitFor(() => expect(document.activeElement).toBe(field));
469
+ });
470
+
471
+ it("runs the caller's onOpenAutoFocus, which can pick another target", async () => {
472
+ renderWithProviders(
473
+ <NameDialog
474
+ onOpenAutoFocus={(event) => {
475
+ event.preventDefault();
476
+ screen.getByRole("button", { name: "Save" }).focus();
477
+ }}
478
+ />,
479
+ );
480
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
481
+ const save = await waitFor(() => screen.getByRole("button", { name: "Save" }));
482
+ await waitFor(() => expect(document.activeElement).toBe(save));
483
+ });
484
+
485
+ it("leaves focus on a tab stop the browser picked", async () => {
486
+ renderWithProviders(<NameDialog helpFirst />);
487
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
488
+ const help = await waitFor(() => screen.getByRole("button", { name: "Help" }));
489
+ await new Promise((resolve) => setTimeout(resolve, 300));
490
+ expect(document.activeElement).toBe(help);
491
+ });
492
+ });
493
+
494
+ describe("AlertDialogContent open focus behind <dialog>.show() (MPO-470)", () => {
495
+ let restoreShow = () => {};
496
+ beforeEach(() => {
497
+ restoreShow = modelFirstFocusableShow();
498
+ });
499
+ afterEach(() => restoreShow());
500
+
501
+ it("moves focus off the house Input's group frame onto its field", async () => {
502
+ renderWithProviders(
503
+ <AlertDialog>
504
+ <AlertDialog.Trigger asChild>
505
+ <button type="button">Rename</button>
506
+ </AlertDialog.Trigger>
507
+ <AlertDialog.Portal>
508
+ <AlertDialogContent>
509
+ <Input inputProps={{ "aria-label": "Name" }} />
510
+ <AlertDialog.Cancel asChild>
511
+ <button type="button">Cancel</button>
512
+ </AlertDialog.Cancel>
513
+ </AlertDialogContent>
514
+ </AlertDialog.Portal>
515
+ </AlertDialog>,
516
+ );
517
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
518
+ const field = await waitFor(() => screen.getByLabelText("Name"));
519
+ expect(field.closest("[tabindex='-1']")).not.toBeNull();
520
+ await waitFor(() => expect(document.activeElement).toBe(field));
521
+ });
522
+ });
523
+
524
+ describe("DialogShowFocus in a raw Dialog.Content (MPO-470)", () => {
525
+ let restoreShow = () => {};
526
+ beforeEach(() => {
527
+ restoreShow = modelFirstFocusableShow();
528
+ });
529
+ afterEach(() => restoreShow());
530
+
531
+ it("moves focus off the house Input's group frame onto its field", async () => {
532
+ renderWithProviders(
533
+ <Dialog modal>
534
+ <Dialog.Trigger asChild>
535
+ <button type="button">Save Filter</button>
536
+ </Dialog.Trigger>
537
+ <Dialog.Portal>
538
+ <Dialog.Content>
539
+ <Input inputProps={{ "aria-label": "Filter Name" }} />
540
+ <button type="button">Save</button>
541
+ <DialogShowFocus />
542
+ </Dialog.Content>
543
+ </Dialog.Portal>
544
+ </Dialog>,
545
+ );
546
+ fireEvent.click(screen.getByRole("button", { name: "Save Filter" }));
547
+ const field = await waitFor(() => screen.getByLabelText("Filter Name"));
548
+ expect(field.closest("[tabindex='-1']")).not.toBeNull();
549
+ await waitFor(() => expect(document.activeElement).toBe(field));
550
+ });
551
+ });