@delacour/react-native-ui 0.1.0-alpha.3

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 (273) hide show
  1. package/README.md +113 -0
  2. package/package.json +125 -0
  3. package/src/components/accordion/AGENTS.md +192 -0
  4. package/src/components/accordion/accordion-content.tsx +125 -0
  5. package/src/components/accordion/accordion-description.tsx +17 -0
  6. package/src/components/accordion/accordion-indicator.tsx +104 -0
  7. package/src/components/accordion/accordion-item.tsx +78 -0
  8. package/src/components/accordion/accordion-title.tsx +18 -0
  9. package/src/components/accordion/accordion-trigger.tsx +148 -0
  10. package/src/components/accordion/accordion.context.tsx +153 -0
  11. package/src/components/accordion/accordion.tsx +253 -0
  12. package/src/components/accordion/accordion.types.ts +11 -0
  13. package/src/components/accordion/accordion.variants.test.ts +435 -0
  14. package/src/components/accordion/accordion.variants.ts +358 -0
  15. package/src/components/accordion/index.ts +45 -0
  16. package/src/components/badge/AGENTS.md +83 -0
  17. package/src/components/badge/badge-close-button.tsx +48 -0
  18. package/src/components/badge/badge-end-content.tsx +15 -0
  19. package/src/components/badge/badge-label.tsx +24 -0
  20. package/src/components/badge/badge-start-content.tsx +16 -0
  21. package/src/components/badge/badge.context.tsx +64 -0
  22. package/src/components/badge/badge.tsx +192 -0
  23. package/src/components/badge/badge.types.ts +10 -0
  24. package/src/components/badge/badge.variants.test.ts +269 -0
  25. package/src/components/badge/badge.variants.ts +238 -0
  26. package/src/components/badge/index.ts +17 -0
  27. package/src/components/bottom-sheet/AGENTS.md +232 -0
  28. package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
  29. package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
  30. package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
  31. package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
  32. package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
  33. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
  34. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
  35. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
  36. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
  37. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
  38. package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
  39. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
  40. package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
  41. package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
  42. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
  44. package/src/components/bottom-sheet/index.ts +40 -0
  45. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
  46. package/src/components/button/AGENTS.md +217 -0
  47. package/src/components/button/button-end-content.tsx +15 -0
  48. package/src/components/button/button-group-separator.tsx +37 -0
  49. package/src/components/button/button-group-text.tsx +101 -0
  50. package/src/components/button/button-group.tsx +158 -0
  51. package/src/components/button/button-label.tsx +24 -0
  52. package/src/components/button/button-start-content.tsx +15 -0
  53. package/src/components/button/button.context.tsx +187 -0
  54. package/src/components/button/button.tsx +273 -0
  55. package/src/components/button/button.types.ts +10 -0
  56. package/src/components/button/button.variants.test.ts +811 -0
  57. package/src/components/button/button.variants.ts +480 -0
  58. package/src/components/button/index.ts +50 -0
  59. package/src/components/chart/AGENTS.md +210 -0
  60. package/src/components/chart/chart-area.tsx +93 -0
  61. package/src/components/chart/chart-bar.tsx +103 -0
  62. package/src/components/chart/chart-bars.tsx +75 -0
  63. package/src/components/chart/chart-candlestick.tsx +60 -0
  64. package/src/components/chart/chart-grid.tsx +33 -0
  65. package/src/components/chart/chart-legend.tsx +41 -0
  66. package/src/components/chart/chart-line.tsx +50 -0
  67. package/src/components/chart/chart-scatter.tsx +35 -0
  68. package/src/components/chart/chart-tooltip-dot.tsx +70 -0
  69. package/src/components/chart/chart-tooltip-x.tsx +121 -0
  70. package/src/components/chart/chart-tooltip-y.tsx +55 -0
  71. package/src/components/chart/chart-tooltip.tsx +133 -0
  72. package/src/components/chart/chart-x-axis.tsx +27 -0
  73. package/src/components/chart/chart-y-axis.tsx +21 -0
  74. package/src/components/chart/chart.context.tsx +127 -0
  75. package/src/components/chart/chart.tsx +448 -0
  76. package/src/components/chart/chart.types.ts +108 -0
  77. package/src/components/chart/chart.variants.test.ts +893 -0
  78. package/src/components/chart/chart.variants.ts +726 -0
  79. package/src/components/chart/index.ts +103 -0
  80. package/src/components/chart/pie-chart-center.tsx +40 -0
  81. package/src/components/chart/pie-chart-label.tsx +56 -0
  82. package/src/components/chart/pie-chart-slice.tsx +45 -0
  83. package/src/components/chart/pie-chart-tooltip.tsx +85 -0
  84. package/src/components/chart/pie-chart.context.tsx +77 -0
  85. package/src/components/chart/pie-chart.tsx +247 -0
  86. package/src/components/chart/use-chart-font.ts +25 -0
  87. package/src/components/chart/use-chart-palette.ts +38 -0
  88. package/src/components/checkbox/AGENTS.md +185 -0
  89. package/src/components/checkbox/checkbox-box.tsx +152 -0
  90. package/src/components/checkbox/checkbox-group.tsx +107 -0
  91. package/src/components/checkbox/checkbox-label.tsx +34 -0
  92. package/src/components/checkbox/checkbox.context.tsx +136 -0
  93. package/src/components/checkbox/checkbox.tsx +258 -0
  94. package/src/components/checkbox/checkbox.types.ts +14 -0
  95. package/src/components/checkbox/checkbox.variants.test.ts +634 -0
  96. package/src/components/checkbox/checkbox.variants.ts +484 -0
  97. package/src/components/checkbox/index.ts +42 -0
  98. package/src/components/field/AGENTS.md +100 -0
  99. package/src/components/field/field-content.tsx +24 -0
  100. package/src/components/field/field-description.tsx +29 -0
  101. package/src/components/field/field-error.tsx +61 -0
  102. package/src/components/field/field-group.tsx +22 -0
  103. package/src/components/field/field-label.tsx +33 -0
  104. package/src/components/field/field-legend.tsx +34 -0
  105. package/src/components/field/field-separator.tsx +81 -0
  106. package/src/components/field/field-set.tsx +22 -0
  107. package/src/components/field/field.context.tsx +78 -0
  108. package/src/components/field/field.tsx +144 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +216 -0
  111. package/src/components/field/field.variants.ts +136 -0
  112. package/src/components/field/index.ts +21 -0
  113. package/src/components/icon/AGENTS.md +104 -0
  114. package/src/components/icon/icon.context.tsx +29 -0
  115. package/src/components/icon/icon.tsx +92 -0
  116. package/src/components/icon/icon.variants.test.ts +111 -0
  117. package/src/components/icon/icon.variants.ts +79 -0
  118. package/src/components/icon/index.ts +11 -0
  119. package/src/components/input/AGENTS.md +126 -0
  120. package/src/components/input/index.ts +27 -0
  121. package/src/components/input/input-group-decorator.tsx +84 -0
  122. package/src/components/input/input-group-prefix.tsx +16 -0
  123. package/src/components/input/input-group-suffix.tsx +15 -0
  124. package/src/components/input/input-group.tsx +140 -0
  125. package/src/components/input/input.context.tsx +87 -0
  126. package/src/components/input/input.tsx +184 -0
  127. package/src/components/input/input.types.ts +10 -0
  128. package/src/components/input/input.variants.test.ts +369 -0
  129. package/src/components/input/input.variants.ts +329 -0
  130. package/src/components/list-group/AGENTS.md +67 -0
  131. package/src/components/list-group/index.ts +20 -0
  132. package/src/components/list-group/list-group-item-content.tsx +10 -0
  133. package/src/components/list-group/list-group-item-description.tsx +12 -0
  134. package/src/components/list-group/list-group-item-prefix.tsx +30 -0
  135. package/src/components/list-group/list-group-item-suffix.tsx +55 -0
  136. package/src/components/list-group/list-group-item-title.tsx +17 -0
  137. package/src/components/list-group/list-group-item.tsx +87 -0
  138. package/src/components/list-group/list-group.context.tsx +65 -0
  139. package/src/components/list-group/list-group.tsx +116 -0
  140. package/src/components/list-group/list-group.types.ts +14 -0
  141. package/src/components/list-group/list-group.variants.test.ts +223 -0
  142. package/src/components/list-group/list-group.variants.ts +107 -0
  143. package/src/components/pressable/AGENTS.md +64 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +256 -0
  146. package/src/components/pressable/pressable.variants.test.ts +99 -0
  147. package/src/components/pressable/pressable.variants.ts +63 -0
  148. package/src/components/provider/AGENTS.md +90 -0
  149. package/src/components/provider/index.ts +1 -0
  150. package/src/components/provider/provider.tsx +105 -0
  151. package/src/components/radio/AGENTS.md +247 -0
  152. package/src/components/radio/index.ts +35 -0
  153. package/src/components/radio/radio-group.tsx +126 -0
  154. package/src/components/radio/radio-indicator.tsx +91 -0
  155. package/src/components/radio/radio-label.tsx +39 -0
  156. package/src/components/radio/radio.context.tsx +132 -0
  157. package/src/components/radio/radio.tsx +215 -0
  158. package/src/components/radio/radio.variants.test.ts +581 -0
  159. package/src/components/radio/radio.variants.ts +272 -0
  160. package/src/components/screen/AGENTS.md +287 -0
  161. package/src/components/screen/index.ts +85 -0
  162. package/src/components/screen/screen-chat-list.tsx +485 -0
  163. package/src/components/screen/screen-content.tsx +69 -0
  164. package/src/components/screen/screen-debug.ts +43 -0
  165. package/src/components/screen/screen-error.tsx +61 -0
  166. package/src/components/screen/screen-flat-list.tsx +88 -0
  167. package/src/components/screen/screen-footer-background.tsx +60 -0
  168. package/src/components/screen/screen-footer.tsx +170 -0
  169. package/src/components/screen/screen-header.tsx +36 -0
  170. package/src/components/screen/screen-legend-list.tsx +105 -0
  171. package/src/components/screen/screen-list-component.tsx +23 -0
  172. package/src/components/screen/screen-loading.tsx +50 -0
  173. package/src/components/screen/screen-navbar-back-button.tsx +79 -0
  174. package/src/components/screen/screen-navbar-background.tsx +45 -0
  175. package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
  176. package/src/components/screen/screen-navbar-title.tsx +31 -0
  177. package/src/components/screen/screen-navbar.tsx +154 -0
  178. package/src/components/screen/screen-root.tsx +38 -0
  179. package/src/components/screen/screen-scroll-area.tsx +116 -0
  180. package/src/components/screen/screen-scroll-shadow.tsx +186 -0
  181. package/src/components/screen/screen-section-list.tsx +81 -0
  182. package/src/components/screen/screen-view.tsx +59 -0
  183. package/src/components/screen/screen.context.tsx +167 -0
  184. package/src/components/screen/screen.tsx +114 -0
  185. package/src/components/screen/screen.types.ts +66 -0
  186. package/src/components/screen/screen.variants.test.ts +602 -0
  187. package/src/components/screen/screen.variants.ts +475 -0
  188. package/src/components/screen/use-screen-scroll-insets.ts +218 -0
  189. package/src/components/separator/AGENTS.md +30 -0
  190. package/src/components/separator/index.ts +8 -0
  191. package/src/components/separator/separator.tsx +85 -0
  192. package/src/components/slider/AGENTS.md +274 -0
  193. package/src/components/slider/index.ts +48 -0
  194. package/src/components/slider/slider-fill.tsx +68 -0
  195. package/src/components/slider/slider-output.tsx +55 -0
  196. package/src/components/slider/slider-thumb.tsx +161 -0
  197. package/src/components/slider/slider-track.tsx +233 -0
  198. package/src/components/slider/slider.context.tsx +118 -0
  199. package/src/components/slider/slider.tsx +321 -0
  200. package/src/components/slider/slider.types.ts +26 -0
  201. package/src/components/slider/slider.variants.test.ts +804 -0
  202. package/src/components/slider/slider.variants.ts +607 -0
  203. package/src/components/spinner/AGENTS.md +73 -0
  204. package/src/components/spinner/index.ts +17 -0
  205. package/src/components/spinner/spinner-arc.tsx +90 -0
  206. package/src/components/spinner/spinner-content.tsx +63 -0
  207. package/src/components/spinner/spinner.context.tsx +52 -0
  208. package/src/components/spinner/spinner.tsx +128 -0
  209. package/src/components/spinner/spinner.variants.test.ts +273 -0
  210. package/src/components/spinner/spinner.variants.ts +187 -0
  211. package/src/components/switch/AGENTS.md +213 -0
  212. package/src/components/switch/index.ts +42 -0
  213. package/src/components/switch/switch-content.tsx +111 -0
  214. package/src/components/switch/switch-end-content.tsx +18 -0
  215. package/src/components/switch/switch-start-content.tsx +20 -0
  216. package/src/components/switch/switch-thumb.tsx +102 -0
  217. package/src/components/switch/switch.context.tsx +79 -0
  218. package/src/components/switch/switch.tsx +423 -0
  219. package/src/components/switch/switch.types.ts +14 -0
  220. package/src/components/switch/switch.variants.test.ts +570 -0
  221. package/src/components/switch/switch.variants.ts +511 -0
  222. package/src/components/tabs/AGENTS.md +286 -0
  223. package/src/components/tabs/index.ts +69 -0
  224. package/src/components/tabs/tabs-content.tsx +65 -0
  225. package/src/components/tabs/tabs-indicator.tsx +97 -0
  226. package/src/components/tabs/tabs-label.tsx +62 -0
  227. package/src/components/tabs/tabs-list.tsx +139 -0
  228. package/src/components/tabs/tabs-pager.tsx +59 -0
  229. package/src/components/tabs/tabs-scroll-view.tsx +171 -0
  230. package/src/components/tabs/tabs-separator.tsx +73 -0
  231. package/src/components/tabs/tabs-trigger.tsx +210 -0
  232. package/src/components/tabs/tabs.context.tsx +294 -0
  233. package/src/components/tabs/tabs.tsx +435 -0
  234. package/src/components/tabs/tabs.types.ts +13 -0
  235. package/src/components/tabs/tabs.variants.test.ts +1020 -0
  236. package/src/components/tabs/tabs.variants.ts +672 -0
  237. package/src/components/text/AGENTS.md +95 -0
  238. package/src/components/text/index.ts +25 -0
  239. package/src/components/text/text.context.tsx +60 -0
  240. package/src/components/text/text.tsx +251 -0
  241. package/src/components/text/text.variants.test.ts +422 -0
  242. package/src/components/text/text.variants.ts +282 -0
  243. package/src/display-name.test.ts +145 -0
  244. package/src/docs.test.ts +98 -0
  245. package/src/expo/navigation-theme.tsx +58 -0
  246. package/src/hooks/use-controllable-state.ts +45 -0
  247. package/src/hooks/use-keyboard-state-sync.tsx +147 -0
  248. package/src/hooks/use-navigation-theme.ts +78 -0
  249. package/src/hooks/use-theme-color.ts +44 -0
  250. package/src/icons/central.ts +1 -0
  251. package/src/lib/cn.test.ts +136 -0
  252. package/src/lib/cn.ts +29 -0
  253. package/src/lib/color.test.ts +80 -0
  254. package/src/lib/color.ts +79 -0
  255. package/src/lib/compose-refs.test.ts +64 -0
  256. package/src/lib/compose-refs.ts +37 -0
  257. package/src/lib/keyboard-animation.test.ts +33 -0
  258. package/src/lib/keyboard-animation.ts +28 -0
  259. package/src/lib/merge-props.test.ts +82 -0
  260. package/src/lib/merge-props.ts +47 -0
  261. package/src/lib/navigation-theme.test.ts +49 -0
  262. package/src/lib/navigation-theme.ts +53 -0
  263. package/src/lib/slot.tsx +45 -0
  264. package/src/lib/tv.ts +20 -0
  265. package/src/styles/base.css +2 -0
  266. package/src/styles/geometry.test.ts +117 -0
  267. package/src/styles/index.css +3 -0
  268. package/src/styles/theme-tokens.test.ts +252 -0
  269. package/src/styles/theme.css +447 -0
  270. package/src/styles/tokens.css +132 -0
  271. package/src/styles/tokens.test.ts +181 -0
  272. package/src/styles/tokens.ts +86 -0
  273. package/src/uniwind-env.d.ts +1 -0
@@ -0,0 +1,1020 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { declaredTokens } from "../../styles/theme-tokens.test";
3
+ import { ICON_SIZE_TOKENS } from "../../styles/tokens";
4
+ import { TEXT_SIZES } from "../text/text.variants";
5
+ import {
6
+ isTriggerOrderConsistent,
7
+ resolveContentAccessibility,
8
+ resolveInitialValue,
9
+ resolveMeasurementTracks,
10
+ resolvePagerTranslate,
11
+ resolvePanOrigin,
12
+ resolvePanPosition,
13
+ resolveReconcileMode,
14
+ resolveScrollOffset,
15
+ resolveSeparatorIndices,
16
+ resolveSeparatorOpacity,
17
+ resolveSettleIndex,
18
+ resolveTabIndex,
19
+ resolveTabOrder,
20
+ resolveTabSelectedness,
21
+ resolveTabsTriggerState,
22
+ resolveVisualIndex,
23
+ shouldEmitTabChange,
24
+ TABS_DEFAULT_SCROLL_ALIGN,
25
+ TABS_DEFAULT_SIZE,
26
+ TABS_DEFAULT_VARIANT,
27
+ TABS_FOREGROUND_TOKEN,
28
+ TABS_LABEL_TEXT_SIZE,
29
+ TABS_PAN,
30
+ TABS_SCROLL_ALIGNS,
31
+ TABS_SCROLL_INSET,
32
+ TABS_SEPARATOR_FADE,
33
+ TABS_SETTLE_SPRING,
34
+ TABS_SIZES,
35
+ TABS_VARIANTS,
36
+ TABS_VISUAL_HYSTERESIS,
37
+ tabsVariants,
38
+ } from "./tabs.variants";
39
+
40
+ const LIGHT = declaredTokens("light");
41
+ const DARK = declaredTokens("dark");
42
+
43
+ /** Tailwind's own spacing scale, in points. `p-1` is four. */
44
+ const SPACING_STEP_PX = 4;
45
+
46
+ /** A slot's class string, with `tv`'s empty-slot `undefined` flattened. */
47
+ function cls(value: string | undefined): string {
48
+ return value ?? "";
49
+ }
50
+
51
+ /** The gap step a class string sets — `gap-2.5` yields 2.5. */
52
+ function gapStep(value: string): number {
53
+ return Number(value.match(/\bgap-(\d+(?:\.\d+)?)\b/)?.[1]);
54
+ }
55
+
56
+ /** The `min-h-*` step a class string sets — `min-h-11` yields 11. */
57
+ function minHeightStep(value: string): number {
58
+ return Number(value.match(/\bmin-h-(\d+(?:\.\d+)?)\b/)?.[1]);
59
+ }
60
+
61
+ /** The `p-*` step a class string sets — `p-0.5` yields 0.5. */
62
+ function paddingStep(value: string): number {
63
+ return Number(value.match(/\bp-(\d+(?:\.\d+)?)\b/)?.[1]);
64
+ }
65
+
66
+ /** The `rounded-*` step a class string names — `rounded-xl` yields `xl`. */
67
+ function radiusStep(value: string): string {
68
+ return value.match(/\brounded-([\w]+)\b/)?.[1] ?? "";
69
+ }
70
+
71
+ /** Every `text-<colour>` a class string names, ignoring the type scale. */
72
+ function textColors(value: string): string[] {
73
+ return [...value.matchAll(/\btext-([a-z][\w-]*)\b/g)]
74
+ .map(([, name]) => name)
75
+ .filter((name) => !TEXT_SIZES.includes(name as (typeof TEXT_SIZES)[number]));
76
+ }
77
+
78
+ type Cell = {
79
+ isDisabled: boolean;
80
+ isScrollable: boolean;
81
+ size: (typeof TABS_SIZES)[number];
82
+ variant: (typeof TABS_VARIANTS)[number];
83
+ };
84
+
85
+ /** Every combination of the axes that paint a tab bar, as `tv` props. */
86
+ function everyCell(): Cell[] {
87
+ const cells: Cell[] = [];
88
+ for (const variant of TABS_VARIANTS) {
89
+ for (const size of TABS_SIZES) {
90
+ for (const isDisabled of [false, true]) {
91
+ for (const isScrollable of [false, true]) {
92
+ cells.push({ isDisabled, isScrollable, size, variant });
93
+ }
94
+ }
95
+ }
96
+ }
97
+ return cells;
98
+ }
99
+
100
+ const CELLS = everyCell();
101
+
102
+ describe("the token readers", () => {
103
+ test("find both theme variants", () => {
104
+ expect(LIGHT.size).toBeGreaterThan(0);
105
+ expect(DARK.size).toBeGreaterThan(0);
106
+ });
107
+ });
108
+
109
+ describe("the matrix", () => {
110
+ test("covers every axis", () => {
111
+ expect(CELLS).toHaveLength(TABS_VARIANTS.length * TABS_SIZES.length * 2 * 2);
112
+ });
113
+ });
114
+
115
+ describe("the root slot", () => {
116
+ test("never takes a width, so a Tabs can be content-sized inside a row", () => {
117
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).root())).not.toMatch(/\bw-/);
118
+ });
119
+
120
+ test("paints no surface — the track is the list's", () => {
121
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).root())).not.toMatch(/\bbg-/);
122
+ });
123
+
124
+ test("steps its gap with size", () => {
125
+ const gaps = TABS_SIZES.map((size) => gapStep(cls(tabsVariants({ size }).root())));
126
+ expect(new Set(gaps).size).toBe(TABS_SIZES.length);
127
+ expect([...gaps]).toEqual([...gaps].sort((a, b) => a - b));
128
+ });
129
+
130
+ test("merges an incoming className last", () => {
131
+ expect(tabsVariants({ size: "md" }).root({ className: "gap-10" })).toContain("gap-10");
132
+ expect(tabsVariants({ size: "md" }).root({ className: "gap-10" })).not.toContain("gap-4");
133
+ });
134
+ });
135
+
136
+ describe("the list slot — the track", () => {
137
+ test("paints a track on primary and on nothing else", () => {
138
+ for (const cell of CELLS) {
139
+ const list = cls(tabsVariants(cell).list());
140
+ if (cell.variant === "primary") expect(list).toMatch(/\bbg-/);
141
+ else expect(list).not.toMatch(/\bbg-/);
142
+ }
143
+ });
144
+
145
+ test("clips, so the capsule cannot round its corners past the track's", () => {
146
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).list())).toContain("overflow-hidden");
147
+ });
148
+
149
+ test("pads only where there is a track to pad", () => {
150
+ for (const cell of CELLS) {
151
+ const list = cls(tabsVariants(cell).list());
152
+ if (cell.variant === "primary") expect(list).toMatch(/\bp-/);
153
+ else expect(list).not.toMatch(/\bp-/);
154
+ }
155
+ });
156
+
157
+ test("carries no text colour, in any cell", () => {
158
+ for (const cell of CELLS) expect(textColors(cls(tabsVariants(cell).list()))).toHaveLength(0);
159
+ });
160
+
161
+ test("never carries the row's own layout, which would flatten the capsule's inset", () => {
162
+ // The track and the row have to be two elements. An absolutely positioned
163
+ // indicator resolves its insets against its parent's padding box, so a track
164
+ // that was also the row would hand the capsule the track's full height while
165
+ // the triggers sat inside the padding — the capsule and the track then have
166
+ // the same radius, offset sideways, and their curves cross at the ends.
167
+ for (const cell of CELLS) {
168
+ expect(cls(tabsVariants(cell).list())).not.toContain("flex-row");
169
+ expect(cls(tabsVariants(cell).list())).not.toContain("items-stretch");
170
+ }
171
+ });
172
+ });
173
+
174
+ describe("the row slot", () => {
175
+ test("lays the triggers out on one row, stretched to a common height", () => {
176
+ for (const cell of CELLS) {
177
+ const row = cls(tabsVariants(cell).row());
178
+ expect(row).toContain("flex-row");
179
+ expect(row).toContain("items-stretch");
180
+ }
181
+ });
182
+
183
+ test("steps its gap with size", () => {
184
+ const gaps = TABS_SIZES.map((size) => gapStep(cls(tabsVariants({ size }).row())));
185
+ expect(new Set(gaps).size).toBe(TABS_SIZES.length);
186
+ expect([...gaps]).toEqual([...gaps].sort((a, b) => a - b));
187
+ });
188
+
189
+ test("paints no surface of its own", () => {
190
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).row())).not.toMatch(/\bbg-/);
191
+ });
192
+ });
193
+
194
+ describe("the indicator slot", () => {
195
+ test("is absolutely positioned in every cell", () => {
196
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).indicator())).toContain("absolute");
197
+ });
198
+
199
+ test("names a fill in every cell", () => {
200
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).indicator())).toMatch(/\bbg-/);
201
+ });
202
+
203
+ test("claims neither a width nor a horizontal offset, which the animated style owns", () => {
204
+ for (const cell of CELLS) {
205
+ const indicator = cls(tabsVariants(cell).indicator());
206
+ expect(indicator).not.toMatch(/\bw-/);
207
+ expect(indicator).not.toMatch(/\bleft-/);
208
+ expect(indicator).not.toMatch(/\bright-/);
209
+ expect(indicator).not.toMatch(/\binset-x-/);
210
+ }
211
+ });
212
+
213
+ test("claims no transform, which the animated style owns", () => {
214
+ for (const cell of CELLS) {
215
+ const indicator = cls(tabsVariants(cell).indicator());
216
+ expect(indicator).not.toMatch(/\btranslate-/);
217
+ expect(indicator).not.toMatch(/\bscale-/);
218
+ }
219
+ });
220
+
221
+ test("spans the trigger on primary, and only its bottom edge on secondary", () => {
222
+ for (const cell of CELLS) {
223
+ const indicator = cls(tabsVariants(cell).indicator());
224
+ if (cell.variant === "secondary") {
225
+ expect(indicator).toContain("bottom-0");
226
+ expect(indicator).toMatch(/\bh-/);
227
+ } else {
228
+ expect(indicator).toContain("inset-y-0");
229
+ }
230
+ }
231
+ });
232
+
233
+ test("carries no text colour, in any cell", () => {
234
+ for (const cell of CELLS) expect(textColors(cls(tabsVariants(cell).indicator()))).toHaveLength(0);
235
+ });
236
+ });
237
+
238
+ describe("the trigger slot", () => {
239
+ test("never carries an opacity, which Pressable's animated style would overwrite", () => {
240
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).trigger())).not.toMatch(/\bopacity-/);
241
+ });
242
+
243
+ test("floors its height rather than fixing it", () => {
244
+ for (const cell of CELLS) {
245
+ const trigger = cls(tabsVariants(cell).trigger());
246
+ expect(trigger).toMatch(/\bmin-h-/);
247
+ expect(trigger).not.toMatch(/(?<!min-)\bh-\d/);
248
+ }
249
+ });
250
+
251
+ test("gives every size a distinct floor, increasing with it", () => {
252
+ const floors = TABS_SIZES.map((size) => minHeightStep(cls(tabsVariants({ size }).trigger())));
253
+ expect(new Set(floors).size).toBe(TABS_SIZES.length);
254
+ expect([...floors]).toEqual([...floors].sort((a, b) => a - b));
255
+ });
256
+
257
+ test("clears the platform hit target at the default size", () => {
258
+ const floor = minHeightStep(cls(tabsVariants({ size: TABS_DEFAULT_SIZE }).trigger()));
259
+ expect(floor * SPACING_STEP_PX).toBeGreaterThanOrEqual(44);
260
+ });
261
+
262
+ test("fills the row when the list does not scroll", () => {
263
+ for (const cell of CELLS) {
264
+ const trigger = cls(tabsVariants(cell).trigger());
265
+ if (cell.isScrollable) expect(trigger).not.toContain("flex-1");
266
+ else expect(trigger).toContain("flex-1");
267
+ }
268
+ });
269
+
270
+ test("paints no surface of its own, in any cell", () => {
271
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).trigger())).not.toMatch(/\bbg-/);
272
+ });
273
+
274
+ test("carries no text colour, in any cell", () => {
275
+ for (const cell of CELLS) expect(textColors(cls(tabsVariants(cell).trigger()))).toHaveLength(0);
276
+ });
277
+
278
+ test("steps its gap with size", () => {
279
+ const gaps = TABS_SIZES.map((size) => gapStep(cls(tabsVariants({ size }).trigger())));
280
+ expect(new Set(gaps).size).toBe(TABS_SIZES.length);
281
+ expect([...gaps]).toEqual([...gaps].sort((a, b) => a - b));
282
+ });
283
+ });
284
+
285
+ describe("the icon slot", () => {
286
+ test("indexes the shared icon scale rather than carrying a private number", () => {
287
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).icon())).toMatch(/\bsize-icon-[\w-]+\b/);
288
+ });
289
+
290
+ test("steps the glyph with the bar, and keeps every step on that scale", () => {
291
+ const steps = TABS_SIZES.map((size) => cls(tabsVariants({ size }).icon()).match(/\bsize-(icon-[\w-]+)\b/)?.[1]);
292
+ expect(new Set(steps).size).toBe(TABS_SIZES.length);
293
+ for (const step of steps) {
294
+ expect(ICON_SIZE_TOKENS).toContain(step as (typeof ICON_SIZE_TOKENS)[number]);
295
+ }
296
+ });
297
+ });
298
+
299
+ describe("the label slot", () => {
300
+ test("restates no type scale", () => {
301
+ for (const cell of CELLS) {
302
+ const label = cls(tabsVariants(cell).label());
303
+ expect(label).not.toMatch(/\bfont-/);
304
+ for (const size of TEXT_SIZES) expect(label).not.toContain(`text-${size}`);
305
+ }
306
+ });
307
+
308
+ test("names no colour at all, in any cell", () => {
309
+ // The label's colour fades between two tokens on the UI thread, so it is an
310
+ // animated style. A class here would be a second source for one colour, and
311
+ // a class and a style disagreeing for a frame is what `Checkbox`'s animated
312
+ // border exists to avoid. `TABS_FOREGROUND_TOKEN` is the matrix instead.
313
+ for (const cell of CELLS) expect(textColors(cls(tabsVariants(cell).label()))).toHaveLength(0);
314
+ });
315
+
316
+ test("fades when the trigger is disabled, and only then", () => {
317
+ for (const cell of CELLS) {
318
+ const label = cls(tabsVariants(cell).label());
319
+ if (cell.isDisabled) expect(label).toMatch(/\bopacity-/);
320
+ else expect(label).not.toMatch(/\bopacity-/);
321
+ }
322
+ });
323
+
324
+ test("merges an incoming className last", () => {
325
+ expect(tabsVariants().label({ className: "shrink-0" })).toContain("shrink-0");
326
+ });
327
+ });
328
+
329
+ describe("the separator slot", () => {
330
+ test("names no colour — the line itself is a Separator", () => {
331
+ for (const cell of CELLS) {
332
+ expect(cls(tabsVariants(cell).separator())).not.toMatch(/\bbg-/);
333
+ expect(textColors(cls(tabsVariants(cell).separator()))).toHaveLength(0);
334
+ }
335
+ });
336
+
337
+ test("claims no opacity, which the fade owns", () => {
338
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).separator())).not.toMatch(/\bopacity-/);
339
+ });
340
+
341
+ test("lays its rule out on a row, or the rule has no length at all", () => {
342
+ // A vertical `Separator` is `self-stretch w-px` and takes its length from
343
+ // the cross axis of whatever holds it. In a column that axis is horizontal,
344
+ // so the rule comes out full width and zero height and draws nothing —
345
+ // found on a simulator, and invisible to every other assertion here.
346
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).separator())).toContain("flex-row");
347
+ });
348
+
349
+ test("steps its height with size", () => {
350
+ const heights = TABS_SIZES.map((size) => Number(cls(tabsVariants({ size }).separator()).match(/\bh-(\d+)\b/)?.[1]));
351
+ expect(new Set(heights).size).toBe(TABS_SIZES.length);
352
+ expect([...heights]).toEqual([...heights].sort((a, b) => a - b));
353
+ });
354
+ });
355
+
356
+ describe("the pager slots", () => {
357
+ test("clip the viewport, so a neighbouring panel cannot paint outside it", () => {
358
+ for (const cell of CELLS) expect(cls(tabsVariants(cell).pager())).toContain("overflow-hidden");
359
+ });
360
+
361
+ test("lay the panels out as one stretched row", () => {
362
+ for (const cell of CELLS) {
363
+ const row = cls(tabsVariants(cell).pageRow());
364
+ expect(row).toContain("flex-row");
365
+ expect(row).toContain("items-stretch");
366
+ }
367
+ });
368
+
369
+ test("size every page to one viewport and stop it shrinking", () => {
370
+ for (const cell of CELLS) {
371
+ const page = cls(tabsVariants(cell).page());
372
+ expect(page).toContain("w-full");
373
+ expect(page).toContain("shrink-0");
374
+ }
375
+ });
376
+ });
377
+
378
+ describe("the variant matrix", () => {
379
+ test("gives every variant a treatment of its own, across the track, the indicator and the selected label", () => {
380
+ const treatments = TABS_VARIANTS.map((variant) => {
381
+ const slots = tabsVariants({ variant });
382
+ const tokens = TABS_FOREGROUND_TOKEN[variant];
383
+ return [cls(slots.list()), cls(slots.indicator()), tokens.selected, tokens.unselected].join("|");
384
+ });
385
+ expect(new Set(treatments).size).toBe(TABS_VARIANTS.length);
386
+ });
387
+ });
388
+
389
+ describe("the track and its capsule", () => {
390
+ test("round both fully, so the capsule is concentric at any padding", () => {
391
+ // A pill inside a pill is concentric whatever the gap, which is why there is
392
+ // no radius arithmetic in this component and no per-size radius maps to keep
393
+ // in step. Checkbox subtracts a border width from a named step precisely
394
+ // because a rounded *rectangle* does not get this for free.
395
+ for (const size of TABS_SIZES) {
396
+ expect(radiusStep(cls(tabsVariants({ size, variant: "primary" }).list()))).toBe("full");
397
+ expect(radiusStep(cls(tabsVariants({ size, variant: "primary" }).indicator()))).toBe("full");
398
+ }
399
+ });
400
+
401
+ test("name no numeric radius step, at any size", () => {
402
+ for (const size of TABS_SIZES) {
403
+ expect(cls(tabsVariants({ size, variant: "primary" }).list())).not.toMatch(/\brounded-(xs|sm|md|lg|xl|2xl)\b/);
404
+ expect(cls(tabsVariants({ size, variant: "primary" }).indicator())).not.toMatch(
405
+ /\brounded-(xs|sm|md|lg|xl|2xl)\b/
406
+ );
407
+ }
408
+ });
409
+
410
+ test("pad the track, and step that padding with size", () => {
411
+ const steps = TABS_SIZES.map((size) => paddingStep(cls(tabsVariants({ size, variant: "primary" }).list())));
412
+ for (const step of steps) expect(step).toBeGreaterThan(0);
413
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
414
+ });
415
+
416
+ test("paints the capsule on a surface that sits above the track", () => {
417
+ // `card` cannot do this job — it is darker than `muted` in the dark theme, so
418
+ // the capsule would read as sunken rather than raised. See theme.css.
419
+ const indicator = cls(tabsVariants({ variant: "primary" }).indicator());
420
+ expect(indicator).toContain("bg-elevated");
421
+ expect(cls(tabsVariants({ variant: "primary" }).list())).toContain("bg-muted");
422
+ });
423
+
424
+ test("declares both of those surfaces in both themes", () => {
425
+ // A token missing from one variant resolves to nothing and the capsule is
426
+ // drawn in whatever the fallback happens to be — silent, and visible in one
427
+ // theme only. The reader `badge.variants.test.ts` uses.
428
+ for (const token of ["elevated", "muted"]) {
429
+ expect(LIGHT.has(token)).toBe(true);
430
+ expect(DARK.has(token)).toBe(true);
431
+ }
432
+ });
433
+ });
434
+
435
+ describe("TABS_FOREGROUND_TOKEN", () => {
436
+ test("names a token for every variant, selected and not", () => {
437
+ for (const variant of TABS_VARIANTS) {
438
+ expect(TABS_FOREGROUND_TOKEN[variant].selected.length).toBeGreaterThan(0);
439
+ expect(TABS_FOREGROUND_TOKEN[variant].unselected.length).toBeGreaterThan(0);
440
+ }
441
+ });
442
+
443
+ test("gives the selected end a colour of its own, in every variant", () => {
444
+ // Two ends collapsing means the label crossfades from a colour to itself and
445
+ // selecting a tab changes nothing about its text.
446
+ for (const variant of TABS_VARIANTS) {
447
+ expect(TABS_FOREGROUND_TOKEN[variant].selected).not.toBe(TABS_FOREGROUND_TOKEN[variant].unselected);
448
+ }
449
+ });
450
+
451
+ test("is the only place either colour is named", () => {
452
+ // If a colour ever reappears on the `label` slot, the fade and the class
453
+ // become two sources for one value. This is the test that catches it.
454
+ for (const cell of CELLS) expect(textColors(cls(tabsVariants(cell).label()))).toHaveLength(0);
455
+ });
456
+
457
+ test("declares every token it names in both themes", () => {
458
+ for (const variant of TABS_VARIANTS) {
459
+ for (const token of [TABS_FOREGROUND_TOKEN[variant].selected, TABS_FOREGROUND_TOKEN[variant].unselected]) {
460
+ expect(LIGHT.has(token)).toBe(true);
461
+ expect(DARK.has(token)).toBe(true);
462
+ }
463
+ }
464
+ });
465
+ });
466
+
467
+ describe("TABS_LABEL_TEXT_SIZE", () => {
468
+ test("covers every tabs size with a size Text actually has", () => {
469
+ for (const size of TABS_SIZES) {
470
+ expect(TEXT_SIZES).toContain(TABS_LABEL_TEXT_SIZE[size]);
471
+ }
472
+ });
473
+
474
+ test("grows with the bar", () => {
475
+ const steps = TABS_SIZES.map((size) => TEXT_SIZES.indexOf(TABS_LABEL_TEXT_SIZE[size]));
476
+ expect(new Set(steps).size).toBe(TABS_SIZES.length);
477
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
478
+ });
479
+ });
480
+
481
+ describe("the gap ladder", () => {
482
+ test("binds a label to the glyph beside it before the trigger beside that", () => {
483
+ for (const size of TABS_SIZES) {
484
+ const trigger = gapStep(cls(tabsVariants({ size }).trigger()));
485
+ const row = gapStep(cls(tabsVariants({ size }).row()));
486
+ const root = gapStep(cls(tabsVariants({ size }).root()));
487
+ expect(trigger).toBeLessThan(row);
488
+ expect(row).toBeLessThan(root);
489
+ }
490
+ });
491
+ });
492
+
493
+ describe("the defaults", () => {
494
+ test("the named constants are the ones tv falls back to", () => {
495
+ expect(cls(tabsVariants().list())).toBe(cls(tabsVariants({ variant: TABS_DEFAULT_VARIANT }).list()));
496
+ expect(cls(tabsVariants().trigger())).toBe(cls(tabsVariants({ size: TABS_DEFAULT_SIZE }).trigger()));
497
+ });
498
+
499
+ test("the default scroll alignment is one the resolver handles", () => {
500
+ expect(TABS_SCROLL_ALIGNS).toContain(TABS_DEFAULT_SCROLL_ALIGN);
501
+ });
502
+ });
503
+
504
+ describe("TABS_SETTLE_SPRING", () => {
505
+ test("describes a spring that actually springs", () => {
506
+ expect(TABS_SETTLE_SPRING.mass).toBeGreaterThan(0);
507
+ expect(TABS_SETTLE_SPRING.stiffness).toBeGreaterThan(0);
508
+ expect(TABS_SETTLE_SPRING.damping).toBeGreaterThan(0);
509
+ expect(TABS_SETTLE_SPRING.damping).toBeLessThan(
510
+ 2 * Math.sqrt(TABS_SETTLE_SPRING.stiffness * TABS_SETTLE_SPRING.mass)
511
+ );
512
+ });
513
+
514
+ test("names no clamp, which belongs to a branch of SpringConfig this one is not on", () => {
515
+ expect("clamp" in TABS_SETTLE_SPRING).toBe(false);
516
+ });
517
+ });
518
+
519
+ describe("TABS_PAN", () => {
520
+ test("gives a sideways drag a head start over a vertical one", () => {
521
+ expect(TABS_PAN.activateX).toBeLessThan(TABS_PAN.failY);
522
+ });
523
+
524
+ test("sets a positive fling threshold and a bounded overscroll", () => {
525
+ expect(TABS_PAN.flingVelocity).toBeGreaterThan(0);
526
+ expect(TABS_PAN.overscroll).toBeGreaterThan(0);
527
+ expect(TABS_PAN.overscroll).toBeLessThan(1);
528
+ });
529
+ });
530
+
531
+ describe("the remaining tuning constants", () => {
532
+ test("TABS_VISUAL_HYSTERESIS stays under the half-tab that would freeze it", () => {
533
+ expect(TABS_VISUAL_HYSTERESIS).toBeGreaterThanOrEqual(0);
534
+ expect(TABS_VISUAL_HYSTERESIS).toBeLessThan(0.5);
535
+ });
536
+
537
+ test("TABS_SEPARATOR_FADE holds before it ramps", () => {
538
+ expect(TABS_SEPARATOR_FADE.hold).toBeLessThan(TABS_SEPARATOR_FADE.distance);
539
+ });
540
+
541
+ test("TABS_SCROLL_INSET always leaves a sliver of the neighbour", () => {
542
+ expect(TABS_SCROLL_INSET).toBeGreaterThan(0);
543
+ });
544
+ });
545
+
546
+ describe("resolveTabOrder", () => {
547
+ test("reads the panels' values in source order", () => {
548
+ expect(resolveTabOrder(["a", "b"], ["b", "a"])).toEqual(["a", "b"]);
549
+ });
550
+
551
+ test("falls back to registration when there are no panels", () => {
552
+ expect(resolveTabOrder([], ["x", "y"])).toEqual(["x", "y"]);
553
+ });
554
+
555
+ test("reports no order when there is neither", () => {
556
+ expect(resolveTabOrder([], [])).toEqual([]);
557
+ });
558
+
559
+ test("returns a copy, so a caller cannot mutate the source", () => {
560
+ const panels = ["a"];
561
+ expect(resolveTabOrder(panels, [])).not.toBe(panels);
562
+ });
563
+ });
564
+
565
+ describe("resolveTabIndex", () => {
566
+ test("finds a value's place", () => {
567
+ expect(resolveTabIndex(["a", "b", "c"], "b")).toBe(1);
568
+ });
569
+
570
+ test("reports -1 for a value no tab claims", () => {
571
+ expect(resolveTabIndex(["a"], "z")).toBe(-1);
572
+ });
573
+
574
+ test("reports -1 with nothing selected", () => {
575
+ expect(resolveTabIndex(["a"], null)).toBe(-1);
576
+ expect(resolveTabIndex(["a"], undefined)).toBe(-1);
577
+ });
578
+ });
579
+
580
+ describe("resolveInitialValue", () => {
581
+ test("takes the caller's default when given one", () => {
582
+ expect(resolveInitialValue(["a", "b"], "b")).toBe("b");
583
+ });
584
+
585
+ test("starts on the first tab otherwise, so the indicator always has somewhere to sit", () => {
586
+ expect(resolveInitialValue(["a", "b"], undefined)).toBe("a");
587
+ });
588
+
589
+ test("reports null only when there are no tabs at all", () => {
590
+ expect(resolveInitialValue([], undefined)).toBe(null);
591
+ });
592
+
593
+ test("respects an explicit null default", () => {
594
+ expect(resolveInitialValue(["a"], null)).toBe(null);
595
+ });
596
+ });
597
+
598
+ describe("shouldEmitTabChange", () => {
599
+ test("reports a real change", () => {
600
+ expect(shouldEmitTabChange("a", "b")).toBe(true);
601
+ expect(shouldEmitTabChange(null, "a")).toBe(true);
602
+ });
603
+
604
+ test("stays quiet for a re-press of the current tab", () => {
605
+ expect(shouldEmitTabChange("a", "a")).toBe(false);
606
+ });
607
+ });
608
+
609
+ describe("resolveMeasurementTracks", () => {
610
+ test("builds three arrays of equal length once every tab has measured", () => {
611
+ const tracks = resolveMeasurementTracks(["a", "b"], { a: { width: 40, x: 0 }, b: { width: 60, x: 44 } });
612
+ expect(tracks).toEqual({ index: [0, 1], width: [40, 60], x: [0, 44] });
613
+ });
614
+
615
+ test("reports nothing while any tab is still unmeasured", () => {
616
+ expect(resolveMeasurementTracks(["a", "b"], { a: { width: 40, x: 0 } })).toBe(null);
617
+ });
618
+
619
+ test("reports nothing with no tabs at all", () => {
620
+ expect(resolveMeasurementTracks([], {})).toBe(null);
621
+ });
622
+
623
+ test("pads a single tab to the two points interpolate needs", () => {
624
+ const tracks = resolveMeasurementTracks(["a"], { a: { width: 40, x: 8 } });
625
+ expect(tracks).toEqual({ index: [0, 1], width: [40, 40], x: [8, 8] });
626
+ });
627
+
628
+ test("keeps the three arrays the same length, whatever the tab count", () => {
629
+ const measured = { a: { width: 1, x: 0 }, b: { width: 2, x: 1 }, c: { width: 3, x: 3 } };
630
+ const tracks = resolveMeasurementTracks(["a", "b", "c"], measured);
631
+ expect(tracks?.index).toHaveLength(3);
632
+ expect(tracks?.width).toHaveLength(3);
633
+ expect(tracks?.x).toHaveLength(3);
634
+ });
635
+ });
636
+
637
+ describe("isTriggerOrderConsistent", () => {
638
+ test("accepts triggers laid out left to right in panel order", () => {
639
+ expect(isTriggerOrderConsistent([0, 44, 96])).toBe(true);
640
+ });
641
+
642
+ test("catches a bar whose triggers were written in a different order", () => {
643
+ expect(isTriggerOrderConsistent([96, 0, 44])).toBe(false);
644
+ });
645
+
646
+ test("catches every trigger reporting a wrapper-relative zero", () => {
647
+ expect(isTriggerOrderConsistent([0, 0, 0])).toBe(false);
648
+ });
649
+
650
+ test("accepts a single trigger, and none at all", () => {
651
+ expect(isTriggerOrderConsistent([12])).toBe(true);
652
+ expect(isTriggerOrderConsistent([])).toBe(true);
653
+ });
654
+ });
655
+
656
+ describe("resolveReconcileMode", () => {
657
+ test("does nothing when the target is already the selection", () => {
658
+ expect(resolveReconcileMode({ selectedIndex: 1, selectedValue: "b", targetIndex: 1, targetValue: "b" })).toBe(
659
+ "none"
660
+ );
661
+ });
662
+
663
+ test("does nothing when a controlled parent rejects the change", () => {
664
+ expect(resolveReconcileMode({ selectedIndex: 0, selectedValue: "a", targetIndex: 0, targetValue: "a" })).toBe(
665
+ "none"
666
+ );
667
+ });
668
+
669
+ test("does nothing when nothing is selected", () => {
670
+ expect(resolveReconcileMode({ selectedIndex: -1, selectedValue: null, targetIndex: 2, targetValue: "c" })).toBe(
671
+ "none"
672
+ );
673
+ });
674
+
675
+ test("springs to a tab the user chose", () => {
676
+ expect(resolveReconcileMode({ selectedIndex: 1, selectedValue: "b", targetIndex: 0, targetValue: "a" })).toBe(
677
+ "spring"
678
+ );
679
+ });
680
+
681
+ test("jumps when the same tab moved because the list changed", () => {
682
+ expect(resolveReconcileMode({ selectedIndex: 2, selectedValue: "b", targetIndex: 1, targetValue: "b" })).toBe(
683
+ "jump"
684
+ );
685
+ });
686
+ });
687
+
688
+ describe("resolveContentAccessibility", () => {
689
+ test("leaves the selected panel readable", () => {
690
+ expect(resolveContentAccessibility(true)).toEqual({
691
+ accessibilityElementsHidden: false,
692
+ importantForAccessibility: "auto",
693
+ });
694
+ });
695
+
696
+ test("hides every other panel on both platforms", () => {
697
+ expect(resolveContentAccessibility(false)).toEqual({
698
+ accessibilityElementsHidden: true,
699
+ importantForAccessibility: "no-hide-descendants",
700
+ });
701
+ });
702
+ });
703
+
704
+ describe("resolveSeparatorIndices", () => {
705
+ test("maps a pair of values onto their places", () => {
706
+ expect(resolveSeparatorIndices(["a", "b", "c"], ["b", "c"])).toEqual({ left: 1, right: 2 });
707
+ });
708
+
709
+ test("reports -1 for a value no tab claims", () => {
710
+ expect(resolveSeparatorIndices(["a"], ["a", "z"])).toEqual({ left: 0, right: -1 });
711
+ });
712
+ });
713
+
714
+ describe("resolveTabsTriggerState", () => {
715
+ test("resolves isDisabled nearest-first across all nine combinations", () => {
716
+ const expected: Record<string, boolean> = {
717
+ "undefined|undefined": false,
718
+ "undefined|true": true,
719
+ "undefined|false": false,
720
+ "true|undefined": true,
721
+ "true|true": true,
722
+ "true|false": true,
723
+ "false|undefined": false,
724
+ "false|true": false,
725
+ "false|false": false,
726
+ };
727
+ for (const own of [undefined, true, false]) {
728
+ for (const root of [undefined, true, false]) {
729
+ expect(resolveTabsTriggerState({ own, root }).isDisabled).toBe(expected[`${own}|${root}`]);
730
+ }
731
+ }
732
+ });
733
+
734
+ test("lets one trigger opt out of a disabled bar", () => {
735
+ expect(resolveTabsTriggerState({ own: false, root: true }).isDisabled).toBe(false);
736
+ });
737
+
738
+ test("lets one trigger disable itself inside a bar that names nothing", () => {
739
+ expect(resolveTabsTriggerState({ own: true }).isDisabled).toBe(true);
740
+ });
741
+ });
742
+
743
+ describe("resolvePagerTranslate", () => {
744
+ test("moves the row left as the position advances", () => {
745
+ expect(resolvePagerTranslate(1, 390)).toBe(-390);
746
+ expect(resolvePagerTranslate(2, 390)).toBe(-780);
747
+ });
748
+
749
+ test("leaves the first panel at rest", () => {
750
+ // `toBeCloseTo` rather than `toBe`: negating zero yields `-0`, which is a
751
+ // perfectly good translation and a failing `Object.is` comparison.
752
+ expect(resolvePagerTranslate(0, 390)).toBeCloseTo(0);
753
+ });
754
+
755
+ test("tracks a fractional position", () => {
756
+ expect(resolvePagerTranslate(0.5, 390)).toBe(-195);
757
+ });
758
+ });
759
+
760
+ describe("resolvePanOrigin", () => {
761
+ test("cancels out the travel that happened before the pan activated", () => {
762
+ // A pan activates only after the finger has crossed `activateX`, but
763
+ // `translationX` counts from touch-down. Feeding the origin straight back
764
+ // through `resolvePanPosition` has to return the position the pager was
765
+ // already at, or every drag jumps sideways on its first frame.
766
+ const position = 1;
767
+ const translationX = -TABS_PAN.activateX;
768
+ const pageWidth = 100;
769
+ const origin = resolvePanOrigin(position, translationX, pageWidth);
770
+ expect(resolvePanPosition({ count: 3, pageWidth, startPosition: origin, translationX })).toBeCloseTo(position);
771
+ });
772
+
773
+ test("round-trips from a fractional position, so a mid-spring grab does not jump", () => {
774
+ const position = 1.37;
775
+ const translationX = 24;
776
+ const pageWidth = 390;
777
+ const origin = resolvePanOrigin(position, translationX, pageWidth);
778
+ expect(resolvePanPosition({ count: 4, pageWidth, startPosition: origin, translationX })).toBeCloseTo(position);
779
+ });
780
+
781
+ test("stands still before the viewport has measured", () => {
782
+ expect(resolvePanOrigin(2, 50, 0)).toBe(2);
783
+ });
784
+ });
785
+
786
+ describe("resolvePanPosition", () => {
787
+ const base = { count: 3, pageWidth: 100, startPosition: 1 };
788
+
789
+ test("follows the finger one page at a time", () => {
790
+ expect(resolvePanPosition({ ...base, translationX: -50 })).toBeCloseTo(1.5);
791
+ expect(resolvePanPosition({ ...base, translationX: 50 })).toBeCloseTo(0.5);
792
+ });
793
+
794
+ test("stands still before the viewport has measured", () => {
795
+ expect(resolvePanPosition({ ...base, pageWidth: 0, translationX: -50 })).toBe(1);
796
+ });
797
+
798
+ test("resists rather than stopping dead before the first tab", () => {
799
+ const dragged = resolvePanPosition({ ...base, startPosition: 0, translationX: 200 });
800
+ expect(dragged).toBeLessThan(0);
801
+ expect(dragged).toBeGreaterThan(-TABS_PAN.overscroll);
802
+ });
803
+
804
+ test("resists rather than stopping dead past the last tab", () => {
805
+ const dragged = resolvePanPosition({ ...base, startPosition: 2, translationX: -200 });
806
+ expect(dragged).toBeGreaterThan(2);
807
+ expect(dragged).toBeLessThan(2 + TABS_PAN.overscroll);
808
+ });
809
+
810
+ test("never lets an unbounded drag escape the overscroll bound", () => {
811
+ const dragged = resolvePanPosition({ ...base, startPosition: 2, translationX: -100000 });
812
+ expect(dragged).toBeLessThan(2 + TABS_PAN.overscroll);
813
+ });
814
+ });
815
+
816
+ describe("resolveSettleIndex", () => {
817
+ const base = { count: 4, startIndex: 1, velocity: 0 };
818
+
819
+ test("stays put for a drag shorter than half a page", () => {
820
+ expect(resolveSettleIndex({ ...base, position: 1.3 })).toBe(1);
821
+ });
822
+
823
+ test("advances for a drag past the midpoint", () => {
824
+ expect(resolveSettleIndex({ ...base, position: 1.7 })).toBe(2);
825
+ });
826
+
827
+ test("advances on a flick that never crossed the midpoint", () => {
828
+ expect(resolveSettleIndex({ ...base, position: 1.1, velocity: TABS_PAN.flingVelocity })).toBe(2);
829
+ });
830
+
831
+ test("retreats on a flick the other way", () => {
832
+ expect(resolveSettleIndex({ ...base, position: 0.9, velocity: -TABS_PAN.flingVelocity })).toBe(0);
833
+ });
834
+
835
+ test("never skips a tab, however fast the flick", () => {
836
+ expect(resolveSettleIndex({ ...base, position: 1.05, velocity: 50 })).toBe(2);
837
+ });
838
+
839
+ test("cannot settle past the last tab", () => {
840
+ expect(resolveSettleIndex({ ...base, count: 2, position: 1.4, startIndex: 1, velocity: 50 })).toBe(1);
841
+ });
842
+
843
+ test("cannot settle before the first", () => {
844
+ expect(resolveSettleIndex({ ...base, position: 0.1, startIndex: 0, velocity: -50 })).toBe(0);
845
+ });
846
+
847
+ test("steps over a disabled tab in the direction of travel", () => {
848
+ expect(resolveSettleIndex({ ...base, isEnabled: [true, true, false, true], position: 1.8, startIndex: 1 })).toBe(3);
849
+ });
850
+
851
+ test("turns back when the direction of travel runs out of enabled tabs", () => {
852
+ expect(resolveSettleIndex({ ...base, isEnabled: [true, true, false, false], position: 2.2, startIndex: 1 })).toBe(
853
+ 1
854
+ );
855
+ });
856
+
857
+ test("never settles on an index no tab has, when the last tabs are disabled", () => {
858
+ const settled = resolveSettleIndex({
859
+ ...base,
860
+ isEnabled: [true, true, true, false],
861
+ position: 2.8,
862
+ startIndex: 2,
863
+ });
864
+ expect(settled).toBeLessThanOrEqual(3);
865
+ expect(settled).toBeGreaterThanOrEqual(0);
866
+ expect(settled).toBe(2);
867
+ });
868
+
869
+ test("never settles below zero, when the first tabs are disabled", () => {
870
+ const settled = resolveSettleIndex({
871
+ ...base,
872
+ isEnabled: [false, true, true, true],
873
+ position: 0.2,
874
+ startIndex: 1,
875
+ });
876
+ expect(settled).toBeGreaterThanOrEqual(0);
877
+ expect(settled).toBe(1);
878
+ });
879
+
880
+ test("settles on 0 with no tabs at all", () => {
881
+ expect(resolveSettleIndex({ ...base, count: 0, position: 0 })).toBe(0);
882
+ });
883
+ });
884
+
885
+ describe("resolveTabSelectedness", () => {
886
+ test("reads fully selected when the pager sits on the tab", () => {
887
+ expect(resolveTabSelectedness(1, 1)).toBe(1);
888
+ });
889
+
890
+ test("reads not selected at all once a whole tab away", () => {
891
+ expect(resolveTabSelectedness(0, 1)).toBe(0);
892
+ expect(resolveTabSelectedness(0, 3)).toBe(0);
893
+ });
894
+
895
+ test("splits evenly between two neighbours at the midpoint of a drag", () => {
896
+ expect(resolveTabSelectedness(0, 0.5)).toBeCloseTo(0.5);
897
+ expect(resolveTabSelectedness(1, 0.5)).toBeCloseTo(0.5);
898
+ });
899
+
900
+ test("is symmetric, so approaching from either side fades the same", () => {
901
+ expect(resolveTabSelectedness(1, 0.7)).toBeCloseTo(resolveTabSelectedness(1, 1.3));
902
+ });
903
+
904
+ test("stays within 0 and 1 across a rubber-banded overscroll", () => {
905
+ for (const position of [-TABS_PAN.overscroll, 0, 0.25, 1, 2.5, 3 + TABS_PAN.overscroll]) {
906
+ const value = resolveTabSelectedness(1, position);
907
+ expect(value).toBeGreaterThanOrEqual(0);
908
+ expect(value).toBeLessThanOrEqual(1);
909
+ }
910
+ });
911
+
912
+ test("reads nothing for a tab no panel claims", () => {
913
+ expect(resolveTabSelectedness(-1, 0)).toBe(0);
914
+ });
915
+ });
916
+
917
+ describe("resolveVisualIndex", () => {
918
+ test("holds the current tab until the pager is most of the way to the next", () => {
919
+ expect(resolveVisualIndex(1.4, 1, 3)).toBe(1);
920
+ });
921
+
922
+ test("swaps once the pager is past the midpoint and the hysteresis band", () => {
923
+ expect(resolveVisualIndex(1.7, 1, 3)).toBe(2);
924
+ });
925
+
926
+ test("holds inside the band, which is what stops a midpoint flicker", () => {
927
+ expect(resolveVisualIndex(1.5 + TABS_VISUAL_HYSTERESIS / 2, 1, 3)).toBe(1);
928
+ });
929
+
930
+ test("clamps to the tabs that exist", () => {
931
+ expect(resolveVisualIndex(9, 1, 3)).toBe(2);
932
+ expect(resolveVisualIndex(-9, 1, 3)).toBe(0);
933
+ });
934
+
935
+ test("reports 0 with no tabs at all", () => {
936
+ expect(resolveVisualIndex(0, 0, 0)).toBe(0);
937
+ });
938
+ });
939
+
940
+ describe("resolveSeparatorOpacity", () => {
941
+ test("shows the rule while the pager is parked on either of its neighbours", () => {
942
+ // The bar at rest shows every rule it has. The earlier shape hid the ones
943
+ // flanking the active tab, which on a three-tab bar left exactly one visible
944
+ // over on the far side and changed the set on every tab change.
945
+ expect(resolveSeparatorOpacity(0, 0, 1)).toBe(1);
946
+ expect(resolveSeparatorOpacity(1, 0, 1)).toBe(1);
947
+ });
948
+
949
+ test("hides it while the pager is crossing it", () => {
950
+ expect(resolveSeparatorOpacity(0.5, 0, 1)).toBe(0);
951
+ });
952
+
953
+ test("dips and returns across a full swipe over it", () => {
954
+ const ramp = [0, 0.25, 0.5, 0.75, 1].map((p) => resolveSeparatorOpacity(p, 0, 1));
955
+ expect(ramp[0]).toBe(1);
956
+ expect(ramp[2]).toBe(0);
957
+ expect(ramp[4]).toBe(1);
958
+ expect(ramp[1]).toBeLessThan(1);
959
+ expect(ramp[1]).toBeCloseTo(ramp[3]);
960
+ });
961
+
962
+ test("stays fully visible for a gap the pager is nowhere near", () => {
963
+ expect(resolveSeparatorOpacity(4, 0, 1)).toBe(1);
964
+ expect(resolveSeparatorOpacity(-2, 0, 1)).toBe(1);
965
+ });
966
+
967
+ test("never leaves the 0 to 1 range, across a rubber-banded overscroll", () => {
968
+ for (const position of [-TABS_PAN.overscroll, 0, 0.5, 1, 2, 3 + TABS_PAN.overscroll]) {
969
+ const value = resolveSeparatorOpacity(position, 1, 2);
970
+ expect(value).toBeGreaterThanOrEqual(0);
971
+ expect(value).toBeLessThanOrEqual(1);
972
+ }
973
+ });
974
+
975
+ test("stays visible for a pair naming a tab nothing claims", () => {
976
+ expect(resolveSeparatorOpacity(0, -1, 1)).toBe(1);
977
+ expect(resolveSeparatorOpacity(0, 0, -1)).toBe(1);
978
+ });
979
+
980
+ test("reads the pair in either order", () => {
981
+ expect(resolveSeparatorOpacity(0.5, 1, 0)).toBe(0);
982
+ expect(resolveSeparatorOpacity(0, 1, 0)).toBe(1);
983
+ });
984
+ });
985
+
986
+ describe("resolveScrollOffset", () => {
987
+ const base = { contentWidth: 1000, currentOffset: 120, viewportWidth: 400, width: 100, x: 450 };
988
+
989
+ test("reports nothing to scroll for none", () => {
990
+ expect(resolveScrollOffset({ ...base, align: "none" })).toBe(120);
991
+ });
992
+
993
+ test("puts the selected trigger at the leading edge for start", () => {
994
+ expect(resolveScrollOffset({ ...base, align: "start" })).toBe(450 - TABS_SCROLL_INSET);
995
+ });
996
+
997
+ test("centres it for center", () => {
998
+ expect(resolveScrollOffset({ ...base, align: "center" })).toBe(450 + 50 - 200);
999
+ });
1000
+
1001
+ test("puts it at the trailing edge for end", () => {
1002
+ expect(resolveScrollOffset({ ...base, align: "end" })).toBe(450 + 100 - 400 + TABS_SCROLL_INSET);
1003
+ });
1004
+
1005
+ test("clamps at zero, so the first tab cannot scroll the row past its own start", () => {
1006
+ expect(resolveScrollOffset({ ...base, align: "center", x: 0 })).toBe(0);
1007
+ });
1008
+
1009
+ test("clamps at the end, so the last tab cannot scroll past the content", () => {
1010
+ expect(resolveScrollOffset({ ...base, align: "center", x: 900 })).toBe(1000 - 400);
1011
+ });
1012
+
1013
+ test("reports zero while the row is narrower than its viewport", () => {
1014
+ expect(resolveScrollOffset({ ...base, align: "center", contentWidth: 200 })).toBe(0);
1015
+ });
1016
+
1017
+ test("leaves the bar alone before it has measured", () => {
1018
+ expect(resolveScrollOffset({ ...base, align: "center", viewportWidth: 0 })).toBe(120);
1019
+ });
1020
+ });