@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,254 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file snapOffsets.test.ts
5
+ * @input Uses vitest, snapOffsets pure helpers
6
+ * @output Unit tests for the snap-point -> translateY offset geometry
7
+ * @position Testing; validates snapOffsets.ts (no DOM needed)
8
+ *
9
+ * SYNC: When snapOffsets.ts changes, update these tests to match.
10
+ */
11
+
12
+ import {describe, it, expect} from 'vitest';
13
+ import {
14
+ computeDetentOffsets,
15
+ isValidSnapPoint,
16
+ nearestOffset,
17
+ peekOffsetFor,
18
+ resolveSettleOffset,
19
+ resolveSnapPoints,
20
+ scrimOpacityForOffset,
21
+ DETENT_DEDUP_PX,
22
+ } from './snapOffsets';
23
+
24
+ describe('resolveSnapPoints', () => {
25
+ it('reads a bare number as a fraction of the viewport', () => {
26
+ expect(resolveSnapPoints([0.5, 0.92], 800)).toEqual([400, 736]);
27
+ });
28
+
29
+ it('reads a percentage as the same fraction', () => {
30
+ expect(resolveSnapPoints(['50%', '92%'], 800)).toEqual([400, 736]);
31
+ });
32
+
33
+ it('reads a px length as an absolute height', () => {
34
+ expect(resolveSnapPoints(['320px'], 800)).toEqual([320]);
35
+ });
36
+
37
+ it('keeps the caller order, mixing forms', () => {
38
+ expect(resolveSnapPoints(['120px', 0.5, '90%'], 800)).toEqual([
39
+ 120, 400, 720,
40
+ ]);
41
+ });
42
+
43
+ it('accepts fractional and unit-cased lengths, and surrounding space', () => {
44
+ expect(resolveSnapPoints(['12.5%', '0.5PX', ' 80px '], 800)).toEqual([
45
+ 100, 0.5, 80,
46
+ ]);
47
+ });
48
+
49
+ it('drops numbers outside the (0, 1] fraction range', () => {
50
+ // 200 is the px mistake — a bare number is never a length.
51
+ expect(resolveSnapPoints([0, 0.5, 1, 1.2, -0.3, 200], 1000)).toEqual([
52
+ 500, 1000,
53
+ ]);
54
+ });
55
+
56
+ it('drops lengths it cannot resolve by arithmetic on the viewport', () => {
57
+ expect(
58
+ resolveSnapPoints(
59
+ ['50', '30rem', '50vh', 'calc(50% - 20px)', '-10px', '', '0px'],
60
+ 800,
61
+ ),
62
+ ).toEqual([]);
63
+ });
64
+ });
65
+
66
+ describe('isValidSnapPoint', () => {
67
+ it('accepts the three supported forms', () => {
68
+ expect(isValidSnapPoint(0.5)).toBe(true);
69
+ expect(isValidSnapPoint(1)).toBe(true);
70
+ expect(isValidSnapPoint('50%')).toBe(true);
71
+ expect(isValidSnapPoint('320px')).toBe(true);
72
+ });
73
+
74
+ it('rejects what resolveSnapPoints would silently drop', () => {
75
+ expect(isValidSnapPoint(200)).toBe(false);
76
+ expect(isValidSnapPoint(0)).toBe(false);
77
+ expect(isValidSnapPoint('50')).toBe(false);
78
+ expect(isValidSnapPoint('50vh')).toBe(false);
79
+ expect(isValidSnapPoint('calc(50% - 20px)')).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe('computeDetentOffsets', () => {
84
+ it('always includes fully-open (0) first, ascending', () => {
85
+ // sheet 800; detent visible heights 400, 640 -> offsets 400, 160 -> sorted
86
+ expect(computeDetentOffsets(800, [400, 640])).toEqual([0, 160, 400]);
87
+ });
88
+
89
+ it('drops detents at or taller than the sheet (cannot translate up)', () => {
90
+ // 800 and 900 are >= sheet 800 -> dropped; only 500 (offset 300) remains
91
+ expect(computeDetentOffsets(800, [500, 800, 900])).toEqual([0, 300]);
92
+ });
93
+
94
+ it('de-dupes near-equal detents, keeping the taller (smaller offset)', () => {
95
+ // sheet 800; heights 760 (offset 40) and 400 (offset 400). 40 is within
96
+ // DETENT_DEDUP_PX of the fully-open 0 -> collapses to just [0, 400].
97
+ expect(computeDetentOffsets(800, [760, 400])).toEqual([0, 400]);
98
+ });
99
+
100
+ it('keeps detents that are farther apart than the dedup threshold', () => {
101
+ // offsets 0, 100, 400 with default 48px dedup -> all kept
102
+ expect(computeDetentOffsets(800, [700, 400])).toEqual([0, 100, 400]);
103
+ });
104
+
105
+ it('respects a custom dedup threshold', () => {
106
+ // offsets 0, 100, 400; dedup 150 -> 100 collapses into 0
107
+ expect(computeDetentOffsets(800, [700, 400], 150)).toEqual([0, 400]);
108
+ });
109
+
110
+ it('a single-height sheet has just the fully-open detent', () => {
111
+ expect(computeDetentOffsets(600, [])).toEqual([0]);
112
+ });
113
+ });
114
+
115
+ describe('nearestOffset', () => {
116
+ it('picks the closest offset', () => {
117
+ expect(nearestOffset(180, [0, 200, 400])).toBe(200);
118
+ expect(nearestOffset(90, [0, 200, 400])).toBe(0);
119
+ });
120
+ });
121
+
122
+ describe('resolveSettleOffset', () => {
123
+ const offsets = [0, 200, 400]; // full, mid, short
124
+
125
+ it('a down-drag never settles above the starting detent', () => {
126
+ // start at mid (200), drag down to ~240 -> nearest at/below is 200 (mid),
127
+ // never back up to 0.
128
+ expect(resolveSettleOffset(240, offsets, 1, 200)).toBe(200);
129
+ });
130
+
131
+ it('a down-drag past the midpoint moves to the next lower detent', () => {
132
+ // start mid (200), drag to 320 -> nearest at/below is 400 (short)
133
+ expect(resolveSettleOffset(320, offsets, 1, 200)).toBe(400);
134
+ });
135
+
136
+ it('an up-drag never settles below the starting detent', () => {
137
+ // start mid (200), drag up to ~160 -> nearest at/above is 0 or 200; 160 is
138
+ // closer to 200, so it holds mid rather than dropping to short.
139
+ expect(resolveSettleOffset(160, offsets, -1, 200)).toBe(200);
140
+ });
141
+
142
+ it('an up-drag past the midpoint expands to the next higher detent', () => {
143
+ // start mid (200), drag up to 80 -> nearest at/above is 0 (full)
144
+ expect(resolveSettleOffset(80, offsets, -1, 200)).toBe(0);
145
+ });
146
+
147
+ it('with no direction, picks the plain nearest', () => {
148
+ expect(resolveSettleOffset(180, offsets, 0, 200)).toBe(200);
149
+ });
150
+ });
151
+
152
+ describe('peekOffsetFor', () => {
153
+ it('is the shortest stop when that stop is a sliver', () => {
154
+ // sheet 800; stops at 800, 400 and 100 visible px. The 100px stop is an
155
+ // eighth of the sheet, well inside the quarter that makes a peek.
156
+ expect(peekOffsetFor([0, 400, 700], 800)).toBe(700);
157
+ });
158
+
159
+ it('has no peek when the shortest stop is a working height', () => {
160
+ // A half-height stop lays its content out and keeps a full scrim; it is
161
+ // the sheet the caller asked for, not a glance at one.
162
+ expect(peekOffsetFor([0, 400], 800)).toBeNull();
163
+ });
164
+
165
+ it('has no peek when the sheet has no collapsed stop', () => {
166
+ expect(peekOffsetFor([0], 800)).toBeNull();
167
+ });
168
+
169
+ it('takes the quarter mark itself as a peek', () => {
170
+ expect(peekOffsetFor([0, 600], 800)).toBe(600);
171
+ });
172
+
173
+ it('has no peek without a measured sheet', () => {
174
+ expect(peekOffsetFor([0, 600], 0)).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe('scrimOpacityForOffset', () => {
179
+ describe('with a peek detent', () => {
180
+ const offsets = [0, 100, 200]; // full, mid, peek
181
+ const peekOffset = 200;
182
+ const dismissOffset = 280;
183
+
184
+ it('is full at or above the mid detent', () => {
185
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset, peekOffset)).toBe(
186
+ 1,
187
+ );
188
+ expect(
189
+ scrimOpacityForOffset(100, offsets, dismissOffset, peekOffset),
190
+ ).toBe(1);
191
+ expect(
192
+ scrimOpacityForOffset(60, offsets, dismissOffset, peekOffset),
193
+ ).toBe(1);
194
+ });
195
+
196
+ it('fades linearly from the mid detent toward the peek floor', () => {
197
+ // Halfway from mid (100) to peek (200), opacity is halfway from 1 to the
198
+ // 0.3 floor => 0.65.
199
+ expect(
200
+ scrimOpacityForOffset(150, offsets, dismissOffset, peekOffset),
201
+ ).toBeCloseTo(0.65);
202
+ });
203
+
204
+ it('thins to the peek floor at or below the peek detent (still modal)', () => {
205
+ expect(
206
+ scrimOpacityForOffset(200, offsets, dismissOffset, peekOffset),
207
+ ).toBeCloseTo(0.3);
208
+ expect(
209
+ scrimOpacityForOffset(240, offsets, dismissOffset, peekOffset),
210
+ ).toBeCloseTo(0.3);
211
+ });
212
+ });
213
+
214
+ describe('with collapsed stops but no peek among them', () => {
215
+ // The shortest stop is a working height, so the scrim is full there and
216
+ // only fades once the sheet is being dragged out below it.
217
+ const offsets = [0, 100];
218
+ const dismissOffset = 160;
219
+
220
+ it('stays full at every stop, including the shortest', () => {
221
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset, null)).toBe(1);
222
+ expect(scrimOpacityForOffset(100, offsets, dismissOffset, null)).toBe(1);
223
+ });
224
+
225
+ it('fades only across the dismiss overshoot below the shortest stop', () => {
226
+ expect(
227
+ scrimOpacityForOffset(130, offsets, dismissOffset, null),
228
+ ).toBeCloseTo(0.5);
229
+ expect(scrimOpacityForOffset(160, offsets, dismissOffset, null)).toBe(0);
230
+ });
231
+ });
232
+
233
+ describe('with a single detent (no peek)', () => {
234
+ const offsets = [0];
235
+ const dismissOffset = 160;
236
+
237
+ it('stays full until the drag begins collapsing', () => {
238
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset, null)).toBe(1);
239
+ });
240
+
241
+ it('fades across the dismiss overshoot toward the threshold', () => {
242
+ expect(
243
+ scrimOpacityForOffset(80, offsets, dismissOffset, null),
244
+ ).toBeCloseTo(0.5);
245
+ expect(scrimOpacityForOffset(160, offsets, dismissOffset, null)).toBe(0);
246
+ });
247
+ });
248
+ });
249
+
250
+ describe('DETENT_DEDUP_PX', () => {
251
+ it('is a sane positive threshold', () => {
252
+ expect(DETENT_DEDUP_PX).toBeGreaterThan(0);
253
+ });
254
+ });
@@ -0,0 +1,245 @@
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
+ export type BottomSheetSnapPoint = number | string;
31
+
32
+ /**
33
+ * Detents whose resting offsets land within this many px of each other are
34
+ * treated as the same stop (the taller one wins). Stops the sheet from having
35
+ * two near-identical rest positions — e.g. when a content-hugging height sits
36
+ * a hair away from a fractional snap point.
37
+ */
38
+ export const DETENT_DEDUP_PX = 48;
39
+
40
+ /**
41
+ * Largest share of the fully open sheet the shortest stop may fill and still
42
+ * be a peek. Above it the stop is a working surface — it lays its content out
43
+ * and keeps a full scrim — so a two-stop sheet like `[0.5]` behaves like the
44
+ * half-height panel it asks for rather than like a glance.
45
+ */
46
+ export const PEEK_MAX_HEIGHT_RATIO = 0.25;
47
+
48
+ // A snap point string: a positive number with a `px` or `%` unit. Deliberately
49
+ // narrow — every accepted unit has to be resolvable by arithmetic on the
50
+ // viewport height, because this runs inside the drag loop. Units that need the
51
+ // DOM to resolve (`rem`, `em`, `calc()`) would cost a layout per frame.
52
+ const SNAP_POINT_PATTERN = /^(\d+(?:\.\d+)?|\.\d+)(px|%)$/i;
53
+
54
+ /**
55
+ * Resolve one snap point to a visible height in px, or `null` when it is not a
56
+ * snap point this sheet can honor.
57
+ */
58
+ function parseSnapPoint(
59
+ point: BottomSheetSnapPoint,
60
+ viewportPx: number,
61
+ ): number | null {
62
+ if (typeof point === 'number') {
63
+ // A bare number is a viewport fraction. Anything above 1 is a px value in
64
+ // disguise; the caller warns rather than guessing which was meant.
65
+ return Number.isFinite(point) && point > 0 && point <= 1
66
+ ? point * viewportPx
67
+ : null;
68
+ }
69
+ const match = SNAP_POINT_PATTERN.exec(point.trim());
70
+ if (match === null) {
71
+ return null;
72
+ }
73
+ const value = Number.parseFloat(match[1]);
74
+ if (!(value > 0)) {
75
+ return null;
76
+ }
77
+ return match[2].toLowerCase() === '%' ? (value / 100) * viewportPx : value;
78
+ }
79
+
80
+ /**
81
+ * Whether a snap point can be resolved at all. The viewport only scales the
82
+ * result, so validity is independent of it.
83
+ */
84
+ export function isValidSnapPoint(point: BottomSheetSnapPoint): boolean {
85
+ return parseSnapPoint(point, 1) !== null;
86
+ }
87
+
88
+ /**
89
+ * Resolve snap points to candidate visible heights in px, dropping the ones
90
+ * this sheet cannot honor. Callers warn about those separately — this stays
91
+ * silent because it runs on every drag frame.
92
+ */
93
+ export function resolveSnapPoints(
94
+ points: ReadonlyArray<BottomSheetSnapPoint>,
95
+ viewportPx: number,
96
+ ): number[] {
97
+ const heights: number[] = [];
98
+ for (const point of points) {
99
+ const height = parseSnapPoint(point, viewportPx);
100
+ if (height !== null) {
101
+ heights.push(height);
102
+ }
103
+ }
104
+ return heights;
105
+ }
106
+
107
+ /**
108
+ * Given the sheet's full height (px, as rendered fully open) and a set of
109
+ * candidate detent *visible heights* (px), return the resting offsets from
110
+ * fully-open, ascending and de-duplicated.
111
+ *
112
+ * - `0` (fully open) is always the first detent.
113
+ * - Only heights strictly shorter than the sheet become collapsed detents; a
114
+ * height >= the sheet is already covered by the fully-open stop.
115
+ * - Offsets closer than `dedupPx` collapse to the smaller (taller) offset, so
116
+ * near-identical detents — e.g. a hug height ≈ a snap point — become one stop.
117
+ */
118
+ export function computeDetentOffsets(
119
+ sheetHeight: number,
120
+ detentHeights: ReadonlyArray<number>,
121
+ dedupPx: number = DETENT_DEDUP_PX,
122
+ ): number[] {
123
+ const collapsed = detentHeights
124
+ .filter(h => h > 0 && h < sheetHeight)
125
+ .map(h => sheetHeight - h);
126
+ const ascending = [0, ...collapsed].sort((a, b) => a - b);
127
+
128
+ const deduped: number[] = [];
129
+ for (const offset of ascending) {
130
+ const last = deduped[deduped.length - 1];
131
+ if (last === undefined || offset - last >= dedupPx) {
132
+ deduped.push(offset);
133
+ }
134
+ }
135
+ return deduped;
136
+ }
137
+
138
+ /** Nearest value in `offsets` to `value` (offsets must be non-empty). */
139
+ export function nearestOffset(
140
+ value: number,
141
+ offsets: ReadonlyArray<number>,
142
+ ): number {
143
+ return offsets.reduce(
144
+ (best, o) => (Math.abs(o - value) < Math.abs(best - value) ? o : best),
145
+ offsets[0],
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Minimum scrim opacity at the peek detent. The scrim thins to a glance state
151
+ * but never fully vanishes, because a modal sheet keeps the background inert —
152
+ * a fully clear backdrop would read as "interactive" when it isn't. (For a
153
+ * genuinely interactive, undimmed peek, use a non-modal sheet, `hasScrim=false`.)
154
+ */
155
+ export const MIN_PEEK_SCRIM_OPACITY = 0.3;
156
+
157
+ /**
158
+ * The peek detent's offset, or `null` when this sheet has no peek.
159
+ *
160
+ * A peek is the shortest stop AND a sliver — at most `PEEK_MAX_HEIGHT_RATIO`
161
+ * of the fully open sheet. It is the one detent the sheet does NOT express as
162
+ * layout height: at a sliver there is nothing useful to lay out, and reflowing
163
+ * the content into it (then back out on the way up) is churn the user sees, so
164
+ * it keeps the full layout height and slides below the viewport instead. It is
165
+ * also the only stop that thins the scrim.
166
+ *
167
+ * A sheet whose shortest stop is a working height — `[0.5]`, say — has no
168
+ * peek: half a screen of content deserves to be laid out at half a screen, and
169
+ * a scrim that thinned there would read as a dismissed sheet.
170
+ */
171
+ export function peekOffsetFor(
172
+ offsets: ReadonlyArray<number>,
173
+ visibleSheetHeight: number,
174
+ ): number | null {
175
+ if (offsets.length < 2 || visibleSheetHeight <= 0) {
176
+ return null;
177
+ }
178
+ const shortest = offsets[offsets.length - 1];
179
+ const heightAtShortest = visibleSheetHeight - shortest;
180
+ return heightAtShortest <= PEEK_MAX_HEIGHT_RATIO * visibleSheetHeight
181
+ ? shortest
182
+ : null;
183
+ }
184
+
185
+ /**
186
+ * Scrim opacity (1 = fully visible) for a drag/settle `offset`.
187
+ *
188
+ * The scrim is full down to the shortest stop that is still a working surface,
189
+ * and only thins past it — onto a peek, where it holds at
190
+ * `MIN_PEEK_SCRIM_OPACITY` (a glance state, and the sheet is still modal), or
191
+ * out through the dismiss overshoot toward `dismissOffset`, where it clears
192
+ * completely because the sheet is leaving.
193
+ *
194
+ * So a sheet whose stops are all working heights keeps a full scrim at every
195
+ * one of them; only the sliver of a peek, or a sheet on its way out, dims less.
196
+ */
197
+ export function scrimOpacityForOffset(
198
+ offset: number,
199
+ offsets: ReadonlyArray<number>,
200
+ dismissOffset: number,
201
+ peekOffset: number | null,
202
+ ): number {
203
+ const hasPeek = peekOffset !== null;
204
+ // The last stop that is still a working surface — the peek's neighbor when
205
+ // there is a peek, otherwise the shortest stop itself.
206
+ const fadeStart = hasPeek
207
+ ? offsets[offsets.length - 2]
208
+ : offsets[offsets.length - 1];
209
+ const fadeEnd = hasPeek ? peekOffset : dismissOffset;
210
+ const floor = hasPeek ? MIN_PEEK_SCRIM_OPACITY : 0;
211
+ if (offset <= fadeStart) {
212
+ return 1;
213
+ }
214
+ if (offset >= fadeEnd) {
215
+ return floor;
216
+ }
217
+ return 1 - (1 - floor) * ((offset - fadeStart) / (fadeEnd - fadeStart));
218
+ }
219
+
220
+ /**
221
+ * Settle target for a released drag. Restricts candidates to the drag
222
+ * direction so a committed drag never snaps *back past* where it started
223
+ * (a down-drag settles at/below the start, an up-drag at/above), then picks
224
+ * the nearest remaining detent. `dir`: 1 = dragged down, -1 = up, 0 = neither.
225
+ */
226
+ export function resolveSettleOffset(
227
+ value: number,
228
+ offsets: ReadonlyArray<number>,
229
+ dir: number,
230
+ baseOffset: number,
231
+ ): number {
232
+ let candidates = offsets;
233
+ if (dir > 0) {
234
+ const downward = offsets.filter(o => o >= baseOffset);
235
+ if (downward.length > 0) {
236
+ candidates = downward;
237
+ }
238
+ } else if (dir < 0) {
239
+ const upward = offsets.filter(o => o <= baseOffset);
240
+ if (upward.length > 0) {
241
+ candidates = upward;
242
+ }
243
+ }
244
+ return nearestOffset(value, candidates);
245
+ }