@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
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # @delacour/react-native-ui
2
+
3
+ A React Native component library. Styling is
4
+ [Uniwind](https://github.com/kirillzyusko/uniwind) — Tailwind v4 for React
5
+ Native; interaction is Reanimated and Gesture Handler; haptics are Pulsar.
6
+
7
+ The package **ships raw `.tsx` with no build step.** Uniwind's transform has to
8
+ run inside the consuming app's Metro pipeline, so a precompiled build would
9
+ arrive with its classNames already dead.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ bun add @delacour/react-native-ui@alpha
15
+ ```
16
+
17
+ Native modules are **peer dependencies**, because two copies of a native module
18
+ register twice and break at runtime. The complete list — native modules through
19
+ `expo install`, the rest through your package manager — is derived from the
20
+ components' own imports and kept on the
21
+ [Installation page](https://ui.delacour.co.nz/docs/native/getting-started/installation).
22
+ Four peers are optional and resolve only if you import the subpath that needs them:
23
+
24
+ | Peer | Needed for |
25
+ | --- | --- |
26
+ | `expo-router` | `@delacour/react-native-ui/expo/navigation-theme` |
27
+ | `@legendapp/list` | `Screen.LegendList` and `Screen.ChatList` |
28
+ | `react-native-screens` | native stack navigation |
29
+ | `@delacour/react-native-charts` | `Chart` |
30
+
31
+ Granular exports are what make that safe: an app that never imports a subpath
32
+ never makes Metro resolve its peers.
33
+
34
+ ## Setup
35
+
36
+ **1. Styles.** In your app's `global.css`:
37
+
38
+ ```css
39
+ @import '@delacour/react-native-ui/styles';
40
+ @source '../../../../packages/native-ui/src';
41
+ ```
42
+
43
+ Use the real workspace path, not a `node_modules` one — Bun symlinks workspace
44
+ packages and Tailwind's scanner cannot follow symlinks, so classes would be
45
+ silently dropped from production builds.
46
+
47
+ **2. Provider.** Wrap the app's root:
48
+
49
+ ```tsx
50
+ import { DelacourProvider } from "@delacour/react-native-ui/provider";
51
+
52
+ <DelacourProvider>{children}</DelacourProvider>;
53
+ ```
54
+
55
+ That is the gesture root every `Pressable` needs above it, the safe-area provider
56
+ and keyboard provider `Screen` reads, the bottom-sheet modal provider, and the
57
+ keyboard state sync that keeps them honest.
58
+
59
+ ## Usage
60
+
61
+ There is **no package-wide barrel.** Import from the subpath:
62
+
63
+ ```tsx
64
+ import { Button } from "@delacour/react-native-ui/button";
65
+ import { Icon } from "@delacour/react-native-ui/icon";
66
+ import { IconArrowRight } from "@delacour/react-native-ui/icons/central";
67
+
68
+ // The icon inherits the button's size and its variant's colour.
69
+ <Button haptic="selection" onPress={next}>
70
+ <Button.Label>Continue</Button.Label>
71
+ <Icon icon={IconArrowRight} />
72
+ </Button>;
73
+ ```
74
+
75
+ ## Components
76
+
77
+ | Component | Import | |
78
+ | --- | --- | --- |
79
+ | Accordion | `@delacour/react-native-ui/accordion` | Selection modes, measured panels, indicators |
80
+ | Badge | `@delacour/react-native-ui/badge` | Variants, colours, sizes, dismiss |
81
+ | BottomSheet | `@delacour/react-native-ui/bottom-sheet` | Overlay, snap points, sticky footer, keyboard |
82
+ | Button | `@delacour/react-native-ui/button` | Variants, sizes, icons, loading |
83
+ | Checkbox | `@delacour/react-native-ui/checkbox` | Colours, sizes, indeterminate, groups |
84
+ | Field | `@delacour/react-native-ui/field` | Form layout, grouping, state cascade |
85
+ | Icon | `@delacour/react-native-ui/icon` | Central Icons, with inherited size and colour |
86
+ | Input | `@delacour/react-native-ui/input` | Variants, sizes, prefix and suffix |
87
+ | ListGroup | `@delacour/react-native-ui/list-group` | Grouped rows, dividers, slots |
88
+ | Pressable | `@delacour/react-native-ui/pressable` | Gestures, haptics, `asChild` |
89
+ | Radio | `@delacour/react-native-ui/radio` | Groups, selection, sizes, orientation |
90
+ | Screen | `@delacour/react-native-ui/screen` | Navbar, footer, scrollables, keyboard |
91
+ | Separator | `@delacour/react-native-ui/separator` | Orientations, insets, weight |
92
+ | Slider | `@delacour/react-native-ui/slider` | Range, orientation, colours, steps |
93
+ | Spinner | `@delacour/react-native-ui/spinner` | Sizes, colours, custom glyphs |
94
+ | Switch | `@delacour/react-native-ui/switch` | Drag or tap, colours, sizes, end content |
95
+ | Tabs | `@delacour/react-native-ui/tabs` | Variants, sizes, swipe, scrolling |
96
+ | Text | `@delacour/react-native-ui/text` | Type scale, presets, inline nesting |
97
+ | DelacourProvider | `@delacour/react-native-ui/provider` | The app's root layer stack |
98
+
99
+ Also exported: `hooks/*`, `lib/*`, `expo/navigation-theme`, `icons/central`, and
100
+ the `styles/*` CSS entries.
101
+
102
+ ## Theming
103
+
104
+ Tokens are CSS variables under `@variant light` / `@variant dark`. Components
105
+ reference semantic names — `bg-background`, `text-muted-foreground` — never a raw
106
+ palette colour or a `dark:` prefix. For a prop needing a colour *value* rather
107
+ than a class, use `useThemeColor`.
108
+
109
+ ## Documentation
110
+
111
+ [`AGENTS.md`](AGENTS.md) is the reference — the rules, the patterns, and the
112
+ reasoning behind each decision. Each component has its own alongside its source
113
+ in [`src/components`](src/components).
package/package.json ADDED
@@ -0,0 +1,125 @@
1
+ {
2
+ "name": "@delacour/react-native-ui",
3
+ "version": "0.1.0-alpha.3",
4
+ "description": "React Native UI components \u2014 Uniwind, Reanimated, Gesture Handler",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/delacournz/delacour-ui.git",
10
+ "directory": "packages/native-ui"
11
+ },
12
+ "keywords": [
13
+ "react-native",
14
+ "expo",
15
+ "ui",
16
+ "components",
17
+ "uniwind",
18
+ "tailwind",
19
+ "reanimated"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "README.md"
27
+ ],
28
+ "sideEffects": [
29
+ "**/*.css"
30
+ ],
31
+ "exports": {
32
+ "./styles": "./src/styles/index.css",
33
+ "./styles/base": "./src/styles/base.css",
34
+ "./styles/tokens": "./src/styles/tokens.css",
35
+ "./styles/theme": "./src/styles/theme.css",
36
+ "./accordion": "./src/components/accordion/index.ts",
37
+ "./badge": "./src/components/badge/index.ts",
38
+ "./bottom-sheet": "./src/components/bottom-sheet/index.ts",
39
+ "./button": "./src/components/button/index.ts",
40
+ "./chart": "./src/components/chart/index.ts",
41
+ "./checkbox": "./src/components/checkbox/index.ts",
42
+ "./field": "./src/components/field/index.ts",
43
+ "./icon": "./src/components/icon/index.ts",
44
+ "./input": "./src/components/input/index.ts",
45
+ "./list-group": "./src/components/list-group/index.ts",
46
+ "./pressable": "./src/components/pressable/index.ts",
47
+ "./provider": "./src/components/provider/index.ts",
48
+ "./radio": "./src/components/radio/index.ts",
49
+ "./screen": "./src/components/screen/index.ts",
50
+ "./separator": "./src/components/separator/index.ts",
51
+ "./slider": "./src/components/slider/index.ts",
52
+ "./spinner": "./src/components/spinner/index.ts",
53
+ "./switch": "./src/components/switch/index.ts",
54
+ "./tabs": "./src/components/tabs/index.ts",
55
+ "./text": "./src/components/text/index.ts",
56
+ "./expo/navigation-theme": "./src/expo/navigation-theme.tsx",
57
+ "./hooks/use-controllable-state": "./src/hooks/use-controllable-state.ts",
58
+ "./hooks/use-keyboard-state-sync": "./src/hooks/use-keyboard-state-sync.tsx",
59
+ "./hooks/use-navigation-theme": "./src/hooks/use-navigation-theme.ts",
60
+ "./hooks/use-theme-color": "./src/hooks/use-theme-color.ts",
61
+ "./lib/cn": "./src/lib/cn.ts",
62
+ "./lib/color": "./src/lib/color.ts",
63
+ "./lib/compose-refs": "./src/lib/compose-refs.ts",
64
+ "./lib/keyboard-animation": "./src/lib/keyboard-animation.ts",
65
+ "./lib/merge-props": "./src/lib/merge-props.ts",
66
+ "./lib/navigation-theme": "./src/lib/navigation-theme.ts",
67
+ "./lib/slot": "./src/lib/slot.tsx",
68
+ "./lib/tv": "./src/lib/tv.ts",
69
+ "./icons/central": "./src/icons/central.ts"
70
+ },
71
+ "scripts": {
72
+ "check": "biome check ./src",
73
+ "typecheck": "tsc --noEmit",
74
+ "test": "bun test",
75
+ "gen-exports": "bun scripts/gen-exports.ts"
76
+ },
77
+ "dependencies": {
78
+ "clsx": "^2.1.1",
79
+ "tailwind-merge": "^3.6.0",
80
+ "tailwind-variants": "^3.3.1"
81
+ },
82
+ "peerDependencies": {
83
+ "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
84
+ "@delacour/react-native-charts": ">=0.1.0-alpha.4",
85
+ "@gorhom/bottom-sheet": "^5.2.8",
86
+ "@legendapp/list": ">=3.3",
87
+ "expo-linear-gradient": ">=15",
88
+ "expo-router": ">=57",
89
+ "react": ">=19",
90
+ "react-native": ">=0.81",
91
+ "react-native-gesture-handler": ">=2.28",
92
+ "react-native-keyboard-controller": ">=1.18",
93
+ "react-native-pulsar": "^1.7.0",
94
+ "react-native-reanimated": ">=4",
95
+ "react-native-safe-area-context": ">=5",
96
+ "react-native-screens": ">=4.16",
97
+ "react-native-svg": ">=15",
98
+ "react-native-worklets": ">=0.5",
99
+ "tailwindcss": "^4",
100
+ "uniwind": "^1.11.0"
101
+ },
102
+ "peerDependenciesMeta": {
103
+ "@delacour/react-native-charts": {
104
+ "optional": true
105
+ },
106
+ "@legendapp/list": {
107
+ "optional": true
108
+ },
109
+ "expo-router": {
110
+ "optional": true
111
+ },
112
+ "react-native-screens": {
113
+ "optional": true
114
+ }
115
+ },
116
+ "devDependencies": {
117
+ "@delacour/biome-config": "workspace:*",
118
+ "@delacour/react-native-charts": "workspace:*",
119
+ "@delacour/tsconfig": "workspace:*",
120
+ "@types/bun": "^1.3.8",
121
+ "@types/react": "catalog:",
122
+ "expo-router": "~57.0.15",
123
+ "typescript": "^5.9.3"
124
+ }
125
+ }
@@ -0,0 +1,192 @@
1
+ # Accordion
2
+
3
+ Rows that each disclose a panel. Compound root plus `Accordion.Item`,
4
+ `Accordion.Trigger`, `Accordion.Title`, `Accordion.Description`,
5
+ `Accordion.Indicator` and `Accordion.Content`. It takes
6
+ [ListGroup](../list-group/AGENTS.md)'s axes and its divider insertion outright,
7
+ so read that section first — what follows is only where a disclosure differs from
8
+ a list of rows.
9
+
10
+ `import { Accordion } from "@delacour/react-native-ui/accordion";`
11
+
12
+ ## Files
13
+
14
+ | File | What it holds |
15
+ | --- | --- |
16
+ | `index.ts` | → `@delacour/react-native-ui/accordion` |
17
+ | `accordion.tsx` | Root, the selection narrow, the divider insertion, the `Object.assign` |
18
+ | `accordion-item.tsx` | `Accordion.Item` — owns `progress`, the measured height and the spring |
19
+ | `accordion-trigger.tsx` | `Accordion.Trigger`, and the row it assembles from its children |
20
+ | `accordion-title.tsx` | `Accordion.Title` |
21
+ | `accordion-description.tsx` | `Accordion.Description` |
22
+ | `accordion-indicator.tsx` | `Accordion.Indicator`, the one rotation |
23
+ | `accordion-content.tsx` | `Accordion.Content` — the measured clip |
24
+ | `accordion.context.tsx` | `AccordionContext` and `AccordionItemContext`, with their hooks |
25
+ | `accordion.types.ts` | Prop types shared by two or more parts |
26
+ | `accordion.variants.ts` | Pure `tv()` slots + the selection maths, no RN imports |
27
+ | `accordion.variants.test.ts` | |
28
+
29
+ ## Design
30
+
31
+ - **Variants**: `default`, `secondary`, `tertiary`, `transparent`. **Sizes**:
32
+ `sm`, `md`, `lg` — driving the trigger's metrics, the title and description
33
+ type scale, the indicator's glyph step, the panel's padding *and* the divider
34
+ inset. Six numbers on one axis rather than in six places that can drift, and
35
+ the tests pin the inset against the trigger's own padding as a **pair**.
36
+ - **The corner is `rounded-md`, the 0.8 step of the shared ramp.** shadcn puts a
37
+ grouped surface a step or two below a card, so a group reads as a control
38
+ surface rather than as one. Size does not move it, and neither does a button's
39
+ own corner — `--radius-button-*` is a capsule at half its height and outside
40
+ this ramp. See the corner-scale note in
41
+ [the package AGENTS.md](../../../AGENTS.md).
42
+ - **The variant paints the root and nothing else.** Only the root has a surface;
43
+ a trigger or a panel that also changed with it would be a second source for one
44
+ colour. A test asserts every variant's root is distinct, because two cells
45
+ collapsing means a caller can set the axis and see nothing change.
46
+
47
+ ## The measured panel
48
+
49
+ - **A panel's height is measured, never laid out.** `Accordion.Content` clips,
50
+ and its height is an animated style running from zero to whatever its inner
51
+ view's `onLayout` reported. The alternative — unmounting the content and
52
+ letting a `LinearTransition` reflow the tree — makes every sibling on the
53
+ screen a participant in this component's animation, and other implementations
54
+ of this control document exactly that as a requirement. Here the animation is
55
+ contained to the accordion's own node: a real height is changing, so whatever
56
+ sits below follows it smoothly through ordinary layout, without opting into
57
+ anything and without a transition painted on it.
58
+ - **The measured layer is out of flow.** `contentInner` is absolutely positioned,
59
+ so its height is purely its content's and can never be fed back the clip's own
60
+ animated one. `left-0 right-0` is the other half: an absolute child is
61
+ content-width without it, and a paragraph would measure as a single unwrapped
62
+ line.
63
+ - **One shared value per item drives three properties** — the panel's height, the
64
+ panel's opacity and the indicator's rotation, all off `progress` through one
65
+ `ACCORDION_SPRING`. `Switch`'s rule, and the failure it prevents is visible:
66
+ two springs a few hundred stiffness apart leave the chevron lagging the panel
67
+ on every tap. A test pins that both read the same constant.
68
+ - **The spring is critically damped**, which is `Switch`'s reason turned inside
69
+ out. A switch damps its thumb because an overshoot squashes against a capsule
70
+ that clips it; a panel damps its height because an overshoot draws the panel
71
+ *taller* than its content measured, flashing the surface behind it for a frame
72
+ at the end of every expand.
73
+ - **The fade runs ahead of the height.** Opacity ramps across
74
+ `ACCORDION_CONTENT_FADE` rather than tracking `progress` linearly, because a
75
+ panel half transparent at the midpoint of every expand reads as content
76
+ struggling to arrive rather than as a panel opening.
77
+ - **A panel mounts on first expand and stays mounted.** Nothing renders until an
78
+ item is first opened, so a screen of collapsed panels costs an empty view each;
79
+ once opened, the subtree survives every later collapse with its scroll
80
+ position, its form state and its media intact. `hasExpanded` is adjusted during
81
+ render rather than in an effect, so the panel mounts in the same commit as the
82
+ tap — waiting a frame would put the first measurement, and therefore the start
83
+ of the travel, one frame later still.
84
+ - **That is what the three accessibility props on the clip are for.** Mounted
85
+ content is content a screen reader reads and a finger reaches, so a collapsed
86
+ panel sets `accessibilityElementsHidden`, `importantForAccessibility` and
87
+ `pointerEvents`. A component that unmounted its content never has to think
88
+ about this; the trade is worth making, but it is not free.
89
+ - **The first expand starts from the panel's own layout, not from the item's
90
+ effect.** A panel that has never mounted has no height to travel against, so
91
+ springing on the state change would run the whole animation at zero and jump
92
+ the moment a measurement arrived. The item's effect skips exactly that one
93
+ case and `accordion-content.tsx`'s `onLayout` starts it instead. Every later
94
+ toggle, in either direction, is the item's.
95
+ - **`ACCORDION_UNMEASURED` is negative, and that is load-bearing.** A panel that
96
+ measured `0` is a real answer — content that rendered nothing — and an item
97
+ treating it as "still waiting" would never start its spring, leaving the
98
+ indicator pointing the wrong way. Only a value no layout can produce can mean
99
+ *unmeasured*, so the height style floors it.
100
+
101
+ ## Selection
102
+
103
+ - **`selectionMode` is a true discriminated union**, so `single` with an array
104
+ `defaultValue` is a compile error. Intersecting the union with a widened
105
+ `defaultValue?: string | string[]` defeats it entirely — every member re-admits
106
+ both shapes, and `onValueChange` ends up typed as a function *returning* a
107
+ union rather than a union of functions, which is what forces a cast in the
108
+ toggle. `AccordionRoot` is nothing but the narrow, and it happens **before
109
+ anything is destructured**; destructure first and the correlation is gone.
110
+ - **The state is one internal `string[]` whichever mode is in play.**
111
+ `useControllableState` cannot be called conditionally, and two hooks would be
112
+ two pieces of state for one answer, so the caller's shape is converted at the
113
+ boundary — `toExpandedList` on the way in, a narrow on `selectionMode` on the
114
+ way out. `value={null}` in single mode is a controlled *empty*, not an absence,
115
+ which is why `undefined` is preserved rather than normalised away.
116
+ - **`isCollapsible` bounds the set, never a single item.** In `multiple` mode an
117
+ item still closes while another is open and only the last one is refused.
118
+ Reading it as "no item may ever close" makes a multiple accordion **add-only** —
119
+ a control that fills up and never empties — and that is the bug this rule
120
+ exists to name.
121
+ - **A refused tap returns its own input, by identity.** `toggleExpandedValue`
122
+ hands back the array it was given when the rules reject the change, so the root
123
+ can skip it: a rejected tap must not re-render and must not report an
124
+ `onValueChange` for a change that did not happen. A real change is always a new
125
+ array, because React bails out of a re-render on an unchanged reference.
126
+ - **`toggle` and the change callback are both ref-backed trampolines**, so the
127
+ accordion's context keeps its identity across every expansion and across a
128
+ caller passing a fresh arrow. Without them every item re-renders on every
129
+ toggle, which is what the implementations that hand a fresh object literal to
130
+ their provider actually do.
131
+
132
+ ## The trigger
133
+
134
+ - **A trigger is a `Pressable`**, so `feedback`, `haptic` and the rest are
135
+ inherited rather than restated. Two defaults differ: `fade`, because a
136
+ full-bleed row that scales reads as the whole card flexing — `ListGroup.Item`'s
137
+ reason — and `haptic="selection"`, because an item is a state toggle and the
138
+ panel landing is the confirmation, which is `Checkbox`'s and `Switch`'s.
139
+ `onPress` is the one prop `Omit`ed rather than forwarded: the press *is* the
140
+ toggle, and a side effect belongs on `onValueChange`. `Checkbox`'s trade.
141
+ - **The disabled fade lands on the item, never on the trigger.** The trigger is a
142
+ `Pressable`, whose root `Animated.View` writes `opacity` every frame through a
143
+ `useAnimatedStyle` of its own — a class on that node is overwritten silently,
144
+ the failure `Switch` and `Radio` both record. A test sweeps for it.
145
+ - **The trigger assembles its own row.** Titles and descriptions stack in a
146
+ column, anything else — a leading `Icon`, a `Badge` — stays where it was
147
+ written as a row sibling, and the indicator is moved to the end and composed in
148
+ when the children hold none. That is what makes an `Accordion.TriggerContent`
149
+ part unnecessary rather than merely absent. Bare string children are wrapped in
150
+ an `Accordion.Title`, consecutive strings collapsing into one — the same rule,
151
+ and the same reason, as `Button`.
152
+ - **The indicator rotates off the item's own `progress`**, so a *custom* glyph
153
+ turns exactly like the default one. Passing children and silently dropping the
154
+ animated style is a real trap in other implementations of this control, and the
155
+ opt-out here is explicit: `isAnimated={false}`, for a plus becoming a minus,
156
+ which reads as broken when it also spins. The rotation lives on the indicator's
157
+ node rather than the trigger's because `Pressable` already owns `transform`
158
+ there.
159
+ - **An indicator has to be a direct `Accordion.Indicator` child of the trigger.**
160
+ The trigger finds it by element type, so one wrapped in a component of the
161
+ caller's own is invisible to it and a second, default one is composed in
162
+ beside it — two glyphs on the row, with nothing to explain them.
163
+ `hasThumbChild` carries the same limitation for `Switch`. What closes it here
164
+ is that the indicator takes a **render function**, handed the item's settled
165
+ state: reading `isExpanded` means calling a hook, a hook cannot be called in
166
+ the parent's JSX, and that is the only thing a wrapper was ever for.
167
+ `Slider.Track` takes a function for the same reason.
168
+ - **The indicator takes `muted-foreground`, a step quieter than the title.** The
169
+ chevron is chrome saying the row opens; the title is the content. A test pins
170
+ that the two tokens differ.
171
+
172
+ ## Accessibility and structure
173
+
174
+ - **There is no separate heading element.** Wrapping the trigger in one and
175
+ setting `aria-expanded` there as well as `accessibilityState.expanded` on the
176
+ button inside is state written twice, and a screen reader reads it out twice.
177
+ `Pressable` already supplies the role, `accessible` and the state merge.
178
+ - **The root is a plain `View` with no role.** Announcing a container as a
179
+ control puts an actionless element in front of every child — `Checkbox.Group`'s
180
+ rule.
181
+ - **Dividers are inserted, not written out**, `ListGroup`'s implementation
182
+ verbatim in shape: `Children.toArray` drops the nulls a conditional item leaves
183
+ behind, and a hand-placed `Separator` suppresses the automatic one on either
184
+ side. Counting indices against the raw children instead — `index < count - 1` —
185
+ miscounts a conditional item and breaks outright on a fragment.
186
+ - **Reduce motion is left at Reanimated's default `System` policy**, with
187
+ `Switch`, `Checkbox` and `Radio` and against `Spinner`. The state is carried by
188
+ whether the panel is *there*, so completing instantly is the right degradation.
189
+ - **An item is expected to hold an `Accordion.Content`.** One without a panel has
190
+ nothing to measure and therefore nothing to travel against, so its indicator
191
+ stays put — which is the right outcome for a row that discloses nothing, and is
192
+ stated here rather than guarded against.
@@ -0,0 +1,125 @@
1
+ import { type ReactElement, type ReactNode, useCallback, useState } from "react";
2
+ import { type LayoutChangeEvent, StyleSheet, View, type ViewProps } from "react-native";
3
+ import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
4
+ import { useAccordionItemPart, useAccordionPart } from "./accordion.context";
5
+ import { ACCORDION_CONTENT_FADE, ACCORDION_UNMEASURED, accordionVariants } from "./accordion.variants";
6
+
7
+ export type AccordionContentProps = Omit<ViewProps, "style"> & {
8
+ className?: string;
9
+ children?: ReactNode;
10
+ };
11
+
12
+ /**
13
+ * The panel an item discloses.
14
+ *
15
+ * **Its height is measured and animated, never laid out.** The outer view clips,
16
+ * and its height runs from zero to whatever the inner view reported. The animation
17
+ * therefore lives on this one node: whatever sits below follows it through
18
+ * ordinary layout, with nothing to opt into. The alternative — unmounting the
19
+ * content and letting a whole-tree layout transition reflow the screen — makes
20
+ * every sibling on the page a participant in this component's animation.
21
+ *
22
+ * The inner view is **out of flow**, so its height is its content's and can never
23
+ * be fed back the clip's own animated one. In flow it is laid out against that
24
+ * height, and a panel measured while the clip sits at zero reports its padding and
25
+ * nothing else.
26
+ *
27
+ * **The content mounts on first expand and stays mounted.** Nothing renders until
28
+ * an item is first opened, so a screen of collapsed panels costs an empty view
29
+ * each; once opened, the subtree survives every later collapse with its scroll
30
+ * position, its form state and its media intact. A panel that unmounted would
31
+ * destroy all three on every tap.
32
+ *
33
+ * That is what the three accessibility props are for. Mounted content is content a
34
+ * screen reader will read and a finger can reach, so a collapsed panel has to say
35
+ * it is not there — an unmounting accordion never has to think about this.
36
+ *
37
+ * **It reports its first measurement and never starts the travel itself.** The
38
+ * item owns the spring alone; see `onMeasured` on the item context for the race
39
+ * that rule exists to close.
40
+ */
41
+ export function AccordionContent({ className, children, ...props }: AccordionContentProps): ReactElement {
42
+ const { size } = useAccordionPart("Accordion.Content");
43
+ const { contentHeight, isExpanded, onMeasured, progress } = useAccordionItemPart("Accordion.Content");
44
+ const slots = accordionVariants({ size });
45
+
46
+ // Adjusted during render rather than in an effect, so the panel mounts in the
47
+ // same commit as the tap that opened it. Waiting a frame would put the first
48
+ // measurement — and therefore the start of the travel — one frame later still.
49
+ const [hasExpanded, setHasExpanded] = useState(isExpanded);
50
+ if (isExpanded && !hasExpanded) setHasExpanded(true);
51
+
52
+ // Whether this panel has ever reported a height. Until it has, the clip must
53
+ // not constrain anything — see `accordionStyleSheet.measuring` below.
54
+ const [isMeasured, setMeasured] = useState(false);
55
+
56
+ const handleLayout = useCallback(
57
+ (event: LayoutChangeEvent) => {
58
+ const wasMeasured = contentHeight.value > ACCORDION_UNMEASURED;
59
+ contentHeight.value = event.nativeEvent.layout.height;
60
+
61
+ // The panel reports its first measurement and stops there — it must not
62
+ // start the travel itself. `onLayout` is dispatched from the native side
63
+ // and can land either side of React's effects, so a spring started here
64
+ // is sometimes cancelled a moment later by the cleanup of the item's
65
+ // effect it raced, leaving the panel shut and the indicator pointing the
66
+ // wrong way. Telling the item instead leaves one owner of the spring.
67
+ if (wasMeasured) return;
68
+ setMeasured(true);
69
+ onMeasured();
70
+ },
71
+ [contentHeight, onMeasured]
72
+ );
73
+
74
+ // Height and opacity off the one `progress`, never off a timing of their own —
75
+ // so there is no second clock the panel's position can drift from. The fade
76
+ // closes before the height does, which is what keeps the content legible for
77
+ // most of the travel rather than half transparent at its midpoint.
78
+ const clipStyle = useAnimatedStyle(() => ({
79
+ height: progress.value * Math.max(contentHeight.value, 0),
80
+ opacity: interpolate(
81
+ progress.value,
82
+ [0, ACCORDION_CONTENT_FADE.start, ACCORDION_CONTENT_FADE.end, 1],
83
+ [0, 0, 1, 1]
84
+ ),
85
+ }));
86
+
87
+ return (
88
+ <Animated.View
89
+ accessibilityElementsHidden={!isExpanded}
90
+ className={slots.content()}
91
+ importantForAccessibility={isExpanded ? "auto" : "no-hide-descendants"}
92
+ // Never `auto` while measuring: the clip is stretched over its own item
93
+ // for that commit, and an invisible layer over a trigger is a tap that
94
+ // goes nowhere.
95
+ pointerEvents={isExpanded && isMeasured ? "auto" : "none"}
96
+ style={isMeasured ? clipStyle : accordionStyleSheet.measuring}
97
+ >
98
+ {hasExpanded ? (
99
+ <View className={slots.contentInner({ className })} onLayout={handleLayout} {...props}>
100
+ {children}
101
+ </View>
102
+ ) : null}
103
+ </Animated.View>
104
+ );
105
+ }
106
+ AccordionContent.displayName = "DelacourUI.Accordion.Content";
107
+
108
+ const accordionStyleSheet = StyleSheet.create({
109
+ /**
110
+ * What the clip wears until its panel has reported a height.
111
+ *
112
+ * Absolutely positioned over its own item, so it is out of the item's layout and
113
+ * nothing on screen moves while it measures, and transparent, so nothing shows
114
+ * either. What matters is what it is *not*: it is not `height: 0`. A clip
115
+ * already at zero squashes an in-flow child to its padding, and never fires
116
+ * `onLayout` at all for an out-of-flow one — its frame is empty, so no layout
117
+ * event is emitted and the measurement arrives only when something unrelated
118
+ * forces a relayout, minutes later or never. Both were found on a simulator.
119
+ *
120
+ * The switch to the animated clip happens on the commit the measurement lands,
121
+ * while `progress` is still zero — so the panel goes from taking no space to
122
+ * taking none, and the travel starts from there.
123
+ */
124
+ measuring: { bottom: 0, left: 0, opacity: 0, position: "absolute", right: 0, top: 0 },
125
+ });
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import { useAccordionPart } from "./accordion.context";
4
+ import type { AccordionTextProps } from "./accordion.types";
5
+ import { accordionVariants } from "./accordion.variants";
6
+
7
+ /**
8
+ * The trigger's secondary line, a step down in scale and on the muted token.
9
+ *
10
+ * Sits under the title inside the trigger's own text column, which the trigger
11
+ * assembles — so the two stack without a wrapper part between them.
12
+ */
13
+ export function AccordionDescription({ className, ...props }: AccordionTextProps): ReactElement {
14
+ const { size } = useAccordionPart("Accordion.Description");
15
+ return <Text className={accordionVariants({ size }).description({ className })} {...props} />;
16
+ }
17
+ AccordionDescription.displayName = "DelacourUI.Accordion.Description";