@astryxdesign/core 0.4.3 → 0.4.4-canary.0e8af82

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 (254) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +75 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +396 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +63 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +441 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +109 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +206 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +185 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +1038 -0
  30. package/dist/Calendar/Calendar.d.ts +17 -0
  31. package/dist/Calendar/Calendar.d.ts.map +1 -1
  32. package/dist/Calendar/Calendar.js +25 -1
  33. package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
  34. package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
  35. package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
  36. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  37. package/dist/Chat/ChatComposerInput.js +3 -4
  38. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  39. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  40. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  41. package/dist/ComplexSelector/ComplexSelector.js +18 -7
  42. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  43. package/dist/ContextMenu/ContextMenu.js +4 -2
  44. package/dist/DateInput/DateInput.d.ts.map +1 -1
  45. package/dist/DateInput/DateInput.js +15 -2
  46. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  47. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  48. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  49. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  50. package/dist/DateTimeInput/DateTimeInput.js +38 -3
  51. package/dist/Dialog/Dialog.d.ts.map +1 -1
  52. package/dist/Dialog/Dialog.js +2 -1
  53. package/dist/Field/FieldLabel.d.ts +2 -1
  54. package/dist/Field/FieldLabel.d.ts.map +1 -1
  55. package/dist/Field/FieldLabel.js +21 -3
  56. package/dist/Field/InputClearButton.d.ts +2 -1
  57. package/dist/Field/InputClearButton.d.ts.map +1 -1
  58. package/dist/Field/InputClearButton.js +6 -1
  59. package/dist/FormLayout/FormLayout.d.ts +24 -2
  60. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  61. package/dist/FormLayout/FormLayout.js +7 -2
  62. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  63. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  64. package/dist/FormLayout/FormLayoutContext.js +19 -4
  65. package/dist/FormLayout/index.d.ts +1 -1
  66. package/dist/FormLayout/index.d.ts.map +1 -1
  67. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  68. package/dist/Lightbox/Lightbox.js +9 -3
  69. package/dist/MobileNav/MobileNav.d.ts +12 -0
  70. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  71. package/dist/MobileNav/MobileNav.js +102 -30
  72. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  73. package/dist/MultiSelector/MultiSelector.js +27 -8
  74. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  75. package/dist/NumberInput/NumberInput.js +15 -2
  76. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  77. package/dist/RadioList/RadioList.d.ts.map +1 -1
  78. package/dist/RadioList/RadioList.js +12 -1
  79. package/dist/Selector/Selector.d.ts.map +1 -1
  80. package/dist/Selector/Selector.js +20 -4
  81. package/dist/StatusDot/StatusDot.d.ts +43 -3
  82. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  83. package/dist/StatusDot/StatusDot.js +42 -5
  84. package/dist/Switch/Switch.d.ts.map +1 -1
  85. package/dist/Switch/Switch.js +14 -1
  86. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  87. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  88. package/dist/TextArea/TextArea.d.ts.map +1 -1
  89. package/dist/TextArea/TextArea.js +6 -1
  90. package/dist/TextInput/TextInput.d.ts.map +1 -1
  91. package/dist/TextInput/TextInput.js +6 -1
  92. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  93. package/dist/TimeInput/TimeInput.js +14 -2
  94. package/dist/Tokenizer/Tokenizer.js +11 -5
  95. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  96. package/dist/Tooltip/useTooltip.js +4 -1
  97. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  98. package/dist/Typeahead/BaseTypeahead.js +5 -3
  99. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  100. package/dist/Typeahead/Typeahead.js +10 -1
  101. package/dist/astryx.css +21 -0
  102. package/dist/hooks/index.d.ts +8 -1
  103. package/dist/hooks/index.d.ts.map +1 -1
  104. package/dist/hooks/index.js +8 -1
  105. package/dist/hooks/useFocusTrap.d.ts +0 -11
  106. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  107. package/dist/hooks/useFocusTrap.js +1 -11
  108. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  109. package/dist/hooks/useInputStatusIcon.js +1 -0
  110. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  111. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  112. package/dist/hooks/useResolvedRequired.js +40 -0
  113. package/dist/index.d.ts +1 -0
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +1 -0
  116. package/dist/theme/defineTheme.d.ts +22 -4
  117. package/dist/theme/defineTheme.d.ts.map +1 -1
  118. package/dist/theme/expandColorScale.d.ts +17 -2
  119. package/dist/theme/expandColorScale.d.ts.map +1 -1
  120. package/dist/theme/expandColorScale.js +63 -33
  121. package/dist/utils/ime.d.ts +5 -0
  122. package/dist/utils/ime.d.ts.map +1 -0
  123. package/dist/utils/ime.js +63 -0
  124. package/dist/utils/index.d.ts +1 -0
  125. package/dist/utils/index.d.ts.map +1 -1
  126. package/dist/utils/index.js +2 -1
  127. package/dist/utils/plainDate.d.ts +6 -0
  128. package/dist/utils/plainDate.d.ts.map +1 -1
  129. package/dist/utils/plainDate.js +12 -0
  130. package/locales/af-ZA.json +958 -0
  131. package/locales/ar-SA.json +998 -0
  132. package/locales/ca-ES.json +974 -0
  133. package/locales/cs-CZ.json +990 -0
  134. package/locales/da-DK.json +966 -0
  135. package/locales/de-DE.json +966 -0
  136. package/locales/el-GR.json +990 -0
  137. package/locales/en.json +52 -0
  138. package/locales/es-ES.json +978 -0
  139. package/locales/fi-FI.json +990 -0
  140. package/locales/fr-FR.json +928 -0
  141. package/locales/he-IL.json +990 -0
  142. package/locales/hu-HU.json +990 -0
  143. package/locales/it-IT.json +978 -0
  144. package/locales/ja-JP.json +998 -0
  145. package/locales/ko-KR.json +990 -0
  146. package/locales/nl-NL.json +954 -0
  147. package/locales/no-NO.json +978 -0
  148. package/locales/pl-PL.json +982 -0
  149. package/locales/pseudo.json +39 -0
  150. package/locales/pt-BR.json +982 -0
  151. package/locales/pt-PT.json +982 -0
  152. package/locales/ro-RO.json +982 -0
  153. package/locales/ru-RU.json +990 -0
  154. package/locales/sr-SP.json +990 -0
  155. package/locales/sv-SE.json +986 -0
  156. package/locales/tr-TR.json +990 -0
  157. package/locales/uk-UA.json +990 -0
  158. package/locales/vi-VN.json +986 -0
  159. package/locales/zh-CN.json +998 -0
  160. package/locales/zh-TW.json +998 -0
  161. package/package.json +9 -8
  162. package/src/Banner/Banner.doc.mjs +3 -3
  163. package/src/Banner/Banner.test.tsx +40 -0
  164. package/src/Banner/Banner.tsx +27 -2
  165. package/src/BottomSheet/BottomSheet.doc.mjs +299 -0
  166. package/src/BottomSheet/BottomSheet.test.tsx +2265 -0
  167. package/src/BottomSheet/BottomSheet.tsx +559 -0
  168. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  169. package/src/BottomSheet/BottomSheetPanel.tsx +601 -0
  170. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  171. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  172. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  173. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  174. package/src/BottomSheet/index.ts +19 -0
  175. package/src/BottomSheet/snapOffsets.test.ts +254 -0
  176. package/src/BottomSheet/snapOffsets.ts +245 -0
  177. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  178. package/src/BottomSheet/useSheetGestures.test.ts +1112 -0
  179. package/src/BottomSheet/useSheetGestures.ts +1503 -0
  180. package/src/Calendar/Calendar.doc.mjs +16 -0
  181. package/src/Calendar/Calendar.test.tsx +96 -0
  182. package/src/Calendar/Calendar.tsx +55 -0
  183. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  184. package/src/Chat/ChatComposerInput.tsx +3 -4
  185. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  186. package/src/ComplexSelector/ComplexSelector.tsx +15 -6
  187. package/src/ContextMenu/ContextMenu.tsx +4 -2
  188. package/src/DateInput/DateInput.test.tsx +21 -0
  189. package/src/DateInput/DateInput.tsx +12 -2
  190. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  191. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  192. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  193. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  194. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  195. package/src/DateTimeInput/DateTimeInput.tsx +43 -4
  196. package/src/Dialog/Dialog.tsx +2 -1
  197. package/src/Field/Field.doc.mjs +1 -0
  198. package/src/Field/FieldLabel.tsx +20 -4
  199. package/src/Field/InputClearButton.test.tsx +25 -0
  200. package/src/Field/InputClearButton.tsx +4 -1
  201. package/src/FileInput/FileInput.test.tsx +53 -9
  202. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  203. package/src/FormLayout/FormLayout.test.tsx +181 -4
  204. package/src/FormLayout/FormLayout.tsx +33 -2
  205. package/src/FormLayout/FormLayoutContext.ts +22 -7
  206. package/src/FormLayout/index.ts +1 -1
  207. package/src/Lightbox/Lightbox.test.tsx +49 -28
  208. package/src/Lightbox/Lightbox.tsx +7 -3
  209. package/src/MobileNav/MobileNav.tsx +128 -37
  210. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  211. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  212. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  213. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  214. package/src/MultiSelector/MultiSelector.tsx +25 -9
  215. package/src/NumberInput/NumberInput.test.tsx +18 -0
  216. package/src/NumberInput/NumberInput.tsx +12 -2
  217. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  218. package/src/RadioList/RadioList.tsx +9 -1
  219. package/src/Selector/Selector.test.tsx +85 -26
  220. package/src/Selector/Selector.tsx +16 -5
  221. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  222. package/src/StatusDot/StatusDot.test.tsx +115 -8
  223. package/src/StatusDot/StatusDot.tsx +74 -9
  224. package/src/Switch/Switch.test.tsx +16 -0
  225. package/src/Switch/Switch.tsx +13 -0
  226. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  227. package/src/TextArea/TextArea.tsx +3 -1
  228. package/src/TextInput/TextInput.tsx +3 -1
  229. package/src/TimeInput/TimeInput.test.tsx +23 -0
  230. package/src/TimeInput/TimeInput.tsx +11 -1
  231. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  232. package/src/Tokenizer/Tokenizer.tsx +5 -5
  233. package/src/Tooltip/useTooltip.tsx +4 -1
  234. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  235. package/src/Typeahead/Typeahead.test.tsx +116 -7
  236. package/src/Typeahead/Typeahead.tsx +10 -1
  237. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  238. package/src/__tests__/stubMatchMedia.ts +95 -0
  239. package/src/hooks/index.ts +9 -5
  240. package/src/hooks/useFocusTrap.ts +1 -14
  241. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  242. package/src/hooks/useInputStatusIcon.tsx +7 -0
  243. package/src/hooks/useResolvedRequired.ts +42 -0
  244. package/src/index.ts +1 -0
  245. package/src/theme/defineTheme.ts +22 -4
  246. package/src/theme/expandColorScale.test.ts +138 -0
  247. package/src/theme/expandColorScale.ts +89 -44
  248. package/src/utils/ime.test.ts +32 -0
  249. package/src/utils/ime.ts +69 -0
  250. package/src/utils/index.ts +2 -0
  251. package/src/utils/plainDate.test.ts +50 -0
  252. package/src/utils/plainDate.ts +12 -0
  253. package/dist/astryx.umd.js +0 -147
  254. package/dist/astryx.umd.js.map +0 -7
@@ -0,0 +1,206 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file snapOffsets.ts
5
+ * @input Pure geometry helpers — no React, no DOM.
6
+ * @output Resolves snap points and converts them to translateY offsets.
7
+ * @position Internal to BottomSheet; consumed by useSheetGestures and
8
+ * BottomSheetPanel, tested by snapOffsets.test.ts.
9
+ *
10
+ * A detent is represented as an offset in px from the fully-open position
11
+ * (0 = fully open, larger = more collapsed). The panel may render that offset
12
+ * as a translate or as a reduction in layout height. These
13
+ * helpers turn snap points into the shared offset list; they are pure so the
14
+ * geometry can be unit-tested without a DOM.
15
+ */
16
+
17
+ /**
18
+ * A height the sheet can rest at, expressed as the sheet's VISIBLE height:
19
+ *
20
+ * - a number in `(0, 1]` — a fraction of the viewport (`0.5` is half the
21
+ * screen), so the stop tracks rotation and window resizes;
22
+ * - a `'<n>%'` string — the same thing spelled in CSS;
23
+ * - a `'<n>px'` string — an absolute height that does not scale.
24
+ *
25
+ * Anything else — a unitless string, `calc()`, `vh`, `rem`, a number outside
26
+ * `(0, 1]` — is ignored with a dev warning. Percentages resolve against the
27
+ * layout viewport (`window.innerHeight`), the same box the height budgets are
28
+ * written against, so the mobile keyboard never moves a stop.
29
+ */
30
+
31
+ /**
32
+ * Detents whose resting offsets land within this many px of each other are
33
+ * treated as the same stop (the taller one wins). Stops the sheet from having
34
+ * two near-identical rest positions — e.g. when a content-hugging height sits
35
+ * a hair away from a fractional snap point.
36
+ */
37
+ export const DETENT_DEDUP_PX = 48;
38
+
39
+ /**
40
+ * Largest share of the fully open sheet the shortest stop may fill and still
41
+ * be a peek. Above it the stop is a working surface — it lays its content out
42
+ * and keeps a full scrim — so a two-stop sheet like `[0.5]` behaves like the
43
+ * half-height panel it asks for rather than like a glance.
44
+ */
45
+ export const PEEK_MAX_HEIGHT_RATIO = 0.25;
46
+
47
+ // A snap point string: a positive number with a `px` or `%` unit. Deliberately
48
+ // narrow — every accepted unit has to be resolvable by arithmetic on the
49
+ // viewport height, because this runs inside the drag loop. Units that need the
50
+ // DOM to resolve (`rem`, `em`, `calc()`) would cost a layout per frame.
51
+ const SNAP_POINT_PATTERN = /^(\d+(?:\.\d+)?|\.\d+)(px|%)$/i;
52
+
53
+ /**
54
+ * Resolve one snap point to a visible height in px, or `null` when it is not a
55
+ * snap point this sheet can honor.
56
+ */
57
+ function parseSnapPoint(point, viewportPx) {
58
+ if (typeof point === 'number') {
59
+ // A bare number is a viewport fraction. Anything above 1 is a px value in
60
+ // disguise; the caller warns rather than guessing which was meant.
61
+ return Number.isFinite(point) && point > 0 && point <= 1 ? point * viewportPx : null;
62
+ }
63
+ const match = SNAP_POINT_PATTERN.exec(point.trim());
64
+ if (match === null) {
65
+ return null;
66
+ }
67
+ const value = Number.parseFloat(match[1]);
68
+ if (!(value > 0)) {
69
+ return null;
70
+ }
71
+ return match[2].toLowerCase() === '%' ? value / 100 * viewportPx : value;
72
+ }
73
+
74
+ /**
75
+ * Whether a snap point can be resolved at all. The viewport only scales the
76
+ * result, so validity is independent of it.
77
+ */
78
+ export function isValidSnapPoint(point) {
79
+ return parseSnapPoint(point, 1) !== null;
80
+ }
81
+
82
+ /**
83
+ * Resolve snap points to candidate visible heights in px, dropping the ones
84
+ * this sheet cannot honor. Callers warn about those separately — this stays
85
+ * silent because it runs on every drag frame.
86
+ */
87
+ export function resolveSnapPoints(points, viewportPx) {
88
+ const heights = [];
89
+ for (const point of points) {
90
+ const height = parseSnapPoint(point, viewportPx);
91
+ if (height !== null) {
92
+ heights.push(height);
93
+ }
94
+ }
95
+ return heights;
96
+ }
97
+
98
+ /**
99
+ * Given the sheet's full height (px, as rendered fully open) and a set of
100
+ * candidate detent *visible heights* (px), return the resting offsets from
101
+ * fully-open, ascending and de-duplicated.
102
+ *
103
+ * - `0` (fully open) is always the first detent.
104
+ * - Only heights strictly shorter than the sheet become collapsed detents; a
105
+ * height >= the sheet is already covered by the fully-open stop.
106
+ * - Offsets closer than `dedupPx` collapse to the smaller (taller) offset, so
107
+ * near-identical detents — e.g. a hug height ≈ a snap point — become one stop.
108
+ */
109
+ export function computeDetentOffsets(sheetHeight, detentHeights, dedupPx = DETENT_DEDUP_PX) {
110
+ const collapsed = detentHeights.filter(h => h > 0 && h < sheetHeight).map(h => sheetHeight - h);
111
+ const ascending = [0, ...collapsed].sort((a, b) => a - b);
112
+ const deduped = [];
113
+ for (const offset of ascending) {
114
+ const last = deduped[deduped.length - 1];
115
+ if (last === undefined || offset - last >= dedupPx) {
116
+ deduped.push(offset);
117
+ }
118
+ }
119
+ return deduped;
120
+ }
121
+
122
+ /** Nearest value in `offsets` to `value` (offsets must be non-empty). */
123
+ export function nearestOffset(value, offsets) {
124
+ return offsets.reduce((best, o) => Math.abs(o - value) < Math.abs(best - value) ? o : best, offsets[0]);
125
+ }
126
+
127
+ /**
128
+ * Minimum scrim opacity at the peek detent. The scrim thins to a glance state
129
+ * but never fully vanishes, because a modal sheet keeps the background inert —
130
+ * a fully clear backdrop would read as "interactive" when it isn't. (For a
131
+ * genuinely interactive, undimmed peek, use a non-modal sheet, `hasScrim=false`.)
132
+ */
133
+ export const MIN_PEEK_SCRIM_OPACITY = 0.3;
134
+
135
+ /**
136
+ * The peek detent's offset, or `null` when this sheet has no peek.
137
+ *
138
+ * A peek is the shortest stop AND a sliver — at most `PEEK_MAX_HEIGHT_RATIO`
139
+ * of the fully open sheet. It is the one detent the sheet does NOT express as
140
+ * layout height: at a sliver there is nothing useful to lay out, and reflowing
141
+ * the content into it (then back out on the way up) is churn the user sees, so
142
+ * it keeps the full layout height and slides below the viewport instead. It is
143
+ * also the only stop that thins the scrim.
144
+ *
145
+ * A sheet whose shortest stop is a working height — `[0.5]`, say — has no
146
+ * peek: half a screen of content deserves to be laid out at half a screen, and
147
+ * a scrim that thinned there would read as a dismissed sheet.
148
+ */
149
+ export function peekOffsetFor(offsets, visibleSheetHeight) {
150
+ if (offsets.length < 2 || visibleSheetHeight <= 0) {
151
+ return null;
152
+ }
153
+ const shortest = offsets[offsets.length - 1];
154
+ const heightAtShortest = visibleSheetHeight - shortest;
155
+ return heightAtShortest <= PEEK_MAX_HEIGHT_RATIO * visibleSheetHeight ? shortest : null;
156
+ }
157
+
158
+ /**
159
+ * Scrim opacity (1 = fully visible) for a drag/settle `offset`.
160
+ *
161
+ * The scrim is full down to the shortest stop that is still a working surface,
162
+ * and only thins past it — onto a peek, where it holds at
163
+ * `MIN_PEEK_SCRIM_OPACITY` (a glance state, and the sheet is still modal), or
164
+ * out through the dismiss overshoot toward `dismissOffset`, where it clears
165
+ * completely because the sheet is leaving.
166
+ *
167
+ * So a sheet whose stops are all working heights keeps a full scrim at every
168
+ * one of them; only the sliver of a peek, or a sheet on its way out, dims less.
169
+ */
170
+ export function scrimOpacityForOffset(offset, offsets, dismissOffset, peekOffset) {
171
+ const hasPeek = peekOffset !== null;
172
+ // The last stop that is still a working surface — the peek's neighbor when
173
+ // there is a peek, otherwise the shortest stop itself.
174
+ const fadeStart = hasPeek ? offsets[offsets.length - 2] : offsets[offsets.length - 1];
175
+ const fadeEnd = hasPeek ? peekOffset : dismissOffset;
176
+ const floor = hasPeek ? MIN_PEEK_SCRIM_OPACITY : 0;
177
+ if (offset <= fadeStart) {
178
+ return 1;
179
+ }
180
+ if (offset >= fadeEnd) {
181
+ return floor;
182
+ }
183
+ return 1 - (1 - floor) * ((offset - fadeStart) / (fadeEnd - fadeStart));
184
+ }
185
+
186
+ /**
187
+ * Settle target for a released drag. Restricts candidates to the drag
188
+ * direction so a committed drag never snaps *back past* where it started
189
+ * (a down-drag settles at/below the start, an up-drag at/above), then picks
190
+ * the nearest remaining detent. `dir`: 1 = dragged down, -1 = up, 0 = neither.
191
+ */
192
+ export function resolveSettleOffset(value, offsets, dir, baseOffset) {
193
+ let candidates = offsets;
194
+ if (dir > 0) {
195
+ const downward = offsets.filter(o => o >= baseOffset);
196
+ if (downward.length > 0) {
197
+ candidates = downward;
198
+ }
199
+ } else if (dir < 0) {
200
+ const upward = offsets.filter(o => o <= baseOffset);
201
+ if (upward.length > 0) {
202
+ candidates = upward;
203
+ }
204
+ }
205
+ return nearestOffset(value, candidates);
206
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @file useMobileKeyboard.ts
3
+ * @input Uses React effects and refs supplied by BottomSheet
4
+ * @output Exports internal useMobileKeyboard hook
5
+ * @position Internal to BottomSheet; not exported from the core entry point
6
+ *
7
+ * Gives a fully expanded, explicitly Tall sheet a keyboard-aware internal
8
+ * scroll range while leaving the sheet itself stationary. Shorter detents and
9
+ * other heights opt out entirely. Starting Tall-sheet travel or closing the
10
+ * sheet blurs the field and dismisses the keyboard.
11
+ *
12
+ * Every route into a field also has to reach it without the browser revealing
13
+ * it for us. On iOS that reveal scrolls the DOCUMENT, and a fixed sheet travels
14
+ * with it, so the whole page lurches. The reveal is attached to the focus
15
+ * operation, which is where it can be refused: take the focus transition over
16
+ * on the capture-phase blur, deliver it with preventScroll, and bring the
17
+ * control into view with the sheet's own scroller afterwards. Every route in —
18
+ * a tap, the keyboard's Next, Tab, a programmatic focus() — passes through that
19
+ * one transition.
20
+ */
21
+ import { type RefObject } from 'react';
22
+ interface UseMobileKeyboardOptions {
23
+ bodyRef: RefObject<HTMLElement | null>;
24
+ bottomClearance: number;
25
+ isEnabled: boolean;
26
+ isFullyExpanded: boolean;
27
+ isPageScrollLocked: boolean;
28
+ isSheetTraveling: boolean;
29
+ isOpen: boolean;
30
+ isPresented: boolean;
31
+ sheetRef: RefObject<HTMLDivElement | null>;
32
+ }
33
+ export declare function useMobileKeyboard({ bodyRef, bottomClearance, isEnabled, isFullyExpanded, isPageScrollLocked, isSheetTraveling, isOpen, isPresented, sheetRef, }: UseMobileKeyboardOptions): void;
34
+ export {};
35
+ //# sourceMappingURL=useMobileKeyboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMobileKeyboard.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/useMobileKeyboard.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAoB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAgBxD,UAAU,wBAAwB;IAChC,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACvC,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CAC5C;AA8DD,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,eAAe,EACf,SAAS,EACT,eAAe,EACf,kBAAkB,EAClB,gBAAgB,EAChB,MAAM,EACN,WAAW,EACX,QAAQ,GACT,EAAE,wBAAwB,GAAG,IAAI,CAuXjC"}
@@ -0,0 +1,373 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useMobileKeyboard.ts
7
+ * @input Uses React effects and refs supplied by BottomSheet
8
+ * @output Exports internal useMobileKeyboard hook
9
+ * @position Internal to BottomSheet; not exported from the core entry point
10
+ *
11
+ * Gives a fully expanded, explicitly Tall sheet a keyboard-aware internal
12
+ * scroll range while leaving the sheet itself stationary. Shorter detents and
13
+ * other heights opt out entirely. Starting Tall-sheet travel or closing the
14
+ * sheet blurs the field and dismisses the keyboard.
15
+ *
16
+ * Every route into a field also has to reach it without the browser revealing
17
+ * it for us. On iOS that reveal scrolls the DOCUMENT, and a fixed sheet travels
18
+ * with it, so the whole page lurches. The reveal is attached to the focus
19
+ * operation, which is where it can be refused: take the focus transition over
20
+ * on the capture-phase blur, deliver it with preventScroll, and bring the
21
+ * control into view with the sheet's own scroller afterwards. Every route in —
22
+ * a tap, the keyboard's Next, Tab, a programmatic focus() — passes through that
23
+ * one transition.
24
+ */
25
+ import { useEffect, useRef } from 'react';
26
+ const MOBILE_KEYBOARD_INSET_VAR = '--_sheet-keyboard-inset';
27
+ const NON_TEXT_INPUT_TYPES = new Set(['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']);
28
+ function getObstructionTop() {
29
+ return window.visualViewport?.height ?? window.innerHeight;
30
+ }
31
+
32
+ // The keyboard is gone once the visible band reaches the layout viewport bottom.
33
+ function isVisualViewportRecovered() {
34
+ return getObstructionTop() >= window.innerHeight - 0.5;
35
+ }
36
+ function isIOSWebKit() {
37
+ const userAgent = window.navigator.userAgent;
38
+ return /iPad|iPhone|iPod/.test(userAgent) || userAgent.includes('Macintosh') && window.navigator.maxTouchPoints > 1;
39
+ }
40
+ function isTextEntryControl(element) {
41
+ if (element instanceof HTMLTextAreaElement) {
42
+ return !element.disabled && !element.readOnly;
43
+ }
44
+ if (element instanceof HTMLInputElement) {
45
+ return !element.disabled && !element.readOnly && !NON_TEXT_INPUT_TYPES.has((element.getAttribute('type') ?? 'text').toLowerCase());
46
+ }
47
+ return element instanceof HTMLElement && element.matches('[contenteditable]:not([contenteditable="false"])');
48
+ }
49
+ function findTextEntryControl(target, body) {
50
+ if (!(target instanceof Element)) {
51
+ return null;
52
+ }
53
+ const direct = target.closest('input, textarea, [contenteditable]:not([contenteditable="false"])');
54
+ if (body.contains(direct) && isTextEntryControl(direct)) {
55
+ return direct;
56
+ }
57
+ const label = target.closest('label');
58
+ const control = label instanceof HTMLLabelElement ? label.control : null;
59
+ return body.contains(control) && isTextEntryControl(control) ? control : null;
60
+ }
61
+ export function useMobileKeyboard({
62
+ bodyRef,
63
+ bottomClearance,
64
+ isEnabled,
65
+ isFullyExpanded,
66
+ isPageScrollLocked,
67
+ isSheetTraveling,
68
+ isOpen,
69
+ isPresented,
70
+ sheetRef
71
+ }) {
72
+ const hasKeyboardLayoutRef = useRef(false);
73
+ const retainKeyboardLayoutRef = useRef(false);
74
+ const isFullyExpandedRef = useRef(isFullyExpanded);
75
+ const isOpenRef = useRef(isOpen);
76
+ isFullyExpandedRef.current = isFullyExpanded;
77
+ isOpenRef.current = isOpen;
78
+ useEffect(() => {
79
+ if (!isEnabled || !isSheetTraveling) {
80
+ return;
81
+ }
82
+ const sheet = sheetRef.current;
83
+ const activeElement = document.activeElement;
84
+ if (sheet && activeElement instanceof HTMLElement && activeElement !== sheet && sheet.contains(activeElement)) {
85
+ // Keep the current keyboard scroll range while focusing the sheet
86
+ // dismisses the keyboard. Viewport resize events unwind that layout as
87
+ // the visual viewport recovers, avoiding a content jump on the first
88
+ // drag frame.
89
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
90
+ sheet.focus({
91
+ preventScroll: true
92
+ });
93
+ }
94
+ }, [isEnabled, isSheetTraveling, sheetRef]);
95
+ useEffect(() => {
96
+ if (!isEnabled || !isPresented || isOpen) {
97
+ return;
98
+ }
99
+ const sheet = sheetRef.current;
100
+ const activeElement = document.activeElement;
101
+ if (sheet && activeElement instanceof HTMLElement && activeElement !== sheet && sheet.contains(activeElement)) {
102
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
103
+ activeElement.blur();
104
+ }
105
+ }, [isEnabled, isOpen, isPresented, sheetRef]);
106
+ useEffect(() => {
107
+ const body = bodyRef.current;
108
+ const sheet = sheetRef.current;
109
+ if (!isEnabled || !isPresented || !body) {
110
+ return;
111
+ }
112
+ let keyboardGeometry = null;
113
+ let documentScrollAtKeyboard = null;
114
+ const ownsFocusTransitions = isIOSWebKit();
115
+ const clearKeyboardLayout = () => {
116
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, '0px');
117
+ keyboardGeometry = null;
118
+ documentScrollAtKeyboard = null;
119
+ hasKeyboardLayoutRef.current = false;
120
+ retainKeyboardLayoutRef.current = false;
121
+ };
122
+ const scrollBodyBy = (distance, smoothly) => {
123
+ if (typeof body.scrollBy !== 'function') {
124
+ body.scrollTop += distance;
125
+ return;
126
+ }
127
+ const reduceMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
128
+ body.scrollBy({
129
+ top: distance,
130
+ // Not 'auto': that defers to the element's computed `scroll-behavior`,
131
+ // so a consumer's `scroll-behavior: smooth` would animate a scroll this
132
+ // hook needs to land in the same frame.
133
+ behavior: smoothly && !reduceMotion ? 'smooth' : 'instant'
134
+ });
135
+ };
136
+
137
+ // Scroll `control` into the part of the body the keyboard leaves visible,
138
+ // reading live geometry each time.
139
+ const scrollControlIntoSafeArea = (control, smoothly) => {
140
+ const measuredBodyRect = body.getBoundingClientRect();
141
+ const measuredControlRect = control.getBoundingClientRect();
142
+ const obstructionTop = getObstructionTop();
143
+ const overlap = Math.max(0, measuredBodyRect.bottom - obstructionTop);
144
+ const clearance = overlap > 0 ? bottomClearance : 0;
145
+ const safeTop = measuredBodyRect.top;
146
+ const safeBottom = Math.min(measuredBodyRect.bottom, obstructionTop - clearance);
147
+ if (safeBottom <= safeTop) {
148
+ return;
149
+ }
150
+ if (measuredControlRect.bottom > safeBottom) {
151
+ scrollBodyBy(measuredControlRect.bottom - safeBottom, smoothly);
152
+ } else if (measuredControlRect.top < safeTop) {
153
+ scrollBodyBy(measuredControlRect.top - safeTop, smoothly);
154
+ }
155
+ };
156
+ const applyKeyboardGeometry = geometry => {
157
+ const obstructionTop = getObstructionTop();
158
+ // A collapsed detent can extend the body below the layout viewport even
159
+ // after the keyboard closes, so body overlap alone cannot identify
160
+ // recovery. Once the visual viewport is full height again, release the
161
+ // retained keyboard layout unconditionally.
162
+ if (isVisualViewportRecovered()) {
163
+ clearKeyboardLayout();
164
+ return;
165
+ }
166
+ const overlap = Math.max(0, geometry.bodyBottom - obstructionTop);
167
+ if (overlap === 0) {
168
+ clearKeyboardLayout();
169
+ return;
170
+ }
171
+ const inset = Math.max(0, geometry.bodyBottom - (obstructionTop - bottomClearance));
172
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
173
+ };
174
+
175
+ // Take the focus transition over before the browser performs it.
176
+ //
177
+ // `blur` is dispatched in the capture phase ahead of the browser's own
178
+ // focus step, and it names the destination in `relatedTarget`. Focusing it
179
+ // here with preventScroll settles the transition: the browser's step finds
180
+ // the element already active, so it dispatches nothing further and has
181
+ // nothing to reveal.
182
+ const claimFocusTransition = event => {
183
+ if (!isFullyExpandedRef.current) {
184
+ return;
185
+ }
186
+ const destination = findTextEntryControl(event.relatedTarget, body);
187
+ if (destination) {
188
+ if (destination !== document.activeElement) {
189
+ // Revealing the field is not this handler's job: focusing it raises
190
+ // focusin, and the viewport resize that follows the keyboard raises
191
+ // another — both already schedule the reveal below, which knows the
192
+ // safe area and scrolls only the sheet's own body.
193
+ destination.focus({
194
+ preventScroll: true
195
+ });
196
+ }
197
+ return;
198
+ }
199
+
200
+ // Focus left for nothing — the keyboard's Done button parks it on the
201
+ // body. Park it on the sheet instead, so re-tapping the same field is
202
+ // still a transition this handler sees. Left on the body, the field is
203
+ // already `document.activeElement` on the next tap, no blur fires, and
204
+ // the browser reveals it its own way.
205
+ //
206
+ // Only while the sheet is open: closing blurs the field too, and there
207
+ // is no next tap to keep claimable — the host restores focus to whatever
208
+ // opened the sheet.
209
+ const origin = findTextEntryControl(event.target, body);
210
+ if (isOpenRef.current && origin && !event.relatedTarget) {
211
+ sheet?.focus({
212
+ preventScroll: true
213
+ });
214
+ }
215
+ };
216
+ const revealFocusedControl = () => {
217
+ const activeElement = document.activeElement;
218
+ if (!isFullyExpandedRef.current || !(activeElement instanceof HTMLElement) || !body.contains(activeElement) || !isTextEntryControl(activeElement)) {
219
+ if (retainKeyboardLayoutRef.current && keyboardGeometry) {
220
+ applyKeyboardGeometry(keyboardGeometry);
221
+ return;
222
+ }
223
+ clearKeyboardLayout();
224
+ return;
225
+ }
226
+ retainKeyboardLayoutRef.current = false;
227
+ const measuredBodyRect = body.getBoundingClientRect();
228
+ const obstructionTop = getObstructionTop();
229
+ const overlap = Math.max(0, measuredBodyRect.bottom - obstructionTop);
230
+ // The extra clearance leaves room for mobile suggestion UI, but only
231
+ // while the visual viewport actually overlaps the sheet. With no
232
+ // obstruction, ordinary desktop and hardware-keyboard focus must not
233
+ // shift an already visible control.
234
+ const clearance = overlap > 0 ? bottomClearance : 0;
235
+ keyboardGeometry = overlap > 0 ? {
236
+ bodyBottom: measuredBodyRect.bottom
237
+ } : null;
238
+ // Where the document sits with the keyboard up and nothing yet shifted:
239
+ // the position handleDocumentScroll returns to. Captured on the
240
+ // transition only — a reveal that runs after the browser has already
241
+ // scrolled would otherwise record the shifted position as correct.
242
+ if (isPageScrollLocked && overlap > 0 && !hasKeyboardLayoutRef.current) {
243
+ documentScrollAtKeyboard = {
244
+ x: window.scrollX,
245
+ y: window.scrollY
246
+ };
247
+ }
248
+ hasKeyboardLayoutRef.current = overlap > 0;
249
+ const inset = overlap > 0 ? Math.max(0, measuredBodyRect.bottom - (obstructionTop - clearance)) : 0;
250
+ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`);
251
+ scrollControlIntoSafeArea(activeElement, overlap > 0);
252
+ };
253
+ let animationFrame = 0;
254
+ const scheduleReveal = () => {
255
+ cancelAnimationFrame(animationFrame);
256
+ animationFrame = requestAnimationFrame(revealFocusedControl);
257
+ };
258
+
259
+ // Resuming the app re-reveals the focused field with no focus event to
260
+ // claim, so the reveal above never runs and the document scrolls — taking
261
+ // the fixed sheet with it. Put that scroll back on the event that reports
262
+ // it and re-reveal inside the sheet.
263
+ //
264
+ // Only while the page is locked, which is the only state in which a
265
+ // document scroll cannot be the user's own: behind a non-modal sheet the
266
+ // page stays scrollable, and reverting there would fight them.
267
+ const handleDocumentScroll = () => {
268
+ const expected = documentScrollAtKeyboard;
269
+ if (expected == null || window.scrollX === expected.x && window.scrollY === expected.y) {
270
+ return;
271
+ }
272
+ window.scrollTo(expected.x, expected.y);
273
+ const activeElement = document.activeElement;
274
+ if (activeElement instanceof HTMLElement && body.contains(activeElement) && isTextEntryControl(activeElement)) {
275
+ scrollControlIntoSafeArea(activeElement, false);
276
+ }
277
+ };
278
+ const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleReveal);
279
+ const refreshObservedLayout = () => {
280
+ if (!resizeObserver) {
281
+ return;
282
+ }
283
+ resizeObserver.disconnect();
284
+
285
+ // Body/sheet box changes include public height and xstyle updates. The
286
+ // generated inset pseudo-element changes only scroll overflow, so these
287
+ // observations do not feed the keyboard inset back into sheet geometry.
288
+ const targets = new Set([body]);
289
+ if (sheet) {
290
+ targets.add(sheet);
291
+ }
292
+ for (const child of body.children) {
293
+ targets.add(child);
294
+ }
295
+ const activeElement = document.activeElement;
296
+ if (activeElement instanceof HTMLElement && body.contains(activeElement)) {
297
+ for (let element = activeElement; element && element !== body; element = element.parentElement) {
298
+ targets.add(element);
299
+ }
300
+ }
301
+ for (const target of targets) {
302
+ resizeObserver.observe(target);
303
+ }
304
+ };
305
+ const mutationObserver = typeof MutationObserver === 'undefined' ? null : new MutationObserver(records => {
306
+ // Ignore the internal inset written on the body. Consumer DOM,
307
+ // text, class, style, or visibility changes can all move the
308
+ // focused control without a viewport event.
309
+ if (records.every(record => record.type === 'attributes' && record.target === body)) {
310
+ return;
311
+ }
312
+ refreshObservedLayout();
313
+ scheduleReveal();
314
+ });
315
+ const handleFocusIn = () => {
316
+ refreshObservedLayout();
317
+ scheduleReveal();
318
+ };
319
+ const handleFocusOut = () => {
320
+ // Keep the added scroll range while the keyboard animates away. A focus
321
+ // transition to another text-entry control cancels this in focusin.
322
+ retainKeyboardLayoutRef.current = hasKeyboardLayoutRef.current;
323
+ scheduleReveal();
324
+ };
325
+ const handleSheetTransitionEnd = event => {
326
+ if (event.target === sheet && event.propertyName === 'transform') {
327
+ scheduleReveal();
328
+ }
329
+ };
330
+ const viewport = window.visualViewport;
331
+ if (ownsFocusTransitions) {
332
+ // `blur` does not bubble, so the capture phase is the only place to hear
333
+ // every one of them — and it runs before the browser's own focus step,
334
+ // which is the only window in which the transition can still be claimed.
335
+ document.addEventListener('blur', claimFocusTransition, true);
336
+ }
337
+ body.addEventListener('focusin', handleFocusIn);
338
+ body.addEventListener('focusout', handleFocusOut);
339
+ sheet?.addEventListener('transitionend', handleSheetTransitionEnd);
340
+ viewport?.addEventListener('resize', scheduleReveal);
341
+ viewport?.addEventListener('scroll', scheduleReveal);
342
+ window.addEventListener('scroll', handleDocumentScroll);
343
+ window.addEventListener('resize', scheduleReveal);
344
+ mutationObserver?.observe(body, {
345
+ attributes: true,
346
+ characterData: true,
347
+ childList: true,
348
+ subtree: true
349
+ });
350
+ refreshObservedLayout();
351
+ // The dialog enters the top layer in a later effect. Wait until the next
352
+ // frame so flex geometry is final before measuring the body.
353
+ scheduleReveal();
354
+ return () => {
355
+ cancelAnimationFrame(animationFrame);
356
+ if (ownsFocusTransitions) {
357
+ document.removeEventListener('blur', claimFocusTransition, true);
358
+ }
359
+ body.removeEventListener('focusin', handleFocusIn);
360
+ body.removeEventListener('focusout', handleFocusOut);
361
+ sheet?.removeEventListener('transitionend', handleSheetTransitionEnd);
362
+ viewport?.removeEventListener('resize', scheduleReveal);
363
+ viewport?.removeEventListener('scroll', scheduleReveal);
364
+ window.removeEventListener('scroll', handleDocumentScroll);
365
+ window.removeEventListener('resize', scheduleReveal);
366
+ resizeObserver?.disconnect();
367
+ mutationObserver?.disconnect();
368
+ body.style.removeProperty(MOBILE_KEYBOARD_INSET_VAR);
369
+ hasKeyboardLayoutRef.current = false;
370
+ retainKeyboardLayoutRef.current = false;
371
+ };
372
+ }, [bodyRef, bottomClearance, isEnabled, isPageScrollLocked, isPresented, sheetRef]);
373
+ }