@multiplatform.one/components 7.6.2 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/dist/cjs/Badge/index.cjs +4 -3
  2. package/dist/cjs/Badge/index.native.js +7 -6
  3. package/dist/cjs/Badge/index.native.js.map +1 -1
  4. package/dist/cjs/Card.cjs +7 -53
  5. package/dist/cjs/Card.native.js +8 -53
  6. package/dist/cjs/Card.native.js.map +1 -1
  7. package/dist/cjs/CardFooter.cjs +1 -9
  8. package/dist/cjs/CardFooter.native.js +2 -7
  9. package/dist/cjs/CardFooter.native.js.map +1 -1
  10. package/dist/cjs/CardHeader.cjs +1 -9
  11. package/dist/cjs/CardHeader.native.js +2 -7
  12. package/dist/cjs/CardHeader.native.js.map +1 -1
  13. package/dist/cjs/ProgressSteps/index.cjs +13 -6
  14. package/dist/cjs/ProgressSteps/index.native.js +14 -6
  15. package/dist/cjs/ProgressSteps/index.native.js.map +1 -1
  16. package/dist/cjs/SheetModal/detents.cjs +46 -0
  17. package/dist/cjs/SheetModal/detents.native.js +54 -0
  18. package/dist/cjs/SheetModal/detents.native.js.map +1 -0
  19. package/dist/cjs/SheetModal/index.native.js +8 -11
  20. package/dist/cjs/SheetModal/index.native.js.map +1 -1
  21. package/dist/cjs/SortableList/index.cjs +1 -1
  22. package/dist/cjs/SortableList/index.native.js +1 -1
  23. package/dist/cjs/SortableList/index.native.js.map +1 -1
  24. package/dist/cjs/code/Code/index.cjs +2 -15
  25. package/dist/cjs/code/Code/index.native.js +3 -13
  26. package/dist/cjs/code/Code/index.native.js.map +1 -1
  27. package/dist/cjs/code/HighlightedCode/index.cjs +0 -5
  28. package/dist/cjs/code/HighlightedCode/index.native.js +0 -5
  29. package/dist/cjs/code/HighlightedCode/index.native.js.map +1 -1
  30. package/dist/cjs/knobImmuneExports.cjs +8 -0
  31. package/dist/cjs/knobImmuneExports.native.js +8 -0
  32. package/dist/cjs/knobImmuneExports.native.js.map +1 -1
  33. package/dist/cjs/layouts/breadcrumbs.cjs +3 -1
  34. package/dist/cjs/layouts/breadcrumbs.native.js +3 -1
  35. package/dist/cjs/layouts/breadcrumbs.native.js.map +1 -1
  36. package/dist/cjs/surfaces.native.js.map +1 -1
  37. package/dist/cjs/tamagui.cjs +5 -0
  38. package/dist/cjs/tamagui.native.js +5 -0
  39. package/dist/cjs/tamagui.native.js.map +1 -1
  40. package/dist/cjs/views/Dashboard.cjs +3 -3
  41. package/dist/cjs/views/Dashboard.native.js +3 -3
  42. package/dist/cjs/views/Dashboard.native.js.map +1 -1
  43. package/dist/cjs/views/List.cjs +4 -5
  44. package/dist/cjs/views/List.native.js +4 -5
  45. package/dist/cjs/views/List.native.js.map +1 -1
  46. package/dist/cjs/views/TreeView.cjs +4 -1
  47. package/dist/cjs/views/TreeView.native.js +3 -0
  48. package/dist/cjs/views/TreeView.native.js.map +1 -1
  49. package/dist/esm/Badge/index.mjs +4 -3
  50. package/dist/esm/Badge/index.mjs.map +1 -1
  51. package/dist/esm/Badge/index.native.js +7 -6
  52. package/dist/esm/Badge/index.native.js.map +1 -1
  53. package/dist/esm/Card.mjs +5 -51
  54. package/dist/esm/Card.mjs.map +1 -1
  55. package/dist/esm/Card.native.js +9 -54
  56. package/dist/esm/Card.native.js.map +1 -1
  57. package/dist/esm/CardFooter.mjs +3 -11
  58. package/dist/esm/CardFooter.mjs.map +1 -1
  59. package/dist/esm/CardFooter.native.js +6 -11
  60. package/dist/esm/CardFooter.native.js.map +1 -1
  61. package/dist/esm/CardHeader.mjs +3 -11
  62. package/dist/esm/CardHeader.mjs.map +1 -1
  63. package/dist/esm/CardHeader.native.js +6 -11
  64. package/dist/esm/CardHeader.native.js.map +1 -1
  65. package/dist/esm/ProgressSteps/index.mjs +14 -7
  66. package/dist/esm/ProgressSteps/index.mjs.map +1 -1
  67. package/dist/esm/ProgressSteps/index.native.js +15 -7
  68. package/dist/esm/ProgressSteps/index.native.js.map +1 -1
  69. package/dist/esm/SheetModal/detents.mjs +19 -0
  70. package/dist/esm/SheetModal/detents.mjs.map +1 -0
  71. package/dist/esm/SheetModal/detents.native.js +24 -0
  72. package/dist/esm/SheetModal/detents.native.js.map +1 -0
  73. package/dist/esm/SheetModal/index.mjs.map +1 -1
  74. package/dist/esm/SheetModal/index.native.js +7 -10
  75. package/dist/esm/SheetModal/index.native.js.map +1 -1
  76. package/dist/esm/SortableList/index.mjs +1 -1
  77. package/dist/esm/SortableList/index.mjs.map +1 -1
  78. package/dist/esm/SortableList/index.native.js +1 -1
  79. package/dist/esm/SortableList/index.native.js.map +1 -1
  80. package/dist/esm/code/Code/index.mjs +2 -15
  81. package/dist/esm/code/Code/index.mjs.map +1 -1
  82. package/dist/esm/code/Code/index.native.js +6 -16
  83. package/dist/esm/code/Code/index.native.js.map +1 -1
  84. package/dist/esm/code/HighlightedCode/index.mjs +0 -5
  85. package/dist/esm/code/HighlightedCode/index.mjs.map +1 -1
  86. package/dist/esm/code/HighlightedCode/index.native.js +0 -5
  87. package/dist/esm/code/HighlightedCode/index.native.js.map +1 -1
  88. package/dist/esm/knobImmuneExports.mjs +8 -0
  89. package/dist/esm/knobImmuneExports.mjs.map +1 -1
  90. package/dist/esm/knobImmuneExports.native.js +8 -0
  91. package/dist/esm/knobImmuneExports.native.js.map +1 -1
  92. package/dist/esm/layouts/breadcrumbs.mjs +3 -1
  93. package/dist/esm/layouts/breadcrumbs.mjs.map +1 -1
  94. package/dist/esm/layouts/breadcrumbs.native.js +3 -1
  95. package/dist/esm/layouts/breadcrumbs.native.js.map +1 -1
  96. package/dist/esm/surfaces.mjs.map +1 -1
  97. package/dist/esm/surfaces.native.js.map +1 -1
  98. package/dist/esm/tamagui.mjs +3 -2
  99. package/dist/esm/tamagui.mjs.map +1 -1
  100. package/dist/esm/tamagui.native.js +3 -2
  101. package/dist/esm/tamagui.native.js.map +1 -1
  102. package/dist/esm/views/Dashboard.mjs +3 -3
  103. package/dist/esm/views/Dashboard.mjs.map +1 -1
  104. package/dist/esm/views/Dashboard.native.js +3 -3
  105. package/dist/esm/views/Dashboard.native.js.map +1 -1
  106. package/dist/esm/views/List.mjs +4 -5
  107. package/dist/esm/views/List.mjs.map +1 -1
  108. package/dist/esm/views/List.native.js +4 -5
  109. package/dist/esm/views/List.native.js.map +1 -1
  110. package/dist/esm/views/TreeView.mjs +4 -1
  111. package/dist/esm/views/TreeView.mjs.map +1 -1
  112. package/dist/esm/views/TreeView.native.js +3 -0
  113. package/dist/esm/views/TreeView.native.js.map +1 -1
  114. package/dist/jsx/Badge/index.mjs +4 -3
  115. package/dist/jsx/Badge/index.mjs.map +1 -1
  116. package/dist/jsx/Badge/index.native.js +7 -6
  117. package/dist/jsx/Badge/index.native.js.map +1 -1
  118. package/dist/jsx/Card.mjs +5 -51
  119. package/dist/jsx/Card.mjs.map +1 -1
  120. package/dist/jsx/Card.native.js +8 -53
  121. package/dist/jsx/Card.native.js.map +1 -1
  122. package/dist/jsx/CardFooter.mjs +3 -11
  123. package/dist/jsx/CardFooter.mjs.map +1 -1
  124. package/dist/jsx/CardFooter.native.js +2 -7
  125. package/dist/jsx/CardFooter.native.js.map +1 -1
  126. package/dist/jsx/CardHeader.mjs +3 -11
  127. package/dist/jsx/CardHeader.mjs.map +1 -1
  128. package/dist/jsx/CardHeader.native.js +2 -7
  129. package/dist/jsx/CardHeader.native.js.map +1 -1
  130. package/dist/jsx/ProgressSteps/index.mjs +14 -7
  131. package/dist/jsx/ProgressSteps/index.mjs.map +1 -1
  132. package/dist/jsx/ProgressSteps/index.native.js +14 -6
  133. package/dist/jsx/ProgressSteps/index.native.js.map +1 -1
  134. package/dist/jsx/SheetModal/detents.mjs +19 -0
  135. package/dist/jsx/SheetModal/detents.mjs.map +1 -0
  136. package/dist/jsx/SheetModal/detents.native.js +54 -0
  137. package/dist/jsx/SheetModal/detents.native.js.map +1 -0
  138. package/dist/jsx/SheetModal/index.mjs.map +1 -1
  139. package/dist/jsx/SheetModal/index.native.js +8 -11
  140. package/dist/jsx/SheetModal/index.native.js.map +1 -1
  141. package/dist/jsx/SortableList/index.mjs +1 -1
  142. package/dist/jsx/SortableList/index.mjs.map +1 -1
  143. package/dist/jsx/SortableList/index.native.js +1 -1
  144. package/dist/jsx/SortableList/index.native.js.map +1 -1
  145. package/dist/jsx/code/Code/index.mjs +2 -15
  146. package/dist/jsx/code/Code/index.mjs.map +1 -1
  147. package/dist/jsx/code/Code/index.native.js +3 -13
  148. package/dist/jsx/code/Code/index.native.js.map +1 -1
  149. package/dist/jsx/code/CodeBlock/index.native.js +1 -1
  150. package/dist/jsx/code/HighlightedCode/index.mjs +0 -5
  151. package/dist/jsx/code/HighlightedCode/index.mjs.map +1 -1
  152. package/dist/jsx/code/HighlightedCode/index.native.js +0 -5
  153. package/dist/jsx/code/HighlightedCode/index.native.js.map +1 -1
  154. package/dist/jsx/code/index.native.js +1 -1
  155. package/dist/jsx/knobImmuneExports.mjs +8 -0
  156. package/dist/jsx/knobImmuneExports.mjs.map +1 -1
  157. package/dist/jsx/knobImmuneExports.native.js +8 -0
  158. package/dist/jsx/knobImmuneExports.native.js.map +1 -1
  159. package/dist/jsx/layouts/breadcrumbs.mjs +3 -1
  160. package/dist/jsx/layouts/breadcrumbs.mjs.map +1 -1
  161. package/dist/jsx/layouts/breadcrumbs.native.js +3 -1
  162. package/dist/jsx/layouts/breadcrumbs.native.js.map +1 -1
  163. package/dist/jsx/surfaces.mjs.map +1 -1
  164. package/dist/jsx/surfaces.native.js.map +1 -1
  165. package/dist/jsx/tamagui.mjs +3 -2
  166. package/dist/jsx/tamagui.mjs.map +1 -1
  167. package/dist/jsx/tamagui.native.js +5 -0
  168. package/dist/jsx/tamagui.native.js.map +1 -1
  169. package/dist/jsx/views/Dashboard.mjs +3 -3
  170. package/dist/jsx/views/Dashboard.mjs.map +1 -1
  171. package/dist/jsx/views/Dashboard.native.js +3 -3
  172. package/dist/jsx/views/Dashboard.native.js.map +1 -1
  173. package/dist/jsx/views/List.mjs +4 -5
  174. package/dist/jsx/views/List.mjs.map +1 -1
  175. package/dist/jsx/views/List.native.js +4 -5
  176. package/dist/jsx/views/List.native.js.map +1 -1
  177. package/dist/jsx/views/TreeView.mjs +4 -1
  178. package/dist/jsx/views/TreeView.mjs.map +1 -1
  179. package/dist/jsx/views/TreeView.native.js +3 -0
  180. package/dist/jsx/views/TreeView.native.js.map +1 -1
  181. package/package.json +9 -9
  182. package/src/Badge/Badge.spec.tsx +10 -11
  183. package/src/Badge/index.tsx +7 -6
  184. package/src/Card.spec.tsx +7 -2
  185. package/src/Card.tsx +22 -37
  186. package/src/CardFooter.spec.tsx +1 -1
  187. package/src/CardFooter.tsx +6 -3
  188. package/src/CardHeader.spec.tsx +1 -1
  189. package/src/CardHeader.tsx +6 -3
  190. package/src/Chip/Chip.spec.tsx +1 -1
  191. package/src/Pagination.spec.tsx +1 -1
  192. package/src/ProgressSteps/ProgressSteps.spec.tsx +29 -2
  193. package/src/ProgressSteps/index.tsx +21 -7
  194. package/src/SheetModal/SheetModal.spec.tsx +88 -0
  195. package/src/SheetModal/detents.ts +55 -0
  196. package/src/SheetModal/index.native.tsx +13 -12
  197. package/src/SheetModal/index.tsx +5 -2
  198. package/src/Skeleton/Skeleton.spec.tsx +1 -1
  199. package/src/SortableList/SortableList.spec.tsx +41 -0
  200. package/src/SortableList/SortableList.stories.tsx +35 -2
  201. package/src/SortableList/index.tsx +1 -1
  202. package/src/TreeSelect/TreeSelect.stories.tsx +76 -1
  203. package/src/Wheel/Wheel.spec.tsx +1 -1
  204. package/src/code/Code/code.spec.tsx +27 -1
  205. package/src/code/Code/index.tsx +9 -2
  206. package/src/code/HighlightedCode/index.tsx +0 -1
  207. package/src/code/highlightedCode.spec.tsx +10 -1
  208. package/src/knobImmuneExports.ts +11 -0
  209. package/src/layouts/AsyncBoundary.spec.tsx +1 -1
  210. package/src/layouts/breadcrumbs.spec.tsx +17 -0
  211. package/src/layouts/breadcrumbs.tsx +19 -2
  212. package/src/layouts/tags.r7.spec.tsx +3 -1
  213. package/src/surfaces.tsx +2 -1
  214. package/src/tamagui.ts +8 -0
  215. package/src/views/Dashboard.tsx +3 -3
  216. package/src/views/List.spec.tsx +42 -1
  217. package/src/views/List.tsx +6 -5
  218. package/src/views/TreeView.spec.tsx +26 -0
  219. package/src/views/TreeView.tsx +3 -0
  220. package/types/Badge/index.d.ts.map +1 -1
  221. package/types/Card.d.ts +6 -11
  222. package/types/Card.d.ts.map +1 -1
  223. package/types/CardFooter.d.ts.map +1 -1
  224. package/types/CardHeader.d.ts.map +1 -1
  225. package/types/ProgressSteps/index.d.ts.map +1 -1
  226. package/types/SheetModal/detents.d.ts +38 -0
  227. package/types/SheetModal/detents.d.ts.map +1 -0
  228. package/types/SheetModal/index.d.ts +1 -1
  229. package/types/SheetModal/index.d.ts.map +1 -1
  230. package/types/SheetModal/index.native.d.ts +1 -1
  231. package/types/SheetModal/index.native.d.ts.map +1 -1
  232. package/types/code/Code/index.d.ts.map +1 -1
  233. package/types/code/HighlightedCode/index.d.ts.map +1 -1
  234. package/types/knobImmuneExports.d.ts.map +1 -1
  235. package/types/layouts/breadcrumbs.d.ts +7 -1
  236. package/types/layouts/breadcrumbs.d.ts.map +1 -1
  237. package/types/surfaces.d.ts.map +1 -1
  238. package/types/tamagui.d.ts +3 -1
  239. package/types/tamagui.d.ts.map +1 -1
  240. package/types/views/List.d.ts.map +1 -1
  241. package/types/views/TreeView.d.ts.map +1 -1
@@ -3,6 +3,8 @@ import {
3
3
  MIN_PRESS_TARGET,
4
4
  ensureKeyboardModalityTracking,
5
5
  pressTargetHitSlop,
6
+ radiusClassProps,
7
+ useAaSolidFill,
6
8
  useReadableTextOn,
7
9
  useResolvedKnobs,
8
10
  wasKeyboardFocus,
@@ -76,8 +78,11 @@ const Marker = styled(View, {
76
78
  name: "ProgressStepMarker",
77
79
  alignItems: "center",
78
80
  justifyContent: "center",
79
- borderWidth: 2,
80
81
  flexShrink: 0,
82
+ // R-PILL (D-09 / LC-60): the numbered disc is a circle at every radius
83
+ // stop — the knob moves size, never shape. Not K1 (Progress/Meter rails).
84
+ ...radiusClassProps("R-PILL", "ProgressSteps marker"),
85
+ borderRadius: 1000,
81
86
  });
82
87
 
83
88
  const StepButton = styled(View, {
@@ -148,14 +153,18 @@ export function ProgressSteps({
148
153
  );
149
154
  if (isWeb) ensureKeyboardModalityTracking();
150
155
  const [kbFocusIndex, setKbFocusIndex] = useState<number | null>(null);
151
- // LC-05: current marker rides the accent fill; completed/error fills pick
152
- // paper/ink from luminance rather than an assumed white glyph.
153
- const onSelected = useReadableTextOn(componentColors.indicator.selected);
156
+ // LC-05: current marker rides an accent solid; completed/error fills pick
157
+ // paper/ink from luminance rather than an assumed white glyph. The stock
158
+ // dark `$accentBackground` tops out at 4.02:1 against either scheme
159
+ // anchor, under the in-repo 4.5 text floor, so the current fill walks to
160
+ // the nearest accent solid that can carry the numeral.
161
+ const currentFill = useAaSolidFill(componentColors.indicator.selected);
162
+ const onSelected = useReadableTextOn(currentFill);
154
163
  const onCompleted = useReadableTextOn(componentColors.indicator.completed);
155
164
  const onError = useReadableTextOn("$red9");
156
165
  const { px: markerPx, hitSlop, box: nestedBox } = splitNestedControl(knobProps.nestedControl);
157
166
  const markerIconSize = Math.max(12, Math.round(markerPx * 0.45));
158
- const markerRadius = knobProps.pointy ? 0 : 1000;
167
+ const ringWidth = knobProps.borderRadius.borderWidth ?? 0;
159
168
  const connectorThickness = 2;
160
169
  const pressOutset = Math.max(0, Math.ceil((MIN_PRESS_TARGET - markerPx) / 2));
161
170
  const interactive = !!onStepChange && allowJump;
@@ -192,7 +201,7 @@ export function ProgressSteps({
192
201
  : kind === "completed"
193
202
  ? componentColors.indicator.completed
194
203
  : kind === "current"
195
- ? componentColors.indicator.selected
204
+ ? currentFill
196
205
  : "transparent";
197
206
  const markerBorder = kind === "upcoming" ? componentColors.indicator.track : markerBg;
198
207
  const markerFg =
@@ -207,15 +216,20 @@ export function ProgressSteps({
207
216
  step.description ?? (step.optional && kind !== "error" ? t("Optional") : null);
208
217
  const connectorFill =
209
218
  index < current ? componentColors.indicator.completed : componentColors.indicator.track;
219
+ // Upcoming is outlined-by-state: chromeless `borderWidth:none` would
220
+ // erase the empty disc, so it keeps the OUTLINED-HAIRLINE floor.
221
+ const markerRing = kind === "upcoming" ? Math.max(ringWidth, 0.5) : ringWidth;
210
222
 
211
223
  const marker = (
212
224
  <Marker
213
225
  {...nestedBox}
214
- borderRadius={markerRadius}
226
+ borderRadius={1000}
227
+ borderWidth={markerRing}
215
228
  backgroundColor={markerBg}
216
229
  borderColor={markerBorder}
217
230
  data-nested-px={markerPx}
218
231
  data-step-state={kind}
232
+ data-marker-ring={markerRing}
219
233
  transition={knobProps.transition}
220
234
  {...(state === "current"
221
235
  ? ({ "aria-current": "step" } as Record<string, unknown>)
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mediumDetent, minSheetHeight, resolveSheetDetents } from "./detents";
3
+
4
+ /*
5
+ * MPO-68 AC-4 — "SheetModal's snapPoint prop either drives a detent or is
6
+ * removed". It drives one. These lock that in, because it has already been
7
+ * lost once: 184746910 (MPO-71) rewrote the detents block from a pre-MPO-68
8
+ * copy and dropped the snapPoint push, leaving the prop declared, documented
9
+ * and passed by four callers while doing nothing on device.
10
+ *
11
+ * Geometry under test is a pure height list, largest first. The component's
12
+ * PanResponder turns each height h into a resting translate of `travel - h`.
13
+ */
14
+
15
+ const travel = 600;
16
+
17
+ describe("resolveSheetDetents", () => {
18
+ it("a plain sheet has exactly one rest: fully expanded", () => {
19
+ expect(resolveSheetDetents({ travel })).toEqual([600]);
20
+ });
21
+
22
+ it("REGRESSION: snapPoint creates a detent instead of being ignored", () => {
23
+ // The bug this spec exists for: main returned [600] here.
24
+ expect(resolveSheetDetents({ travel, snapPoint: 85 })).toEqual([600, 510]);
25
+ });
26
+
27
+ it("reads snapPoint as a percent of the full height, matching the web Sheet", () => {
28
+ expect(resolveSheetDetents({ travel, snapPoint: 50 })).toEqual([600, 300]);
29
+ expect(resolveSheetDetents({ travel, snapPoint: 60 })).toEqual([600, 360]);
30
+ expect(resolveSheetDetents({ travel, snapPoint: 92 })).toEqual([600, 552]);
31
+ });
32
+
33
+ it("honours the four heights real callers pass", () => {
34
+ // CommandPalette 92, list-tools-sheet 85, ListThemePlayground 85,
35
+ // Toast.stories 70 — every one of these was a no-op on device.
36
+ for (const snapPoint of [92, 85, 70]) {
37
+ const detents = resolveSheetDetents({ travel, snapPoint });
38
+ expect(detents).toContain(Math.round(travel * (snapPoint / 100)));
39
+ expect(detents.length).toBeGreaterThan(1);
40
+ }
41
+ });
42
+
43
+ it("a scrollable sheet keeps its half-open detent", () => {
44
+ expect(resolveSheetDetents({ travel, scrollable: true })).toEqual([
45
+ 600,
46
+ Math.round(travel * mediumDetent),
47
+ ]);
48
+ });
49
+
50
+ it("scrollable and snapPoint stack, largest first, no duplicates", () => {
51
+ // 600 expanded, 330 medium, 510 from snapPoint.
52
+ expect(resolveSheetDetents({ travel, scrollable: true, snapPoint: 85 })).toEqual([
53
+ 600, 510, 330,
54
+ ]);
55
+ });
56
+
57
+ it("dedupes a snapPoint that lands on a detent already present", () => {
58
+ expect(resolveSheetDetents({ travel, snapPoint: 100 })).toEqual([600]);
59
+ expect(resolveSheetDetents({ travel, scrollable: true, snapPoint: 55 })).toEqual([600, 330]);
60
+ });
61
+
62
+ it("clamps a snapPoint above 100 to fully expanded", () => {
63
+ expect(resolveSheetDetents({ travel, snapPoint: 140 })).toEqual([600]);
64
+ });
65
+
66
+ it("drops a detent shorter than the floor rather than resting near-dismissed", () => {
67
+ // 600 * 0.10 = 60, well under minSheetHeight.
68
+ expect(resolveSheetDetents({ travel, snapPoint: 10 })).toEqual([600]);
69
+ expect(minSheetHeight).toBe(180);
70
+ });
71
+
72
+ it("a sheet shorter than the floor still keeps its own full height", () => {
73
+ expect(resolveSheetDetents({ travel: 120, scrollable: true, snapPoint: 85 })).toEqual([120]);
74
+ });
75
+
76
+ it("ignores a snapPoint that is not a usable number", () => {
77
+ for (const snapPoint of [0, -20, Number.NaN, Number.POSITIVE_INFINITY]) {
78
+ expect(resolveSheetDetents({ travel, snapPoint })).toEqual([600]);
79
+ }
80
+ expect(resolveSheetDetents({ travel, snapPoint: undefined })).toEqual([600]);
81
+ });
82
+
83
+ it("always returns descending heights, so the pan release can scan in order", () => {
84
+ const detents = resolveSheetDetents({ travel, scrollable: true, snapPoint: 40 });
85
+ expect(detents).toEqual([...detents].sort((a, b) => b - a));
86
+ expect(new Set(detents).size).toBe(detents.length);
87
+ });
88
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * SheetModal detent geometry — the pure half, split out so it can be specced
3
+ * without standing up a React Native renderer.
4
+ *
5
+ * A detent is a VISIBLE HEIGHT in points. The native sheet's `translateY`
6
+ * measures DOWN from fully-expanded, so a detent of `h` rests at `travel - h`,
7
+ * and `travel` itself is the fully-expanded sheet.
8
+ */
9
+
10
+ /** Smallest height a native sheet is ever laid out at. */
11
+ export const minSheetHeight = 180;
12
+
13
+ /**
14
+ * Half-open detent for a sheet that owns a scrollport, as a fraction of its
15
+ * full height. Mirrors FloatingPanel so both native sheets drag alike.
16
+ */
17
+ export const mediumDetent = 0.55;
18
+
19
+ export interface ResolveSheetDetentsOptions {
20
+ /** Full (expanded) height of the sheet, in points. */
21
+ travel: number;
22
+ /** The sheet owns a scrollport, so a half-open rest is meaningful. */
23
+ scrollable?: boolean;
24
+ /** Percent detent from the caller — the same prop the web Sheet takes. */
25
+ snapPoint?: number;
26
+ }
27
+
28
+ /**
29
+ * The heights the sheet is allowed to rest at, largest first.
30
+ *
31
+ * MPO-68: `snapPoint` is a percent on the web Sheet (`snapPoints={[snapPoint]}`).
32
+ * Native honours the same prop by turning that percent into a detent, so a
33
+ * caller that writes `snapPoint={85}` gets an 85%-height rest on both twins
34
+ * rather than the prop being silently dropped on device.
35
+ *
36
+ * Anything shorter than `minSheetHeight` is discarded, because a sheet that
37
+ * rests below that is indistinguishable from a dismissed one. A sheet whose
38
+ * full height is already under the floor keeps its own height, otherwise it
39
+ * would have no detents at all.
40
+ */
41
+ export function resolveSheetDetents({
42
+ travel,
43
+ scrollable,
44
+ snapPoint,
45
+ }: ResolveSheetDetentsOptions): number[] {
46
+ const points = [travel];
47
+ if (scrollable) {
48
+ points.push(Math.round(travel * mediumDetent));
49
+ }
50
+ if (typeof snapPoint === "number" && Number.isFinite(snapPoint) && snapPoint > 0) {
51
+ points.push(Math.round(travel * (Math.min(snapPoint, 100) / 100)));
52
+ }
53
+ const floor = Math.min(minSheetHeight, travel);
54
+ return Array.from(new Set(points.filter((h) => h >= floor))).sort((a, b) => b - a);
55
+ }
@@ -24,6 +24,7 @@ import {
24
24
  useWindowDimensions,
25
25
  } from "react-native";
26
26
  import { useResolvedKnobs } from "@multiplatform.one/theme";
27
+ import { minSheetHeight, resolveSheetDetents } from "./detents";
27
28
  import { useTranslation } from "../shared/i18n";
28
29
  import { useKeyboardInset } from "../shared/useKeyboardInset";
29
30
  import { View, YStack } from "tamagui";
@@ -36,10 +37,6 @@ const sheetMaxHeightRatio = 0.85;
36
37
  const bottomInset = Platform.OS === "ios" ? 24 : 16;
37
38
  // Backdrop left visible above the sheet, so the scrim stays tappable.
38
39
  const backdropStrip = 48;
39
- const minSheetHeight = 180;
40
- // Half-open detent for a sheet that owns a scrollport, as a fraction of its
41
- // full height. Mirrors FloatingPanel so both native sheets drag alike.
42
- const mediumDetent = 0.55;
43
40
  // Released more than this far below the smallest detent, the sheet dismisses.
44
41
  const dismissThreshold = 56;
45
42
  const IN_DIALOG_REGION = { inDialogRegion: true } as const;
@@ -49,7 +46,7 @@ export function SheetModal({
49
46
  onOpenChange,
50
47
  children,
51
48
  header,
52
- snapPoint: _snapPoint,
49
+ snapPoint,
53
50
  scrollable,
54
51
  }: SheetModalProps) {
55
52
  const { t } = useTranslation();
@@ -95,16 +92,20 @@ export function SheetModal({
95
92
  *
96
93
  * translateY measures DOWN from fully-expanded: 0 is the sheet at full
97
94
  * height, `travel` is fully off-screen.
95
+ *
96
+ * The detent heights themselves live in ./detents.ts, pure, so the geometry
97
+ * is specced without a native renderer. 184746910 (MPO-71) rewrote this
98
+ * block from a pre-MPO-68 copy and silently dropped the `snapPoint` push,
99
+ * leaving the prop declared, documented and passed by four callers while
100
+ * doing nothing on device. Keep the math in one place so a rewrite of the
101
+ * animation cannot take the geometry with it again.
98
102
  */
99
103
  const [measuredHeight, setMeasuredHeight] = useState(0);
100
104
  const travel = measuredHeight || maxHeight;
101
- const detents = useMemo(() => {
102
- if (!scrollable) return [travel];
103
- const points = [travel, Math.round(travel * mediumDetent)].filter(
104
- (h) => h >= Math.min(minSheetHeight, travel),
105
- );
106
- return Array.from(new Set(points)).sort((a, b) => b - a);
107
- }, [scrollable, travel]);
105
+ const detents = useMemo(
106
+ () => resolveSheetDetents({ travel, scrollable, snapPoint }),
107
+ [travel, scrollable, snapPoint],
108
+ );
108
109
 
109
110
  /*
110
111
  * MPO-71 — park the sheet OFF-SCREEN before the modal ever presents.
@@ -21,7 +21,7 @@ export interface SheetModalProps {
21
21
  children?: ReactNode;
22
22
  /** Fixed content above the (scrollable) body, e.g. the sheet title. */
23
23
  header?: ReactNode;
24
- /** Percent snap point for the web Sheet (native sizes to content). */
24
+ /** Percent snap point. Web: the Sheet's snap point. Native: a drag detent. */
25
25
  snapPoint?: number;
26
26
  /** Wrap children in the sheet's scroll container. */
27
27
  scrollable?: boolean;
@@ -59,7 +59,10 @@ function useSheetFrameFocus(open: boolean, frameRef: RefObject<HTMLElement | nul
59
59
  if (!target || !target.isConnected) return;
60
60
  const ae = document.activeElement;
61
61
  const dropped =
62
- !ae || ae === document.body || ae === document.documentElement || !!frameRef.current?.contains(ae);
62
+ !ae ||
63
+ ae === document.body ||
64
+ ae === document.documentElement ||
65
+ !!frameRef.current?.contains(ae);
63
66
  if (dropped) {
64
67
  target.focus({
65
68
  preventScroll: true,
@@ -22,7 +22,7 @@ function radiusClasses(el: Element): string[] {
22
22
 
23
23
  describe("Skeleton named exports", () => {
24
24
  it("serves named exports only (no default) and keeps the namespace statics", () => {
25
- expect(SkeletonExports.default).toBeUndefined();
25
+ expect("default" in SkeletonExports).toBe(false);
26
26
  expect(SkeletonExports.Skeleton).toBe(Skeleton);
27
27
  expect(SkeletonExports.SkeletonText).toBe(SkeletonText);
28
28
  expect(SkeletonExports.SkeletonCircle).toBe(SkeletonCircle);
@@ -1,9 +1,14 @@
1
1
  import { fireEvent } from "@testing-library/react";
2
2
  import { renderWithProviders } from "@multiplatform.one/test-utils";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  import { useState } from "react";
4
7
  import { describe, expect, it, vi } from "vitest";
5
8
  import { SortableList } from "./index";
6
9
 
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+
7
12
  const ITEMS = [
8
13
  { id: "mon", label: "Monday standup" },
9
14
  { id: "tue", label: "Tuesday review" },
@@ -14,10 +19,14 @@ const ITEMS = [
14
19
  function Harness({
15
20
  onChange = vi.fn(),
16
21
  readOnly,
22
+ disabled,
23
+ compact,
17
24
  canReorder,
18
25
  }: {
19
26
  onChange?: (next: typeof ITEMS) => void;
20
27
  readOnly?: boolean;
28
+ disabled?: boolean;
29
+ compact?: boolean;
21
30
  canReorder?: (item: (typeof ITEMS)[number], index: number) => boolean;
22
31
  }) {
23
32
  const [items, setItems] = useState(ITEMS);
@@ -28,6 +37,8 @@ function Harness({
28
37
  getLabel={(item) => item.label}
29
38
  canReorder={canReorder}
30
39
  readOnly={readOnly}
40
+ disabled={disabled}
41
+ compact={compact}
31
42
  onChange={(next) => {
32
43
  setItems(next);
33
44
  onChange(next);
@@ -119,4 +130,34 @@ describe("SortableList", () => {
119
130
  const { container } = renderWithProviders(<Harness readOnly />);
120
131
  expect(container.querySelectorAll("[data-sortable-handle]")).toHaveLength(0);
121
132
  });
133
+
134
+ it("renders no handles when disabled (board 04)", () => {
135
+ const { container } = renderWithProviders(<Harness disabled />);
136
+ expect(container.querySelectorAll("[data-sortable-handle]")).toHaveLength(0);
137
+ });
138
+
139
+ it("renders emptyMessage when there are no rows (board 04)", () => {
140
+ const { container } = renderWithProviders(
141
+ <SortableList
142
+ items={[]}
143
+ getId={(item: { id: string }) => item.id}
144
+ getLabel={(item: { label: string }) => item.label}
145
+ emptyMessage="No rows yet"
146
+ />,
147
+ );
148
+ expect(container.textContent).toContain("No rows yet");
149
+ expect(container.querySelectorAll("[data-sortable-row]")).toHaveLength(0);
150
+ });
151
+
152
+ it("compact still renders grab handles (LC-76: density does not drop controls)", () => {
153
+ const { container } = renderWithProviders(<Harness compact />);
154
+ expect(container.querySelectorAll("[data-sortable-handle]")).toHaveLength(4);
155
+ expect(container.querySelector('[role="list"]')).toBeTruthy();
156
+ });
157
+
158
+ it("spreads the outer radius fragment on the list frame, not the nested alias", () => {
159
+ const src = readFileSync(join(here, "index.tsx"), "utf8");
160
+ expect(src).toContain("{...knobProps.borderRadius}");
161
+ expect(src).not.toContain("knobProps.borderRadiusOuter");
162
+ });
122
163
  });
@@ -21,13 +21,27 @@ const meta: Meta<typeof SortableList> = {
21
21
  },
22
22
  },
23
23
  },
24
+ argTypes: {
25
+ disabled: { control: "boolean" },
26
+ readOnly: { control: "boolean" },
27
+ compact: { control: "boolean" },
28
+ disabledStyle: { control: "select", options: ["keepLabel", "dimWhole"] },
29
+ emptyMessage: { control: "text" },
30
+ },
24
31
  };
25
32
  export default meta;
26
33
 
27
34
  type Story = StoryObj<typeof SortableList>;
28
35
 
29
- function Demo(props: { readOnly?: boolean; locked?: string }) {
30
- const [items, setItems] = useState(SEED);
36
+ function Demo(props: {
37
+ readOnly?: boolean;
38
+ locked?: string;
39
+ disabled?: boolean;
40
+ compact?: boolean;
41
+ empty?: boolean;
42
+ disabledStyle?: "keepLabel" | "dimWhole";
43
+ }) {
44
+ const [items, setItems] = useState(props.empty ? [] : SEED);
31
45
  return (
32
46
  <SortableList
33
47
  items={items}
@@ -35,6 +49,10 @@ function Demo(props: { readOnly?: boolean; locked?: string }) {
35
49
  getLabel={(item) => item.label}
36
50
  canReorder={props.locked ? (item) => item.id !== props.locked : undefined}
37
51
  readOnly={props.readOnly}
52
+ disabled={props.disabled}
53
+ compact={props.compact}
54
+ disabledStyle={props.disabledStyle}
55
+ emptyMessage="No rows yet"
38
56
  onChange={setItems}
39
57
  aria-label="Sprint rituals"
40
58
  />
@@ -53,3 +71,18 @@ export const ReadOnly: Story = {
53
71
  export const LockedRow: Story = {
54
72
  render: () => <Demo locked="tue" />,
55
73
  };
74
+
75
+ /** Board 04 — empty is a real state, not an error. */
76
+ export const Empty: Story = {
77
+ render: () => <Demo empty />,
78
+ };
79
+
80
+ /** Board 04 — disabled list keeps labels, drops the handles. */
81
+ export const Disabled: Story = {
82
+ render: () => <Demo disabled />,
83
+ };
84
+
85
+ /** Board 04 — compact nested well. */
86
+ export const Compact: Story = {
87
+ render: () => <Demo compact />,
88
+ };
@@ -90,7 +90,7 @@ export function SortableList<T>({
90
90
  <YStack
91
91
  ref={containerRef as never}
92
92
  {...knobProps.surface}
93
- {...knobProps.borderRadiusOuter}
93
+ {...knobProps.borderRadius}
94
94
  overflow="hidden"
95
95
  role="list"
96
96
  aria-label={ariaLabel}
@@ -47,7 +47,7 @@ export default meta;
47
47
 
48
48
  type Story = StoryObj<typeof TreeSelect>;
49
49
 
50
- export const Account: Story = {
50
+ export const Default: Story = {
51
51
  render: function Render() {
52
52
  const [value, setValue] = useState("hdfc");
53
53
  return (
@@ -67,6 +67,31 @@ export const Account: Story = {
67
67
  },
68
68
  };
69
69
 
70
+ export const Open: Story = {
71
+ render: function Render() {
72
+ const [value, setValue] = useState("hdfc");
73
+ const [open, setOpen] = useState(true);
74
+ return (
75
+ <YStack width={280} padding="$4">
76
+ <TreeSelect
77
+ label="Account"
78
+ nodes={accounts}
79
+ value={value}
80
+ onChange={setValue}
81
+ open={open}
82
+ onOpenChange={setOpen}
83
+ placeholder="Select account"
84
+ searchPlaceholder="Filter accounts"
85
+ emptyMessage="No matching accounts"
86
+ defaultExpandedIds={["assets", "current", "bank"]}
87
+ />
88
+ </YStack>
89
+ );
90
+ },
91
+ };
92
+
93
+ export const Account: Story = Default;
94
+
70
95
  export const Empty: Story = {
71
96
  render: () => (
72
97
  <YStack width={280} padding="$4">
@@ -74,3 +99,53 @@ export const Empty: Story = {
74
99
  </YStack>
75
100
  ),
76
101
  };
102
+
103
+ export const Disabled: Story = {
104
+ render: () => (
105
+ <YStack width={280} padding="$4">
106
+ <TreeSelect
107
+ label="Account"
108
+ nodes={accounts}
109
+ value="hdfc"
110
+ disabled
111
+ placeholder="Select account"
112
+ />
113
+ </YStack>
114
+ ),
115
+ };
116
+
117
+ export const WithError: Story = {
118
+ render: () => (
119
+ <YStack width={280} padding="$4">
120
+ <TreeSelect
121
+ label="Account"
122
+ nodes={accounts}
123
+ required
124
+ error="An account is required"
125
+ placeholder="Select account"
126
+ />
127
+ </YStack>
128
+ ),
129
+ };
130
+
131
+ export const ReadOnly: Story = {
132
+ render: () => (
133
+ <YStack width={280} padding="$4">
134
+ <TreeSelect
135
+ label="Account"
136
+ nodes={accounts}
137
+ value="hdfc"
138
+ readOnly
139
+ placeholder="Select account"
140
+ />
141
+ </YStack>
142
+ ),
143
+ };
144
+
145
+ export const Skeleton: Story = {
146
+ render: () => (
147
+ <YStack width={280} padding="$4">
148
+ <TreeSelect label="Account" nodes={accounts} skeleton placeholder="Select account" />
149
+ </YStack>
150
+ ),
151
+ };
@@ -272,7 +272,7 @@ describe("Wheel design-law (MPO-60)", () => {
272
272
  it("exposes named exports and no default", async () => {
273
273
  const mod = await import("./index");
274
274
  expect(mod.Wheel).toBe(Wheel);
275
- expect(mod.default).toBeUndefined();
275
+ expect("default" in mod).toBe(false);
276
276
  });
277
277
 
278
278
  it("selected row type stays on body weight 400, never 600 (R7 / design-audit)", () => {
@@ -1,5 +1,5 @@
1
1
  import { renderWithProviders } from "@multiplatform.one/test-utils";
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
3
  import { Code, CodeInline } from "./index";
4
4
 
5
5
  describe("Code", () => {
@@ -21,6 +21,15 @@ describe("Code", () => {
21
21
  const codeElement = container.querySelector("code");
22
22
  expect(codeElement).toBeDefined();
23
23
  });
24
+
25
+ it("keeps data-code-font and does not emit a dataset attribute", () => {
26
+ const { container } = renderWithProviders(<Code>const test = "hello"</Code>);
27
+ const codeElement = container.querySelector("code");
28
+ expect(codeElement).toBeTruthy();
29
+ expect(codeElement!.getAttribute("data-code-font")).toBe("mono");
30
+ // React lowercases an unknown dataSet prop to dataset="[object Object]".
31
+ expect(codeElement!.hasAttribute("dataset")).toBe(false);
32
+ });
24
33
  });
25
34
 
26
35
  describe("CodeInline", () => {
@@ -42,4 +51,21 @@ describe("CodeInline", () => {
42
51
  const codeElement = container.querySelector("code");
43
52
  expect(codeElement).toBeDefined();
44
53
  });
54
+
55
+ it("emits data-code-font on web without a dataSet host-prop leak", () => {
56
+ const leak = /does not recognize the `dataSet` prop on a DOM element/;
57
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
58
+ try {
59
+ const { container } = renderWithProviders(<CodeInline>npm install</CodeInline>);
60
+ const codeElement = container.querySelector("code");
61
+ expect(codeElement).toBeDefined();
62
+ expect(codeElement?.getAttribute("data-code-font")).toBe("mono");
63
+ const leaks = errorSpy.mock.calls
64
+ .map((args) => args.map(String).join(" "))
65
+ .filter((message) => leak.test(message));
66
+ expect(leaks).toEqual([]);
67
+ } finally {
68
+ errorSpy.mockRestore();
69
+ }
70
+ });
45
71
  });
@@ -40,7 +40,6 @@ export const Code = CodeFrame.styleable((props, ref) => {
40
40
  fontFamily="$mono"
41
41
  letterSpacing={0}
42
42
  data-code-font="mono"
43
- {...({ dataSet: { codeFont: "mono" } } as object)}
44
43
  />
45
44
  );
46
45
  });
@@ -57,6 +56,9 @@ const CodeInlineFrame = styled(Paragraph, {
57
56
  backgroundColor: componentColors.code.background,
58
57
  cursor: "inherit",
59
58
  borderRadius: "$3",
59
+ // Pin (dial: surrounding prose): 0.9× size / 0.7× leading. Size reaches
60
+ // the chip through context, not sizeToken. Lettered in spec § Code and
61
+ // CodeInline; these ratios are the house metrics already shipping.
60
62
  fontSize: "90%" as CSSProperties["fontSize"],
61
63
  lineHeight: "70%" as CSSProperties["lineHeight"],
62
64
  paddingHorizontal: "0.6%",
@@ -79,7 +81,12 @@ export const CodeInline = CodeInlineFrame.styleable((props, ref) => {
79
81
  fontFamily="$mono"
80
82
  letterSpacing={0}
81
83
  data-code-font="mono"
82
- {...({ dataSet: { codeFont: "mono" } } as object)}
84
+ // react-native-web reads dataSet; React-DOM warns on the camel host
85
+ // prop. Keep the kebab attribute on every target; spread dataSet
86
+ // only when TAMAGUI_TARGET is native.
87
+ {...(process.env.TAMAGUI_TARGET === "native"
88
+ ? ({ dataSet: { codeFont: "mono" } } as object)
89
+ : {})}
83
90
  />
84
91
  );
85
92
  });
@@ -74,7 +74,6 @@ export function HighlightedCode({
74
74
  fontFamily="$mono"
75
75
  letterSpacing={0}
76
76
  data-code-font="mono"
77
- {...({ dataSet: { codeFont: "mono" } } as object)}
78
77
  >
79
78
  {showLineNumbers && (
80
79
  <Text
@@ -115,9 +115,18 @@ describe("HighlightedCode", () => {
115
115
  <HighlightedCode tokens={tokens} />
116
116
  </Preset>,
117
117
  );
118
- const line = container.querySelector("span");
118
+ // querySelector("span") used to land on renderWithProviders' Theme
119
+ // wrapper (`t_light _dsp_contents`), never the code line, so this guard
120
+ // asserted null and had been red on main. Select the line itself.
121
+ const line = container.querySelector("[data-code-font]") as HTMLElement | null;
119
122
  expect(line).toBeTruthy();
120
123
  expect(container.textContent).toContain("const");
121
124
  expect(line!.getAttribute("data-code-font")).toBe("mono");
125
+ // The actual LC-07 pin: bodyFont serif / fontWeight bold must not reach
126
+ // code text, so the line keeps the mono family class.
127
+ expect(line!.className).toContain("font_mono");
128
+ // React does not recognise `dataSet`; leaving it on the line logged an
129
+ // unknown-prop error and wrote dataset="[object Object]" on every node.
130
+ expect(container.querySelectorAll("[dataset]")).toHaveLength(0);
122
131
  });
123
132
  });