@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,147 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import { AppState, type AppStateStatus, TextInput } from "react-native";
3
+ import { KeyboardEvents, useKeyboardContext } from "react-native-keyboard-controller";
4
+ import type { SharedValue } from "react-native-reanimated";
5
+ import { scheduleOnUI } from "react-native-worklets";
6
+ import { shouldResetKeyboardAnimation } from "../lib/keyboard-animation";
7
+
8
+ // Re-exported so the pure decision is reachable beside the hook that acts on
9
+ // it; it lives in `lib` only because `bun test` cannot follow this module's
10
+ // React Native imports.
11
+ export { shouldResetKeyboardAnimation } from "../lib/keyboard-animation";
12
+
13
+ /** The one pair of animation values `KeyboardProvider` shares with the whole app. */
14
+ type KeyboardAnimationValues = {
15
+ progress: SharedValue<number>;
16
+ height: SharedValue<number>;
17
+ };
18
+
19
+ /**
20
+ * Snaps the shared progress and height back to closed when they are stale.
21
+ *
22
+ * The focus check stays on the JS thread — that is where the registry lives —
23
+ * and the read-and-write happens inside one worklet, so the UI thread's own
24
+ * values are what the decision is made against.
25
+ */
26
+ export function reconcileKeyboardAnimation({ progress, height }: KeyboardAnimationValues): void {
27
+ if (TextInput.State.currentlyFocusedInput() != null) return;
28
+
29
+ scheduleOnUI(() => {
30
+ "worklet";
31
+ if (!shouldResetKeyboardAnimation({ hasFocusedInput: false, height: height.value, progress: progress.value })) {
32
+ return;
33
+ }
34
+ // Snap, never animate: the keyboard is already gone, so there is nothing
35
+ // to animate towards and a timing curve would just be a visible glitch.
36
+ progress.value = 0;
37
+ height.value = 0;
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Repairs the keyboard animation values `KeyboardProvider` shares with the
43
+ * whole app when the keyboard disappears without telling them.
44
+ *
45
+ * `KeyboardProvider` owns exactly ONE pair of Reanimated shared values
46
+ * (`progress` / `height`), and on iOS they are written only by
47
+ * `onKeyboardMoveStart` — emitted from `keyboardWillAppear` /
48
+ * `keyboardWillDisappear` — and by the interactive-move KVO.
49
+ * `onKeyboardMoveEnd`, the event `keyboardDidDisappear` emits, updates them on
50
+ * ANDROID ONLY, and the library has no app-lifecycle re-sync at all.
51
+ *
52
+ * So any teardown that produces no `keyboardWillHide` — an interactive dismiss
53
+ * interrupted by navigation, a native-stack pop, a call interruption, an app
54
+ * suspend — leaves both values pinned at their open state, app-wide, forever.
55
+ * Every consumer then renders "keyboard open" over a screen with no keyboard on
56
+ * it: `Screen.Footer` translates its content up by a keyboard height, and
57
+ * `useScreenScrollInsets` reserves a keyboard-sized spacer under content that
58
+ * needs none.
59
+ *
60
+ * Deliberately NOT used here, and why:
61
+ * - `KeyboardController.dismiss()` resolves on `keyboardDidHide` and natively
62
+ * just resigns the first responder. With the keyboard already gone there is
63
+ * no responder, nothing fires, and the values stay stuck — and it would blur
64
+ * an input the user legitimately has focused.
65
+ * - `setEnabled(false/true)` unmounts and remounts the native observers,
66
+ * dropping some of them, and never touches the shared values.
67
+ * - Gating each reader behind a screen-local flag adds a second driver to every
68
+ * consumer and still leaves the globals wrong for the next screen.
69
+ *
70
+ * Mount ONCE, directly inside `<KeyboardProvider>` — or let
71
+ * `DelacourProvider` do it.
72
+ */
73
+ export function useKeyboardStateSync(): void {
74
+ const { reanimated } = useKeyboardContext();
75
+
76
+ useEffect(() => {
77
+ const appState = AppState.addEventListener("change", (status: AppStateStatus) => {
78
+ if (status === "active") reconcileKeyboardAnimation(reanimated);
79
+ });
80
+
81
+ // The `did` events carry the authoritative terminal state, and on iOS the
82
+ // provider throws them away. Mirroring them here is what makes a lost
83
+ // `will` event self-heal on the very next keyboard transition.
84
+ const didHide = KeyboardEvents.addListener("keyboardDidHide", () => {
85
+ scheduleOnUI(() => {
86
+ "worklet";
87
+ reanimated.progress.value = 0;
88
+ reanimated.height.value = 0;
89
+ });
90
+ });
91
+ const didShow = KeyboardEvents.addListener("keyboardDidShow", (event) => {
92
+ // The same height `onKeyboardMoveStart` delivers — both come from the
93
+ // observer's `keyboardHeight`.
94
+ const keyboardHeight = event.height;
95
+ scheduleOnUI(() => {
96
+ "worklet";
97
+ reanimated.progress.value = 1;
98
+ reanimated.height.value = -keyboardHeight;
99
+ });
100
+ });
101
+
102
+ // The values may already be stale by the time this mounts.
103
+ reconcileKeyboardAnimation(reanimated);
104
+
105
+ return () => {
106
+ appState.remove();
107
+ didHide.remove();
108
+ didShow.remove();
109
+ };
110
+ }, [reanimated]);
111
+ }
112
+
113
+ /**
114
+ * Render-free host for {@link useKeyboardStateSync}, to drop into a layout tree.
115
+ *
116
+ * `DelacourProvider` already mounts this — write it out only when composing the
117
+ * root providers by hand.
118
+ *
119
+ * @example
120
+ * <KeyboardProvider>
121
+ * <KeyboardStateSync />
122
+ * <Stack />
123
+ * </KeyboardProvider>
124
+ */
125
+ export function KeyboardStateSync(): null {
126
+ useKeyboardStateSync();
127
+ return null;
128
+ }
129
+ KeyboardStateSync.displayName = "DelacourUI.KeyboardStateSync";
130
+
131
+ /**
132
+ * Screen-level repair, for a surface that mounts into an already-poisoned
133
+ * global state.
134
+ *
135
+ * Idempotent and cheap — a no-op whenever an input is genuinely focused, which
136
+ * is what keeps "navigate to a new screen with the keyboard still open"
137
+ * working. `Screen.Footer` runs this on mount so a screen repairs itself even
138
+ * in an app that never mounted {@link KeyboardStateSync}.
139
+ */
140
+ export function useKeyboardAnimationGuard(): void {
141
+ const { reanimated } = useKeyboardContext();
142
+ const reconcile = useCallback(() => reconcileKeyboardAnimation(reanimated), [reanimated]);
143
+
144
+ useEffect(() => {
145
+ reconcile();
146
+ }, [reconcile]);
147
+ }
@@ -0,0 +1,78 @@
1
+ import { useMemo } from "react";
2
+ import { useUniwind } from "uniwind";
3
+ import { NAVIGATION_THEME_TOKENS, type NavigationTheme, omitUnresolvedColors } from "../lib/navigation-theme";
4
+ import { useThemeColor } from "./use-theme-color";
5
+
6
+ // Re-exported so the mapping is reachable beside the hook that reads it; it
7
+ // lives in `lib` only because `bun test` cannot follow this module's React
8
+ // Native imports.
9
+ export {
10
+ NAVIGATION_THEME_TOKENS,
11
+ type NavigationTheme,
12
+ type NavigationThemeSlot,
13
+ } from "../lib/navigation-theme";
14
+
15
+ /**
16
+ * The active theme's colours, shaped for a React Navigation theme.
17
+ *
18
+ * Exists because a navigator paints its own chrome from its own theme, and
19
+ * nothing connects that to this library's tokens. `expo-router` mounts its
20
+ * `NavigationContainer` with no `theme`, so the light default stands however
21
+ * dark the app is: on iOS the native stack hands `colors.background` to the
22
+ * `UINavigationController`'s view, which is the slab visible between cards
23
+ * during a push. That layer is hardcoded to the theme, so a `contentStyle` in
24
+ * `screenOptions` cannot reach it — only a theme can.
25
+ *
26
+ * Returns plain values rather than a `Theme`, and imports nothing from a
27
+ * navigation library. That is deliberate: this package takes no navigation
28
+ * dependency — the same reason `Screen.Navbar.BackButton` takes an `onPress`
29
+ * instead of calling a router — so the app owns the wiring and this owns the
30
+ * colours. It also sidesteps restating React Navigation's `fonts`, whose shape
31
+ * is platform-specific and would be a second definition able to drift.
32
+ *
33
+ * `dark` comes from Uniwind's active theme, never React Native's
34
+ * `useColorScheme`: an app that lets the user force light or dark against the
35
+ * system setting would otherwise theme its navigator the wrong way round.
36
+ *
37
+ * Reactive. Every colour is read through {@link useThemeColor}, which
38
+ * subscribes to Uniwind's theme changes, so switching theme re-renders the
39
+ * caller with new values rather than needing a reload.
40
+ *
41
+ * @example
42
+ * // expo-router. `ThemeProvider` comes from `expo-router`, which vendors
43
+ * // React Navigation — `@react-navigation/native` does not resolve.
44
+ * import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router";
45
+ *
46
+ * function NavigationTheme({ children }: { children: ReactNode }) {
47
+ * const { dark, colors } = useNavigationTheme();
48
+ * const base = dark ? DarkTheme : DefaultTheme;
49
+ * const value = useMemo(
50
+ * () => ({ ...base, dark, colors: { ...base.colors, ...colors } }),
51
+ * [base, dark, colors]
52
+ * );
53
+ *
54
+ * return <ThemeProvider value={value}>{children}</ThemeProvider>;
55
+ * }
56
+ */
57
+ export function useNavigationTheme(): NavigationTheme {
58
+ const { theme } = useUniwind();
59
+
60
+ // Written out one call per slot rather than looped: a hook cannot be called
61
+ // from inside a map, however stable the list looks.
62
+ const background = useThemeColor(NAVIGATION_THEME_TOKENS.background);
63
+ const card = useThemeColor(NAVIGATION_THEME_TOKENS.card);
64
+ const text = useThemeColor(NAVIGATION_THEME_TOKENS.text);
65
+ const border = useThemeColor(NAVIGATION_THEME_TOKENS.border);
66
+ const primary = useThemeColor(NAVIGATION_THEME_TOKENS.primary);
67
+ const notification = useThemeColor(NAVIGATION_THEME_TOKENS.notification);
68
+
69
+ const dark = theme === "dark";
70
+
71
+ return useMemo(
72
+ () => ({
73
+ colors: omitUnresolvedColors({ background, border, card, notification, primary, text }),
74
+ dark,
75
+ }),
76
+ [dark, background, border, card, notification, primary, text]
77
+ );
78
+ }
@@ -0,0 +1,44 @@
1
+ import { useCSSVariable } from "uniwind";
2
+ import { isLiteralColor, themeVariableName } from "../lib/color";
3
+
4
+ /** Defined by every theme, so looking it up never misses and never warns. */
5
+ const PROBE_VARIABLE = "--foreground";
6
+
7
+ /**
8
+ * Reads a theme colour as a plain string for props that take a colour value
9
+ * rather than a style — icon `color`, gradient stops, chart series, and any
10
+ * third-party component that will not accept a className.
11
+ *
12
+ * Resolves against the nearest `ScopedTheme`, so it follows light/dark and any
13
+ * custom theme automatically. Prefer a `bg-*`/`text-*` className wherever the
14
+ * target actually accepts one.
15
+ *
16
+ * A literal colour — `#EC4899`, `rgb(...)` — passes straight through without
17
+ * being looked up, so a caller can hand one to any prop typed as a token
18
+ * without special-casing it.
19
+ *
20
+ * A token resolves only if the active theme actually emits its CSS variable.
21
+ * That is the semantic palette in `styles/theme.css` — Tailwind's own palette
22
+ * names (`emerald-500`) are not emitted unless some utility class references
23
+ * them. An unresolved token yields `undefined` rather than a bogus value.
24
+ *
25
+ * The variable looked up is the RAW name — `--destructive`, not
26
+ * `--color-destructive`. `theme.css` maps the raw names onto the `--color-*`
27
+ * namespace through `@theme inline`, and `inline` means Tailwind substitutes
28
+ * the value into each utility and emits no `--color-*` variable at all. Those
29
+ * names do not exist at runtime; the raw ones are the only thing there. They
30
+ * are also the names a shadcn theme is written in, so `useThemeColor("chart-1")`
31
+ * works on a pasted palette with no support from this package.
32
+ */
33
+ export function useThemeColor(token: string): string | undefined {
34
+ const isLiteral = isLiteralColor(token);
35
+ const name = themeVariableName(token);
36
+
37
+ // A literal still has to go through the hook — hook order cannot be
38
+ // conditional — but it must not be the thing looked up. Uniwind logs a
39
+ // warning on every miss, and `--#EC4899` misses on every single render.
40
+ // Probe a variable that is always defined instead, and discard it.
41
+ const resolved = useCSSVariable(isLiteral ? PROBE_VARIABLE : name) as string | undefined;
42
+
43
+ return isLiteral ? token : resolved;
44
+ }
@@ -0,0 +1 @@
1
+ export * from "@central-icons-react-native/round-outlined-radius-1-stroke-1.5";
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ BUTTON_RADIUS_TOKENS,
4
+ BUTTON_SIZE_TOKENS,
5
+ BUTTON_TEXT_TOKENS,
6
+ ICON_SIZE_TOKENS,
7
+ INPUT_SIZE_TOKENS,
8
+ INPUT_TEXT_TOKENS,
9
+ SCREEN_SIZE_TOKENS,
10
+ } from "../styles/tokens";
11
+ import { cn } from "./cn";
12
+
13
+ describe("cn", () => {
14
+ test("joins plain class strings", () => {
15
+ expect(cn("px-2", "py-1")).toBe("px-2 py-1");
16
+ });
17
+
18
+ test("drops falsy values", () => {
19
+ expect(cn("px-2", false, null, undefined, "", "py-1")).toBe("px-2 py-1");
20
+ });
21
+
22
+ test("resolves conditional objects and arrays", () => {
23
+ expect(cn(["px-2", { "py-1": true, "py-4": false }])).toBe("px-2 py-1");
24
+ });
25
+
26
+ test("last conflicting utility wins", () => {
27
+ expect(cn("p-2", "p-6")).toBe("p-6");
28
+ expect(cn("bg-primary", "bg-destructive")).toBe("bg-destructive");
29
+ });
30
+
31
+ test("keeps non-conflicting utilities from the same group", () => {
32
+ expect(cn("px-2", "py-4")).toBe("px-2 py-4");
33
+ });
34
+
35
+ test("an incoming className overrides the base", () => {
36
+ expect(cn("rounded-lg bg-primary", "bg-secondary")).toBe("rounded-lg bg-secondary");
37
+ });
38
+
39
+ test("returns an empty string for no input", () => {
40
+ expect(cn()).toBe("");
41
+ });
42
+ });
43
+
44
+ // Without these, tailwind-merge treats `h-button-md` as an unrecognised
45
+ // utility and keeps it alongside a caller's `h-12`. Both would apply and the
46
+ // winner would be whichever uniwind resolved last — the exact failure `cn`
47
+ // exists to prevent, and an invisible one, since nothing errors.
48
+ // A side corner survives an all-corner class emitted BEFORE it, and is
49
+ // annihilated by one emitted after. `Button.Group` rests on that asymmetry: it
50
+ // is why a joined member replaces its corner with a per-side pair rather than
51
+ // layering a squaring class over the `rounded-button-*` the size axis would
52
+ // otherwise leave behind. That class would delete the pair silently, with
53
+ // nothing to see in a diff.
54
+ describe("cn with a side corner", () => {
55
+ test("keeps a side class set after an all-corner one", () => {
56
+ for (const token of BUTTON_RADIUS_TOKENS) {
57
+ expect(cn(`rounded-${token}`, "rounded-e-none")).toBe(`rounded-${token} rounded-e-none`);
58
+ }
59
+ });
60
+
61
+ test("drops a side class set before an all-corner one", () => {
62
+ for (const token of BUTTON_RADIUS_TOKENS) {
63
+ expect(cn("rounded-e-none", `rounded-${token}`)).toBe(`rounded-${token}`);
64
+ }
65
+ });
66
+
67
+ test("lets a caller's all-corner class beat a whole per-side pair", () => {
68
+ for (const token of BUTTON_RADIUS_TOKENS) {
69
+ expect(cn(`rounded-s-${token} rounded-e-none`, "rounded-lg")).toBe("rounded-lg");
70
+ }
71
+ });
72
+ });
73
+
74
+ describe("cn with the semantic size tokens", () => {
75
+ test("a caller's plain utility replaces a token one", () => {
76
+ for (const token of BUTTON_SIZE_TOKENS) {
77
+ expect(cn(`h-${token}`, "h-12")).toBe("h-12");
78
+ expect(cn(`w-${token}`, "w-12")).toBe("w-12");
79
+ }
80
+ for (const token of ICON_SIZE_TOKENS) {
81
+ expect(cn(`size-${token}`, "size-6")).toBe("size-6");
82
+ }
83
+ for (const token of BUTTON_TEXT_TOKENS) {
84
+ expect(cn(`text-${token}`, "text-lg")).toBe("text-lg");
85
+ }
86
+ // The button's corner is a token like its height, so a caller who wants
87
+ // a squarer button writes `rounded-lg` and gets it.
88
+ for (const token of BUTTON_RADIUS_TOKENS) {
89
+ expect(cn(`rounded-${token}`, "rounded-lg")).toBe("rounded-lg");
90
+ expect(cn(`rounded-${token}`, "rounded-full")).toBe("rounded-full");
91
+ }
92
+ // A field's height is read on two axes — fixed for a single line,
93
+ // a floor for a multiline one — so `min-h` has to be recognised too.
94
+ for (const token of INPUT_SIZE_TOKENS) {
95
+ expect(cn(`h-${token}`, "h-12")).toBe("h-12");
96
+ expect(cn(`min-h-${token}`, "min-h-12")).toBe("min-h-12");
97
+ }
98
+ for (const token of INPUT_TEXT_TOKENS) {
99
+ expect(cn(`text-${token}`, "text-lg")).toBe("text-lg");
100
+ }
101
+ // The screen tokens are read on two different axes — `h-navbar-row` sizes
102
+ // the navbar's row, `px-screen-gutter` its horizontal padding — so both
103
+ // have to be recognised, not just the one each token was named for.
104
+ for (const token of SCREEN_SIZE_TOKENS) {
105
+ expect(cn(`h-${token}`, "h-12")).toBe("h-12");
106
+ expect(cn(`px-${token}`, "px-8")).toBe("px-8");
107
+ }
108
+ });
109
+
110
+ test("a token utility replaces a plain one", () => {
111
+ expect(cn("h-12", "h-button-sm")).toBe("h-button-sm");
112
+ expect(cn("size-6", "size-icon-xs")).toBe("size-icon-xs");
113
+ expect(cn("text-lg", "text-button-sm")).toBe("text-button-sm");
114
+ expect(cn("px-8", "px-screen-gutter")).toBe("px-screen-gutter");
115
+ expect(cn("min-h-12", "min-h-input-lg")).toBe("min-h-input-lg");
116
+ expect(cn("rounded-lg", "rounded-button-md")).toBe("rounded-button-md");
117
+ });
118
+
119
+ test("one token replaces another on the same axis", () => {
120
+ expect(cn("size-icon-sm", "size-icon-2xl")).toBe("size-icon-2xl");
121
+ expect(cn("h-button-sm", "h-button-lg")).toBe("h-button-lg");
122
+ expect(cn("h-input-sm", "h-input-lg")).toBe("h-input-lg");
123
+ expect(cn("text-input-sm", "text-input-lg")).toBe("text-input-lg");
124
+ expect(cn("rounded-button-sm", "rounded-button-lg")).toBe("rounded-button-lg");
125
+ });
126
+
127
+ test("tokens on different axes both survive", () => {
128
+ expect(cn("h-button-md", "w-button-md")).toBe("h-button-md w-button-md");
129
+ expect(cn("h-button-md", "rounded-button-md")).toBe("h-button-md rounded-button-md");
130
+ expect(cn("size-icon-md", "text-button-md")).toBe("size-icon-md text-button-md");
131
+ // `--input` and `--text-input-md` are different namespaces that
132
+ // happen to share a word. A field sets both at once, so a merger that
133
+ // judged them to conflict would silently strip one.
134
+ expect(cn("border-input", "text-input-md")).toBe("border-input text-input-md");
135
+ });
136
+ });
package/src/lib/cn.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { extendTailwindMerge } from "tailwind-merge";
3
+ import { TW_MERGE_CONFIG } from "../styles/tokens";
4
+
5
+ /**
6
+ * tailwind-merge, taught the semantic size tokens from `tokens.css`.
7
+ *
8
+ * Registering them is load-bearing rather than tidiness. tailwind-merge only
9
+ * treats two classes as conflicting when it recognises both as members of the
10
+ * same group, and `button-md` is not a value it knows. Left unregistered,
11
+ * `cn("h-button-md", "h-12")` returns *both* classes: they each resolve to a
12
+ * height, uniwind applies whichever it saw last, and a caller's override works
13
+ * or does not depending on class order. Nothing throws, so the failure is
14
+ * silent — which is why `cn.test.ts` asserts every token here.
15
+ *
16
+ * `tv()` needs the same treatment for its own merger — see `lib/tv.ts`.
17
+ */
18
+ const twMerge = extendTailwindMerge(TW_MERGE_CONFIG);
19
+
20
+ /**
21
+ * Merges class names and resolves Tailwind conflicts so the last utility wins.
22
+ *
23
+ * Uniwind does not deduplicate conflicting classes on its own — both would be
24
+ * applied and the winner would be undefined. Always route a caller-supplied
25
+ * `className` through this before handing it to a component.
26
+ */
27
+ export function cn(...inputs: ClassValue[]): string {
28
+ return twMerge(clsx(inputs));
29
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isLiteralColor, themeVariableName, transparentOf } from "./color";
3
+
4
+ describe("isLiteralColor", () => {
5
+ test("treats hex as a literal", () => {
6
+ expect(isLiteralColor("#EC4899")).toBe(true);
7
+ expect(isLiteralColor("#fff")).toBe(true);
8
+ expect(isLiteralColor("#00000080")).toBe(true);
9
+ });
10
+
11
+ test("treats functional notation as a literal", () => {
12
+ expect(isLiteralColor("rgb(236, 72, 153)")).toBe(true);
13
+ expect(isLiteralColor("rgba(0, 0, 0, 0.5)")).toBe(true);
14
+ expect(isLiteralColor("hsl(330 81% 60%)")).toBe(true);
15
+ expect(isLiteralColor("oklch(0.7 0.2 20)")).toBe(true);
16
+ expect(isLiteralColor("color-mix(in oklch, red, blue)")).toBe(true);
17
+ });
18
+
19
+ test("treats a theme token name as a token", () => {
20
+ expect(isLiteralColor("foreground")).toBe(false);
21
+ expect(isLiteralColor("primary-foreground")).toBe(false);
22
+ expect(isLiteralColor("destructive-soft-foreground")).toBe(false);
23
+ expect(isLiteralColor("emerald-500")).toBe(false);
24
+ });
25
+
26
+ test("treats a raw CSS variable name as a token", () => {
27
+ expect(isLiteralColor("--color-foreground")).toBe(false);
28
+ });
29
+
30
+ test("does not mistake an empty string for a literal", () => {
31
+ expect(isLiteralColor("")).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe("themeVariableName", () => {
36
+ test("prefixes a bare token name", () => {
37
+ expect(themeVariableName("foreground")).toBe("--foreground");
38
+ expect(themeVariableName("destructive-soft-foreground")).toBe("--destructive-soft-foreground");
39
+ expect(themeVariableName("chart-1")).toBe("--chart-1");
40
+ });
41
+
42
+ test("passes a raw variable name through", () => {
43
+ expect(themeVariableName("--foreground")).toBe("--foreground");
44
+ });
45
+
46
+ // `theme.css` maps the raw names onto `--color-*` through `@theme inline`,
47
+ // and `inline` emits no `--color-*` variable at all — so the prefixed form
48
+ // a caller may have written before would miss on every render.
49
+ test("rewrites the pre-shadcn --color- form onto the raw name", () => {
50
+ expect(themeVariableName("--color-foreground")).toBe("--foreground");
51
+ expect(themeVariableName("--color-destructive-soft")).toBe("--destructive-soft");
52
+ });
53
+ });
54
+
55
+ describe("transparentOf", () => {
56
+ test("keeps the hue and drops the alpha, so a fade never runs through black", () => {
57
+ expect(transparentOf("#0a0a0a")).toBe("#0a0a0a00");
58
+ expect(transparentOf("#FFFFFF")).toBe("#FFFFFF00");
59
+ });
60
+
61
+ test("expands a short hex, which cannot simply take a suffix", () => {
62
+ expect(transparentOf("#abc")).toBe("#aabbcc00");
63
+ });
64
+
65
+ test("replaces an alpha the colour already carries rather than appending a second", () => {
66
+ expect(transparentOf("#0a0a0aff")).toBe("#0a0a0a00");
67
+ expect(transparentOf("#abcd")).toBe("#aabbcc00");
68
+ });
69
+
70
+ test("rewrites functional notation in place", () => {
71
+ expect(transparentOf("rgb(10, 20, 30)")).toBe("rgba(10, 20, 30, 0)");
72
+ expect(transparentOf("rgba(10, 20, 30, 0.5)")).toBe("rgba(10, 20, 30, 0)");
73
+ });
74
+
75
+ test("gives up rather than guessing, so a caller can decline to draw", () => {
76
+ expect(transparentOf(undefined)).toBeUndefined();
77
+ expect(transparentOf("rebeccapurple")).toBeUndefined();
78
+ expect(transparentOf("oklch(0.5 0.1 200)")).toBeUndefined();
79
+ });
80
+ });
@@ -0,0 +1,79 @@
1
+ const FUNCTIONAL_COLOR = /^[a-z][a-z-]*\(/i;
2
+
3
+ /**
4
+ * True when a string is a literal CSS colour rather than a theme token name.
5
+ *
6
+ * Theme tokens (`foreground`, `destructive-soft-foreground`, `emerald-500`) are
7
+ * resolved through a CSS variable; literals are handed to the platform as-is.
8
+ * The two are told apart by shape: a literal is either hex or a functional
9
+ * notation, and no token name can look like either.
10
+ */
11
+ export function isLiteralColor(value: string): boolean {
12
+ return value.startsWith("#") || FUNCTIONAL_COLOR.test(value);
13
+ }
14
+
15
+ /** The `--color-` prefix this package used before it adopted shadcn's names. */
16
+ const LEGACY_PREFIX = "--color-";
17
+
18
+ /**
19
+ * The CSS variable a token name resolves to.
20
+ *
21
+ * The raw name, not the `--color-*` one: `theme.css` maps raw onto `--color-*`
22
+ * through `@theme inline`, and `inline` means Tailwind substitutes the value
23
+ * into each utility and emits no `--color-*` variable at all. Only the raw
24
+ * names exist at runtime.
25
+ *
26
+ * A `--color-*` argument is rewritten rather than rejected — it is what a
27
+ * caller wrote before this package took shadcn's names, and left alone it would
28
+ * miss silently on every render.
29
+ *
30
+ * Pure, and separate from `useThemeColor` so it is reachable from `bun test` —
31
+ * the hook imports `uniwind`, which Bun's transpiler cannot parse. See AGENTS.md.
32
+ */
33
+ export function themeVariableName(token: string): string {
34
+ if (token.startsWith(LEGACY_PREFIX)) return `--${token.slice(LEGACY_PREFIX.length)}`;
35
+ if (token.startsWith("--")) return token;
36
+
37
+ return `--${token}`;
38
+ }
39
+
40
+ const HEX = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
41
+ const RGB = /^rgba?\(\s*([^,]+),\s*([^,]+),\s*([^,)]+)/i;
42
+
43
+ /**
44
+ * The same colour at zero alpha, for the far end of a fade.
45
+ *
46
+ * A gradient must never fade to the keyword `transparent`: that is transparent
47
+ * BLACK, and interpolating toward it drags every stop between through grey — a
48
+ * dark bloom over a light ground, a milky one over a dark. The far stop has to
49
+ * be the near colour with its alpha taken off, which is what this returns.
50
+ *
51
+ * `undefined` for anything it cannot take apart, which a caller should read as
52
+ * "do not draw" rather than reaching for the keyword after all. A colour that
53
+ * arrives here in a notation this does not know is a colour whose channels are
54
+ * not knowable without a parser, and a fade is not worth one.
55
+ */
56
+ export function transparentOf(color: string | undefined): string | undefined {
57
+ if (!color) return undefined;
58
+
59
+ if (HEX.test(color)) {
60
+ const digits = color.slice(1);
61
+ // A short hex cannot take a suffix: `#abc` + `00` is a four-digit hex,
62
+ // which is `#aabbccdd` shorthand and not the colour at all.
63
+ const rgb =
64
+ digits.length <= 4
65
+ ? digits
66
+ .slice(0, 3)
67
+ .split("")
68
+ .map((digit) => digit + digit)
69
+ .join("")
70
+ : digits.slice(0, 6);
71
+
72
+ return `#${rgb}00`;
73
+ }
74
+
75
+ const channels = RGB.exec(color);
76
+ if (channels) return `rgba(${channels[1]?.trim()}, ${channels[2]?.trim()}, ${channels[3]?.trim()}, 0)`;
77
+
78
+ return undefined;
79
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { composeRefs } from "./compose-refs";
3
+
4
+ describe("composeRefs", () => {
5
+ test("assigns to every object ref", () => {
6
+ const a: { current: string | null } = { current: null };
7
+ const b: { current: string | null } = { current: null };
8
+ composeRefs(a, b)("node");
9
+ expect(a.current).toBe("node");
10
+ expect(b.current).toBe("node");
11
+ });
12
+
13
+ test("calls every callback ref", () => {
14
+ const seen: string[] = [];
15
+ composeRefs<string>(
16
+ (n) => {
17
+ seen.push(`a:${n}`);
18
+ },
19
+ (n) => {
20
+ seen.push(`b:${n}`);
21
+ }
22
+ )("node");
23
+ expect(seen).toEqual(["a:node", "b:node"]);
24
+ });
25
+
26
+ test("mixes callback and object refs", () => {
27
+ const obj: { current: string | null } = { current: null };
28
+ const called: string[] = [];
29
+ composeRefs<string>(obj, (n) => {
30
+ if (n) called.push(n);
31
+ })("node");
32
+ expect(obj.current).toBe("node");
33
+ expect(called).toEqual(["node"]);
34
+ });
35
+
36
+ test("skips null and undefined refs", () => {
37
+ const obj: { current: string | null } = { current: null };
38
+ expect(() => composeRefs<string>(null, undefined, obj)("node")).not.toThrow();
39
+ expect(obj.current).toBe("node");
40
+ });
41
+
42
+ test("runs cleanups returned by callback refs on unmount", () => {
43
+ const order: string[] = [];
44
+ const cleanup = composeRefs<string>(
45
+ () => () => {
46
+ order.push("cleanup-a");
47
+ },
48
+ () => () => {
49
+ order.push("cleanup-b");
50
+ }
51
+ )("node");
52
+ expect(typeof cleanup).toBe("function");
53
+ cleanup?.();
54
+ expect(order).toEqual(["cleanup-a", "cleanup-b"]);
55
+ });
56
+
57
+ test("clears object refs on unmount when a cleanup runs", () => {
58
+ const obj: { current: string | null } = { current: null };
59
+ const cleanup = composeRefs<string>(obj, () => () => {})("node");
60
+ expect(obj.current).toBe("node");
61
+ cleanup?.();
62
+ expect(obj.current).toBeNull();
63
+ });
64
+ });