@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,286 @@
1
+ # Tabs
2
+
3
+ A row of tabs and the panels they switch between. Compound root plus `Tabs.List`,
4
+ `Tabs.ScrollView`, `Tabs.Indicator`, `Tabs.Trigger`, `Tabs.Label`,
5
+ `Tabs.Separator` and `Tabs.Content`.
6
+
7
+ `import { Tabs } from "@delacour/react-native-ui/tabs";`
8
+
9
+ ## Files
10
+
11
+ | File | What it holds |
12
+ | --- | --- |
13
+ | `index.ts` | → `@delacour/react-native-ui/tabs` |
14
+ | `tabs.tsx` | Root — owns the order walk, the value flow and the pan |
15
+ | `tabs-list.tsx` | `Tabs.List` — the track, and the measurement registry |
16
+ | `tabs-scroll-view.tsx` | `Tabs.ScrollView` — the scroller and its UI-thread auto-scroll |
17
+ | `tabs-indicator.tsx` | `Tabs.Indicator`, the one sliding layer |
18
+ | `tabs-trigger.tsx` | `Tabs.Trigger` — a `Pressable`, and the frame the indicator reads |
19
+ | `tabs-label.tsx` | `Tabs.Label` |
20
+ | `tabs-separator.tsx` | `Tabs.Separator`, a `Separator` behind an animated opacity |
21
+ | `tabs-content.tsx` | `Tabs.Content`, one panel |
22
+ | `tabs-pager.tsx` | The clipped viewport and the translating row — internal |
23
+ | `tabs.context.tsx` | Five contexts, split by what changes, with their hooks |
24
+ | `tabs.types.ts` | Prop types shared by two or more parts |
25
+ | `tabs.variants.ts` | Pure `tv()` slots + the gesture and geometry worklets, no RN imports |
26
+ | `tabs.variants.test.ts` | |
27
+
28
+ ## Design
29
+
30
+ - **Variants**: `primary` (a fully rounded capsule inside a muted track),
31
+ `secondary` (an underline rule). **Sizes**: `sm`, `md`,
32
+ `lg` — the trigger's floor and padding, the gap ladder, the label's `Text` step
33
+ and the glyph step a composed `Icon` inherits, on one axis.
34
+
35
+ ## Value flow and tab order
36
+
37
+ - **The whole component moves off one shared value**, `position`, a float index
38
+ into the panels' order. A press springs it, a drag writes it directly, and the
39
+ pager's translation, the indicator's frame and every separator's opacity are
40
+ three readings of it. Two clocks is how a capsule ends up a frame behind the
41
+ panel it is meant to be sitting on.
42
+ - **The panels are the order of record, and they alone must be direct children.**
43
+ The root walks its own children for `Tabs.Content`, exactly as
44
+ [`ListGroup`](../list-group/AGENTS.md) finds a hand-placed `Separator`, and
45
+ their source order is what `position` indexes into. Triggers are **not**
46
+ walked: each finds its place by looking its `value` up in that list, so it can
47
+ sit behind a wrapper or come out of a `.map()`. Walking triggers instead breaks
48
+ the first time anyone writes `<FilterTabs />` — the walk sees a component it
49
+ does not recognise and silently drops tabs. A bar with no panels at all is a
50
+ real shape (a filter row driving a list elsewhere) and falls back to trigger
51
+ registration, which is one commit late and therefore never preferred when
52
+ panels exist.
53
+ - **The one mistake source cannot catch is caught in the measurements.** Triggers
54
+ written in a different order from the panels put the indicator on the wrong
55
+ tab, with nothing in the source to point at; `isTriggerOrderConsistent` checks
56
+ the measured `x` values ascend and the list warns by name in development. Every
57
+ trigger reporting an `x` of 0 catches the other shape — a trigger wrapped in a
58
+ `View`, measuring itself against the wrapper rather than the row.
59
+ - **`isSwipeable={false}` is one `.enabled(false)` on the pan and nothing else.**
60
+ Every panel mounts either way. Gating mounting on it would make one boolean
61
+ change both the gesture and the mounting model, and the two are not the same
62
+ setting — you cannot drag to a panel that is not there. Lazy mounting is the
63
+ caller's, per panel, through `Tabs.Content`'s function children. There is no
64
+ `isLazy`, because "lazy" is not one thing: a form wants to stay mounted, a
65
+ video wants to stop, and a report wants never to have been built.
66
+ - **The value flow has exactly three writers and one repair.** The pan's
67
+ `onUpdate`, the pan's `onFinalize`, and a reconcile effect; a JS-side ref records
68
+ where the last of them aimed. `resolveReconcileMode` decides between `none`,
69
+ `spring` and `jump` — and `jump` is the case a float index alone cannot see: a
70
+ tab inserted before the active one moves its index without changing the
71
+ selection, and springing there would animate an edit the user did not make. A
72
+ reducer bump rides alongside every commit for
73
+ [`Switch`](../switch/AGENTS.md)'s reason: a controlled parent that rejects a
74
+ change re-renders nothing, so a reconcile waiting on the parent's commit would
75
+ never run.
76
+ - **The reconcile effect registers no cleanup, and that is load-bearing.**
77
+ [`Checkbox`](../checkbox/AGENTS.md) and [`Switch`](../switch/AGENTS.md) both end
78
+ their spring effects with `cancelAnimation`, and both can afford to: their
79
+ effect starts a fresh animation on *every* run, so a cleanup cancelling the
80
+ previous one always has a replacement behind it. This one does not — its `none`
81
+ branch returns early — and the commit that runs the cleanup is the very one the
82
+ gesture just caused. A swipe would start its settle spring, `commitFromPan`
83
+ would re-render, the previous run's cleanup would cancel that spring, and the
84
+ new run would take `none` and restart nothing: the pager froze part way between
85
+ two panels, intermittently, according to whether the commit beat the spring.
86
+ Before copying a `cancelAnimation` cleanup into a new component, check that
87
+ every path through the effect starts an animation. An in-flight spring on an
88
+ unmounted component is harmless — the shared value goes with it.
89
+ - **`onValueChange` fires once, from `onFinalize`, before the spring finishes.** The
90
+ trigger highlights the moment the finger lifts rather than 300ms later.
91
+ Driving it off `position` crossing a midpoint would fire it several times per
92
+ drag and re-render every panel while the finger was still down.
93
+ - **An accepted swipe keeps its momentum.** `commitFromPan` records the target
94
+ *before* it sets the state, so the reconcile sees no divergence and leaves the
95
+ gesture's own velocity-seeded spring alone. Rejecting the change is what makes
96
+ it spring back.
97
+
98
+ ## The measured indicator
99
+
100
+ - **The measurement registry is a JS map published as one shared value, coalesced
101
+ to one write per tick.** One value holding three pre-built arrays, not three
102
+ values holding one each: `interpolate` throws when its ranges differ in length.
103
+ Without the `queueMicrotask` coalescing an eight-tab bar writes eight times in
104
+ one layout pass and the UI thread can render between two of them, showing an
105
+ indicator built from four new positions and four old ones. A re-layout
106
+ reporting identical numbers is dropped, because reassigning a shared value
107
+ invalidates every worklet reading it. `resolveMeasurementTracks` is
108
+ all-or-nothing — a half-measured bar would collapse the indicator to nothing
109
+ every time `position` crossed the missing tab.
110
+ - **The indicator animates `width` and `translateX`, never `scaleX`.** A scale is
111
+ cheaper and wrong three times over: `rounded-full` stretches to an ellipse at
112
+ the 2.3× a short tab to a long one asks for, a border thickens on two edges
113
+ only, and any children a caller passes are squashed with it — which no
114
+ counter-scale can fix across an arbitrary subtree. The cost is bounded because
115
+ the indicator is absolutely positioned: its size change never dirties a
116
+ sibling, so the row's layout stays settled.
117
+ [`Checkbox`](../checkbox/AGENTS.md) already animates a width every toggle.
118
+
119
+ ## The pan gesture and the pager
120
+
121
+ - **The pan claims sideways and gives up vertical**, `activeOffsetX` under
122
+ `failOffsetY` so a diagonal drag resolves to exactly one of them. That is the
123
+ whole mechanism, and it is what lets a pager live inside
124
+ [`Screen.ScrollArea`](../screen/AGENTS.md). `blocksExternalGesture` is **not**
125
+ the alternative — `Screen.ScrollArea` renders React Native's own `ScrollView`,
126
+ which has no handler tag to resolve, so the call is dropped without an error.
127
+ [`Slider.Track`](../slider/AGENTS.md) found that first, and reaches the opposite
128
+ conclusion for the opposite reason: a slider must claim even a stationary tap,
129
+ so it takes `minDistance(0)` and no axis offsets at all.
130
+ - **The settle lives in `onFinalize`, and the spring is cancelled in `onStart`.**
131
+ Both halves were wrong once and the symptom was the same: a pager frozen half
132
+ way between two panels. `onBegin` fires for *every* touch the pager sees — a
133
+ tap on a panel, the first moment of a vertical scroll — and most of those go on
134
+ to FAIL against `failOffsetY`. Cancelling the settle spring there killed an
135
+ animation for a gesture that never became a drag, and because `onEnd` only runs
136
+ for a pan that actually activated, nothing restarted it. `onFinalize` is the
137
+ one callback that runs on every path out of a gesture, END, FAILED and
138
+ CANCELLED alike, from any state — the rule
139
+ [`Slider.Track`](../slider/AGENTS.md) already states — so it owns the settle,
140
+ guarded by a flag so a touch that never activated cannot retarget a spring it
141
+ never disturbed. Reproduce the old bug by swiping and then scrolling the page
142
+ before the spring has finished.
143
+ - **The drag's origin is back-computed at activation, not captured at
144
+ touch-down.** `translationX` counts from touch-down but the pan does not
145
+ activate until the finger has crossed `activateX`, so an origin taken at
146
+ activation would apply that travel twice and jump the pager on the first frame.
147
+ `resolvePanOrigin` returns the value that makes `resolvePanPosition` yield the
148
+ pager's current position for the translation so far, which is also what lets a
149
+ mid-spring grab pick up exactly where the capsule is.
150
+ - **A horizontal scrollable *inside* a panel is the caller's to settle**, because
151
+ only they know which should win. The pager publishes its pan on
152
+ `useTabsMotion()`, and the caller writes
153
+ `Gesture.Native().blocksExternalGesture(panGesture)`. A hook rather than a
154
+ prop, the trade `useScreenFooterKeyboardClearance` already makes.
155
+
156
+ ## The auto-scrolling bar
157
+
158
+ - **The bar's auto-scroll runs on the UI thread**, through the package's only
159
+ `useAnimatedRef` and `scrollTo`, and it interpolates the *fractional* trigger
160
+ geometry — so the row tracks a finger through a drag rather than jumping once
161
+ the swipe settles. A hand-scroll always wins, and the flag saying so clears on
162
+ **momentum end** rather than on the finger lifting: retargeting into a coast is
163
+ a tug-of-war the user feels as the bar snapping backwards mid-flick.
164
+ `resolveScrollOffset` clamps at both ends, and `none` returns the current
165
+ offset rather than 0 — "leave the bar alone", not "send it home".
166
+
167
+ ## Separators
168
+
169
+ - **A separator fades only while the pager is crossing it.** At rest a bar shows
170
+ every rule it has; a drag dips the one it travels over and brings it back. The
171
+ first shape hid every rule *flanking* the active tab, which is a different
172
+ thing and reads badly — on a three-tab bar it leaves exactly one rule visible
173
+ over on the far side, and the set changed on every tab change so the bar never
174
+ looked still. Measuring from the gap's own midpoint makes the fade mean one
175
+ thing: the capsule is on top of this rule right now.
176
+ - **A faded separator still takes its width**, so the row never reflows as the
177
+ fade runs. That also means separators make a bar wider — enough to tip a row
178
+ that only just fits into one that scrolls, at which point `scrollAlign` starts
179
+ moving it for no reason a user can see. Reach for `Tabs.ScrollView` when the
180
+ tabs genuinely do not fit, not by default.
181
+
182
+ ## Triggers and labels
183
+
184
+ - **A render prop must not change a trigger's size.** A trigger's width *is* the
185
+ indicator's geometry, so content that appears only on selection — a badge, a
186
+ count — re-measures the row and shifts the whole bar every time the tab
187
+ changes. Swap a treatment rather than adding or removing content: the
188
+ playground's composition demo switches a `Badge`'s `variant` and keeps it
189
+ mounted throughout.
190
+ - **The label's colour crossfades; it is a style, not a class.** It interpolates
191
+ between the two values `TABS_FOREGROUND_TOKEN` names, off the same `position`
192
+ the capsule and the panels read — so it fades *with* the capsule arriving
193
+ rather than flipping the moment a midpoint is crossed. One function covers a
194
+ finger dragging, a flick and a plain tap without knowing which is happening,
195
+ because all three write that one value.
196
+ [`Checkbox`](../checkbox/AGENTS.md)'s animated border makes the same trade for
197
+ the same reason: a colour that travels cannot be a class.
198
+ `resolveTabSelectedness` is the ramp, and it is pure, so `bun test` reaches it.
199
+ - **The `label` slot therefore names no colour at all, and a test enforces that.**
200
+ Two sources for one colour is how a class and a style end up disagreeing for a
201
+ frame on every commit. `TABS_FOREGROUND_TOKEN` is the whole matrix now, swept
202
+ for distinctness and asserted present in both themes — the decision stayed
203
+ reachable from `bun test`, it just moved from class strings to token names.
204
+ - **`visualIndex` survives for the things that cannot fade.** A composed `Icon`
205
+ takes its colour as a resolved *value* rather than a style, so it has no way to
206
+ be half way between two; it swaps at the midpoint, with a hysteresis band
207
+ because a finger held exactly there would otherwise flip it every frame and
208
+ each flip is a commit. That leaves a visible mismatch on a trigger holding both
209
+ a glyph and a label — the glyph steps while the text fades. Fixing it means
210
+ crossfading two stacked copies of the trigger's content, which renders a
211
+ caller's children twice; not worth it until a design needs it.
212
+ - **The trigger's `TextClassProvider` carries no colour either**, following the
213
+ slot. A bare `<Text>` composed into a trigger takes the page colour; reach for
214
+ `Tabs.Label` when it should read as part of the tab.
215
+ - **The axis ladder is `own ?? root ?? default`,
216
+ [`Checkbox`](../checkbox/AGENTS.md)'s and not
217
+ [`Radio.Group`](../radio/AGENTS.md)'s.** A radio group puts itself first because
218
+ escaping a disabled form group is a bug; a tab bar's disabled state is
219
+ transient chrome and the per-trigger case is the common one, so it has to
220
+ survive the bar-wide one being set. There is no `Field` rung and no
221
+ `isInvalid`: tabs are navigation, nothing here has a value that can be wrong,
222
+ and a `Field` around a `Tabs` would grey out a navigation bar from a form's
223
+ state.
224
+ - **The disabled fade lands on the label, never on the trigger.** The `trigger`
225
+ slot is worn by [`Pressable`](../pressable/AGENTS.md)'s own `Animated.View`,
226
+ whose `useAnimatedStyle` writes `opacity` every frame —
227
+ [`Radio`](../radio/AGENTS.md)'s lesson, and it bites twice here because
228
+ `feedback` defaults to `fade`.
229
+ - **Selection changes the label's colour and nothing else.** A weight change
230
+ would re-measure the label, which moves the frame the indicator is sitting on,
231
+ on every tap. A test asserts the two class sets differ by exactly one `text-*`.
232
+
233
+ ## Sizing and surface
234
+
235
+ - **No `--spacing-tabs-*`, and that is a decision.** A tab lines up against no
236
+ chrome that forces a height, so the trigger takes `min-h-*` from Tailwind's own
237
+ scale — a floor, so a large accessibility step grows the row instead of
238
+ clipping the label. [`Badge`](../badge/AGENTS.md)'s "a size is padding, never a
239
+ height", and [`Checkbox`](../checkbox/AGENTS.md)'s argument against minting a
240
+ private scale.
241
+ - **The track and the capsule are both `rounded-full`, and there is no radius
242
+ arithmetic at all.** A pill inside a pill is concentric at *any* padding, which
243
+ is the property `resolveCheckboxFillRadius` has to subtract a border width to get
244
+ for a rounded rectangle. The first shape of this component did subtract a
245
+ padding from a named `rounded-*` step per size — three maps to keep in step,
246
+ and a rectangle that still read as boxy against its own track. Do not
247
+ reintroduce them.
248
+ - **The capsule is painted on `elevated`, a token minted for it.** The surface
249
+ has to sit *above* `muted` in both themes and nothing in the neutral ramp did:
250
+ `card` is the same white as `background` in light and **darker** than `muted`
251
+ in dark, so a capsule painted on it reads as raised in one theme and sunken in
252
+ the other. The selected label takes `elevated-foreground` accordingly — rule 11,
253
+ applied to the surface the label is actually drawn on.
254
+ - **There is no third variant.** An accent-filled capsule with no track was
255
+ tried and dropped: next to `primary` it differed only by its fill and the
256
+ absence of a track, which is a variant a caller picks by accident rather than
257
+ on purpose. A caller who wants one writes `className` on `Tabs.Indicator`,
258
+ and takes the label's colour with it.
259
+
260
+ ## Accessibility
261
+
262
+ - **Reduce motion takes the default `System` policy**,
263
+ [`Checkbox`](../checkbox/AGENTS.md)'s call rather than
264
+ [`Spinner`](../spinner/AGENTS.md)'s. The state change is the point and the
265
+ travel is decoration. One wrinkle worth writing down: **the drag itself is
266
+ unaffected and must be** — `onUpdate` writes `position.value` directly, with no
267
+ `withSpring` to shorten. A direct manipulation is the user's own hand, not
268
+ motion played at them; only the release settle degrades.
269
+
270
+ ## Deliberate omissions
271
+
272
+ - **There is no `Tabs.ListBackground` and no `background` prop.**
273
+ [`Screen.Navbar.Background`](../screen/AGENTS.md) exists because that part
274
+ overlays content and its backing has to travel with the keyboard translation. A
275
+ tab track is a static box in the flow, `bg-muted` on the `list` slot is the
276
+ whole feature, and `variant` already says whether there is one.
277
+ - **There is no public pager, no `orientation`, and no router prop.** The root
278
+ wraps its own panels the way [`ListGroup`](../list-group/AGENTS.md) inserts its
279
+ own dividers; a vertical tab bar is a sidebar, a different component whose
280
+ measurements are not in x; and this library takes no navigation dependency, so
281
+ `onValueChange` is where an app calls its router —
282
+ [`Screen.Navbar.BackButton`](../screen/AGENTS.md)'s trade.
283
+ - **No `hitSlop`, and no wash layers.** A trigger is already a wide row, and slop
284
+ on top of it would overlap the trigger beside it and make a tap between two
285
+ ambiguous — [`Checkbox`](../checkbox/AGENTS.md)'s rule. The capsule sliding
286
+ under the label is the feedback; `feedback="fade"` on top of it is the press.
@@ -0,0 +1,69 @@
1
+ export { Tabs, type TabsProps } from "./tabs";
2
+ export {
3
+ type TabsContextValue,
4
+ TabsListProvider,
5
+ type TabsListValue,
6
+ TabsMotionProvider,
7
+ type TabsMotionValue,
8
+ TabsProvider,
9
+ TabsScrollableProvider,
10
+ type TabsTriggerContextValue,
11
+ TabsTriggerProvider,
12
+ useTabs,
13
+ useTabsContext,
14
+ useTabsIsScrollable,
15
+ useTabsList,
16
+ useTabsListContext,
17
+ useTabsMotion,
18
+ useTabsMotionContext,
19
+ useTabsTrigger,
20
+ useTabsTriggerContext,
21
+ } from "./tabs.context";
22
+ export type { TabsLabelProps } from "./tabs.types";
23
+ export {
24
+ isTriggerOrderConsistent,
25
+ resolveContentAccessibility,
26
+ resolveInitialValue,
27
+ resolveMeasurementTracks,
28
+ resolvePagerTranslate,
29
+ resolvePanOrigin,
30
+ resolvePanPosition,
31
+ resolveReconcileMode,
32
+ resolveScrollOffset,
33
+ resolveSeparatorIndices,
34
+ resolveSeparatorOpacity,
35
+ resolveSettleIndex,
36
+ resolveTabIndex,
37
+ resolveTabOrder,
38
+ resolveTabSelectedness,
39
+ resolveTabsTriggerState,
40
+ resolveVisualIndex,
41
+ shouldEmitTabChange,
42
+ TABS_DEFAULT_SCROLL_ALIGN,
43
+ TABS_DEFAULT_SIZE,
44
+ TABS_DEFAULT_VARIANT,
45
+ TABS_FOREGROUND_TOKEN,
46
+ TABS_LABEL_TEXT_SIZE,
47
+ TABS_PAN,
48
+ TABS_SCROLL_ALIGNS,
49
+ TABS_SCROLL_INSET,
50
+ TABS_SEPARATOR_FADE,
51
+ TABS_SETTLE_SPRING,
52
+ TABS_SIZES,
53
+ TABS_VARIANTS,
54
+ TABS_VISUAL_HYSTERESIS,
55
+ type TabMeasurement,
56
+ type TabsReconcileMode,
57
+ type TabsScrollAlign,
58
+ type TabsSize,
59
+ type TabsVariant,
60
+ type TabsVariantProps,
61
+ type TabTracks,
62
+ tabsVariants,
63
+ } from "./tabs.variants";
64
+ export type { TabsContentProps, TabsContentRenderProps } from "./tabs-content";
65
+ export type { TabsIndicatorProps, TabsIndicatorRenderProps } from "./tabs-indicator";
66
+ export type { TabsListProps } from "./tabs-list";
67
+ export type { TabsScrollViewProps } from "./tabs-scroll-view";
68
+ export type { TabsSeparatorProps } from "./tabs-separator";
69
+ export type { TabsTriggerProps, TabsTriggerRenderProps } from "./tabs-trigger";
@@ -0,0 +1,65 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { useTabsPart } from "./tabs.context";
4
+ import { resolveContentAccessibility, tabsVariants } from "./tabs.variants";
5
+
6
+ export type TabsContentRenderProps = {
7
+ /** Whether this panel's tab is the settled selection. */
8
+ isSelected: boolean;
9
+ /** What this panel is called. */
10
+ value: string;
11
+ };
12
+
13
+ export type TabsContentProps = Omit<ViewProps, "children"> & {
14
+ /** Identifies the panel to the trigger that selects it. Required. */
15
+ value: string;
16
+ className?: string;
17
+ /**
18
+ * The panel, or a function called with its own selected state.
19
+ *
20
+ * The function is how a panel is made lazy — `{({ isSelected }) => isSelected ?
21
+ * <Expensive /> : null}` — at the caller's discretion, per panel. There is no
22
+ * `isLazy` prop because "lazy" is not one thing: a form wants to stay mounted,
23
+ * a video wants to stop, and a report wants never to have been built.
24
+ */
25
+ children?: ReactNode | ((props: TabsContentRenderProps) => ReactNode);
26
+ };
27
+
28
+ /**
29
+ * One panel, exactly one viewport wide.
30
+ *
31
+ * **Panels must be direct children of `<Tabs>`.** Their source order is the order
32
+ * of record — it is what the pager translates through and what the indicator
33
+ * interpolates over — and the root reads it by walking its own children, the way
34
+ * `ListGroup` finds the rows it puts dividers between.
35
+ *
36
+ * **Every panel is mounted, whether or not the bar is swipeable.** You cannot drag
37
+ * to a panel that does not exist, so mounting is not a setting `isSwipeable` gets
38
+ * to change as a side effect; and a panel that unmounted on every tab change would
39
+ * lose a half-filled form, a scroll position and a playing video with it. What an
40
+ * unmounting panel never has to think about is the cost of staying: mounted
41
+ * content is content a screen reader will read and a finger can reach, so an
42
+ * unselected panel says it is not there through both platforms' props at once.
43
+ */
44
+ export function TabsContent({ value, className, children, ...props }: TabsContentProps): ReactElement {
45
+ const { value: selected, size, variant } = useTabsPart("Tabs.Content");
46
+ const isSelected = selected === value;
47
+ const slots = tabsVariants({ size, variant });
48
+
49
+ return (
50
+ // No `accessibilityRole`: React Native has no `tabpanel`, and the roles it
51
+ // does have all describe controls. The panel is a container, and the two
52
+ // props below are what actually keep an unselected one out of the way.
53
+ <View
54
+ className={slots.page({ className })}
55
+ // Driven by the settled selection, never by the visual one: a screen reader
56
+ // must not walk into a panel that is halfway across the screen.
57
+ {...resolveContentAccessibility(isSelected)}
58
+ pointerEvents={isSelected ? "auto" : "none"}
59
+ {...props}
60
+ >
61
+ {typeof children === "function" ? children({ isSelected, value }) : children}
62
+ </View>
63
+ );
64
+ }
65
+ TabsContent.displayName = "DelacourUI.Tabs.Content";
@@ -0,0 +1,97 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { ViewProps } from "react-native";
3
+ import Animated, { Extrapolation, interpolate, type SharedValue, useAnimatedStyle } from "react-native-reanimated";
4
+ import { useTabsListPart, useTabsMotionPart, useTabsPart } from "./tabs.context";
5
+ import { type TabsSize, type TabsVariant, tabsVariants } from "./tabs.variants";
6
+
7
+ export type TabsIndicatorRenderProps = {
8
+ /** Where the pager sits in tab-index space, on the UI thread. */
9
+ position: SharedValue<number>;
10
+ variant: TabsVariant;
11
+ size: TabsSize;
12
+ };
13
+
14
+ export type TabsIndicatorProps = Omit<ViewProps, "children"> & {
15
+ className?: string;
16
+ /**
17
+ * Composed inside the capsule, which keeps its own fill, position and size.
18
+ *
19
+ * To change the capsule itself — an outline instead of a fill, a different
20
+ * colour — pass a `className`; it merges last, so `bg-transparent` beats the
21
+ * variant's own fill. Children are for what a class cannot say: a gradient, a
22
+ * second layer, something that reads `position` for itself.
23
+ */
24
+ children?: ReactNode | ((props: TabsIndicatorRenderProps) => ReactNode);
25
+ };
26
+
27
+ /**
28
+ * The one layer that slides, and the only place a tab's motion is drawn.
29
+ *
30
+ * Write it out as the first child of the row it belongs to — inside
31
+ * `Tabs.ScrollView` when there is one, inside `Tabs.List` when there is not. It is
32
+ * absolutely positioned in that row's own space, which is exactly the space every
33
+ * `Tabs.Trigger` measures itself into, so the two need no arithmetic between them.
34
+ *
35
+ * **It animates `width` alongside `translateX`, deliberately rather than
36
+ * `scaleX`.** A scale is cheaper — a pure transform, no layout pass — and it is
37
+ * wrong here three times over: `rounded-full` stretches into an ellipse at the
38
+ * 2.3× a short tab to a long one asks for, a border thickens on two edges and not
39
+ * the other two, and any children a caller passes are squashed with it, which no
40
+ * counter-scale can fix across an arbitrary subtree. The cost is one layout of
41
+ * this node's own box per frame, and it is bounded: the indicator is absolutely
42
+ * positioned, so its size change never dirties a sibling and the row's layout
43
+ * stays settled. `Checkbox` already animates a width every toggle for the same
44
+ * kind of reason.
45
+ *
46
+ * If a caller ever does put a deep subtree inside one, the fix is to give that
47
+ * subtree a fixed size and let the indicator's width move around it rather than
48
+ * through it.
49
+ *
50
+ * **Children are composed inside it, not in place of it** — `Radio.Indicator`'s
51
+ * shape, where the ring stays the radio's and only the dot is the caller's. The
52
+ * capsule's own fill comes from the variant and its `className` merges last, so
53
+ * an outline treatment is `className="border-2 border-primary bg-transparent"`
54
+ * rather than a child.
55
+ *
56
+ * **It reads the UI thread and nothing else**, which is why the shared values it
57
+ * needs come from a context of their own: the bar's selection context changes
58
+ * identity on every tab change, and this component must not re-render for one.
59
+ */
60
+ export function TabsIndicator({ className, children, style, ...props }: TabsIndicatorProps): ReactElement {
61
+ const { variant, size } = useTabsPart("Tabs.Indicator");
62
+ const { position } = useTabsMotionPart("Tabs.Indicator");
63
+ const { tracks } = useTabsListPart("Tabs.Indicator");
64
+
65
+ const frameStyle = useAnimatedStyle(() => {
66
+ const current = tracks.value;
67
+ // Nothing measured yet. `opacity: 0` rather than an early return that names
68
+ // no opacity: a style which stops naming a prop leaves the last value it
69
+ // wrote in place, so the indicator would freeze wherever it last sat instead
70
+ // of getting out of the way.
71
+ if (!current) return { opacity: 0, transform: [{ translateX: 0 }], width: 0 };
72
+
73
+ return {
74
+ opacity: 1,
75
+ transform: [{ translateX: interpolate(position.value, current.index, current.x, Extrapolation.CLAMP) }],
76
+ width: interpolate(position.value, current.index, current.width, Extrapolation.CLAMP),
77
+ };
78
+ });
79
+
80
+ const slots = tabsVariants({ size, variant });
81
+
82
+ return (
83
+ <Animated.View
84
+ // A decoration, and announcing it would put an actionless element in front
85
+ // of every tab a screen reader walks.
86
+ accessibilityElementsHidden
87
+ className={slots.indicator({ className })}
88
+ importantForAccessibility="no-hide-descendants"
89
+ pointerEvents="none"
90
+ style={[frameStyle, style]}
91
+ {...props}
92
+ >
93
+ {typeof children === "function" ? children({ position, size, variant }) : children}
94
+ </Animated.View>
95
+ );
96
+ }
97
+ TabsIndicator.displayName = "DelacourUI.Tabs.Indicator";
@@ -0,0 +1,62 @@
1
+ import type { ReactElement } from "react";
2
+ import { interpolateColor, useAnimatedStyle } from "react-native-reanimated";
3
+ import { useThemeColor } from "../../hooks/use-theme-color";
4
+ import { Text } from "../text";
5
+ import { useTabsMotionPart, useTabsPart, useTabsTriggerPart } from "./tabs.context";
6
+ import type { TabsLabelProps } from "./tabs.types";
7
+ import { resolveTabSelectedness, TABS_FOREGROUND_TOKEN, TABS_LABEL_TEXT_SIZE, tabsVariants } from "./tabs.variants";
8
+
9
+ export type { TabsLabelProps };
10
+
11
+ /**
12
+ * A trigger's text, inside the trigger's own tap target.
13
+ *
14
+ * Renders `Text.Label` and names a size, never a scale of its own — the weight
15
+ * belongs to the preset, and restating it here would be a second definition of
16
+ * `Text.Label` that could drift from it. Naming the size is what lets the label
17
+ * still track the bar's own axis, since `Text`'s size axis is built to beat its
18
+ * preset.
19
+ *
20
+ * **Its colour is an animated style, not a class, because it fades.** The label
21
+ * interpolates between the two values `TABS_FOREGROUND_TOKEN` names, off the same
22
+ * `position` the capsule and the panels read — so it crossfades *with* the
23
+ * capsule arriving rather than flipping the moment the midpoint is crossed. That
24
+ * covers a finger dragging, a flick and a plain tap without knowing which is
25
+ * happening, because all three write the same value. `Checkbox`'s border makes
26
+ * the same trade: a colour that travels cannot be a class, so it interpolates
27
+ * between two tokens and the decision stays in a pure map `bun test` can sweep.
28
+ *
29
+ * The `label` slot therefore carries **no** colour. Two sources for one colour is
30
+ * how a class and a style end up disagreeing for a frame on every commit.
31
+ *
32
+ * **`numberOfLines` defaults to one.** A label that wrapped would change the
33
+ * row's height, which changes every measured frame, which moves the indicator —
34
+ * so the bar would reflow around its own longest word. It is an ordinary prop, so
35
+ * `numberOfLines={undefined}` opts out.
36
+ */
37
+ export function TabsLabel({ className, size, numberOfLines = 1, style, ...props }: TabsLabelProps): ReactElement {
38
+ const { size: barSize, variant } = useTabsPart("Tabs.Label");
39
+ const { index, isDisabled } = useTabsTriggerPart("Tabs.Label");
40
+ const { position } = useTabsMotionPart("Tabs.Label");
41
+
42
+ // Destructured to primitives before the worklet closes over them: a fresh
43
+ // object each render would rebuild the animated style every render.
44
+ const tokens = TABS_FOREGROUND_TOKEN[variant];
45
+ const unselectedColor = useThemeColor(tokens.unselected) ?? "transparent";
46
+ const selectedColor = useThemeColor(tokens.selected) ?? "transparent";
47
+
48
+ const colorStyle = useAnimatedStyle(() => ({
49
+ color: interpolateColor(resolveTabSelectedness(index, position.value), [0, 1], [unselectedColor, selectedColor]),
50
+ }));
51
+
52
+ return (
53
+ <Text.Label
54
+ className={tabsVariants({ isDisabled, size: barSize, variant }).label({ className })}
55
+ numberOfLines={numberOfLines}
56
+ size={size ?? TABS_LABEL_TEXT_SIZE[barSize]}
57
+ style={[colorStyle, style]}
58
+ {...props}
59
+ />
60
+ );
61
+ }
62
+ TabsLabel.displayName = "DelacourUI.Tabs.Label";