@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,65 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { ListGroupSize, ListGroupVariant } from "./list-group.variants";
3
+
4
+ export type ListGroupContextValue = {
5
+ /** Size of the list group. */
6
+ size: ListGroupSize;
7
+ /** Visual variant of the list group. */
8
+ variant: ListGroupVariant;
9
+ };
10
+
11
+ const ListGroupContext = createContext<ListGroupContextValue | null>(null);
12
+
13
+ /**
14
+ * Supplies the enclosing list group's size and variant to its subtree.
15
+ *
16
+ * Lives in its own module, importing nothing but `list-group.variants`, so a
17
+ * component the list group renders can read it without importing `../list-group`
18
+ * and closing a cycle through `list-group.tsx`. See AGENTS.md rule 3.
19
+ */
20
+ export function ListGroupProvider({
21
+ value,
22
+ children,
23
+ }: {
24
+ value: ListGroupContextValue;
25
+ children: ReactNode;
26
+ }): ReactElement {
27
+ return <ListGroupContext value={value}>{children}</ListGroupContext>;
28
+ }
29
+ ListGroupProvider.displayName = "DelacourUI.ListGroup.Provider";
30
+
31
+ /** The enclosing list group's context, or null outside a `<ListGroup>`. */
32
+ export function useListGroupContext(): ListGroupContextValue | null {
33
+ return use(ListGroupContext);
34
+ }
35
+
36
+ /**
37
+ * Reads the enclosing list group's size and variant.
38
+ *
39
+ * Lets a custom row style itself to match without the list group having to pass
40
+ * props down through every slot. Throws outside a `<ListGroup>` — use
41
+ * {@link useListGroupContext} where the enclosing list group is optional.
42
+ */
43
+ export function useListGroup(): ListGroupContextValue {
44
+ const context = useListGroupContext();
45
+ if (!context) {
46
+ throw new Error("useListGroup must be called inside a <ListGroup>.");
47
+ }
48
+ return context;
49
+ }
50
+
51
+ /**
52
+ * The enclosing list group's context, for a compound part that cannot work
53
+ * without one.
54
+ *
55
+ * Internal: deliberately not re-exported from `index.ts`. A caller outside the
56
+ * library wants {@link useListGroup}, whose error message names the hook rather
57
+ * than a part.
58
+ */
59
+ export function useListGroupPart(component: string): ListGroupContextValue {
60
+ const context = useListGroupContext();
61
+ if (!context) {
62
+ throw new Error(`${component} must be rendered inside a <ListGroup>.`);
63
+ }
64
+ return context;
65
+ }
@@ -0,0 +1,116 @@
1
+ import { Children, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { Separator } from "../separator";
4
+ import { type ListGroupContextValue, ListGroupProvider } from "./list-group.context";
5
+ import { type ListGroupSize, type ListGroupVariant, listGroupVariants } from "./list-group.variants";
6
+ import { ListGroupItem } from "./list-group-item";
7
+ import { ListGroupItemContent } from "./list-group-item-content";
8
+ import { ListGroupItemDescription } from "./list-group-item-description";
9
+ import { ListGroupItemPrefix } from "./list-group-item-prefix";
10
+ import { ListGroupItemSuffix } from "./list-group-item-suffix";
11
+ import { ListGroupItemTitle } from "./list-group-item-title";
12
+
13
+ export type ListGroupProps = ViewProps & {
14
+ variant?: ListGroupVariant;
15
+ size?: ListGroupSize;
16
+ /** Draw a divider between adjacent children. On by default. */
17
+ isDivided?: boolean;
18
+ className?: string;
19
+ children?: ReactNode;
20
+ };
21
+
22
+ function ListGroupRoot({
23
+ variant = "default",
24
+ size = "md",
25
+ isDivided = true,
26
+ className,
27
+ children,
28
+ ...props
29
+ }: ListGroupProps): ReactElement {
30
+ const context = useMemo<ListGroupContextValue>(() => ({ size, variant }), [size, variant]);
31
+
32
+ const slots = listGroupVariants({ size, variant });
33
+ const dividerClassName = slots.divider();
34
+
35
+ const content = useMemo(
36
+ () => (isDivided ? withDividers(children, dividerClassName) : children),
37
+ [children, isDivided, dividerClassName]
38
+ );
39
+
40
+ return (
41
+ <ListGroupProvider value={context}>
42
+ <View className={slots.root({ className })} {...props}>
43
+ {content}
44
+ </View>
45
+ </ListGroupProvider>
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Inserts a divider between adjacent children.
51
+ *
52
+ * A pair is skipped when either side already is a `Separator`, so a hand-placed
53
+ * divider is never doubled. `Children.toArray` drops the nulls and booleans a
54
+ * conditional child leaves behind, so a row rendered only some of the time does
55
+ * not strand a divider where nothing follows it.
56
+ */
57
+ function withDividers(children: ReactNode, dividerClassName: string): ReactNode {
58
+ const items = Children.toArray(children);
59
+ const output: ReactNode[] = [];
60
+
61
+ for (const [index, child] of items.entries()) {
62
+ if (index > 0 && !isSeparator(items[index - 1]) && !isSeparator(child)) {
63
+ output.push(<Separator className={dividerClassName} key={`divider-${index}`} />);
64
+ }
65
+ output.push(child);
66
+ }
67
+
68
+ return output;
69
+ }
70
+
71
+ function isSeparator(node: ReactNode): boolean {
72
+ return isValidElement(node) && node.type === Separator;
73
+ }
74
+
75
+ /**
76
+ * A surface grouping related rows under one rounded, clipped container.
77
+ *
78
+ * `size` and `variant` reach the sub-components through context, so a row's
79
+ * title picks its own text colour and type scale. That indirection is not
80
+ * incidental: a React Native `View` does not cascade colour to a `Text`
81
+ * descendant the way a DOM element would.
82
+ *
83
+ * Dividers are inserted between adjacent children rather than written out at
84
+ * every call site, inset to line up with the rows' own padding. A `Separator`
85
+ * placed by hand is respected — no divider is added on either side of it — so a
86
+ * caller can still control one gap without turning the whole feature off.
87
+ *
88
+ * @example
89
+ * <ListGroup>
90
+ * <ListGroup.Item onPress={openProfile}>
91
+ * <ListGroup.ItemPrefix>
92
+ * <Icon icon={IconUser} />
93
+ * </ListGroup.ItemPrefix>
94
+ * <ListGroup.ItemContent>
95
+ * <ListGroup.ItemTitle>Personal info</ListGroup.ItemTitle>
96
+ * <ListGroup.ItemDescription>Name, email, phone</ListGroup.ItemDescription>
97
+ * </ListGroup.ItemContent>
98
+ * <ListGroup.ItemSuffix />
99
+ * </ListGroup.Item>
100
+ * </ListGroup>
101
+ */
102
+ export const ListGroup = Object.assign(ListGroupRoot, {
103
+ /** One row of a list group. Bare text children are wrapped in a title inside a content column. */
104
+ Item: ListGroupItem,
105
+ /** The leading slot of a row. Its subtree inherits the group's icon size and foreground token. */
106
+ ItemPrefix: ListGroupItemPrefix,
107
+ /** The text column of a row, taking whatever width the prefix and suffix leave. */
108
+ ItemContent: ListGroupItemContent,
109
+ /** The row's primary line. Carries its own colour — a `View` cannot cascade one to a `Text`. */
110
+ ItemTitle: ListGroupItemTitle,
111
+ /** The row's secondary line, a step down in scale and on the muted token. */
112
+ ItemDescription: ListGroupItemDescription,
113
+ /** The trailing slot of a row, showing a chevron unless given content. */
114
+ ItemSuffix: ListGroupItemSuffix,
115
+ displayName: "DelacourUI.ListGroup",
116
+ });
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from "react";
2
+ import type { TextProps, ViewProps } from "react-native";
3
+
4
+ /**
5
+ * The shape of a row's leading, text and trailing slots.
6
+ *
7
+ * Shared by `ListGroup.ItemPrefix` and `ListGroup.ItemContent`, and extended by
8
+ * `ListGroup.ItemSuffix`, so it lives in a leaf rather than in one of them
9
+ * arbitrarily.
10
+ */
11
+ export type ListGroupSlotProps = ViewProps & { className?: string; children?: ReactNode };
12
+
13
+ /** The shape of a row's two text lines, `ItemTitle` and `ItemDescription`. */
14
+ export type ListGroupTextProps = TextProps & { className?: string };
@@ -0,0 +1,223 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ICON_SIZE_TOKENS } from "../../styles/tokens";
3
+ import { LIST_GROUP_SIZES, LIST_GROUP_VARIANTS, listGroupVariants } from "./list-group.variants";
4
+
5
+ /** Pulls the horizontal padding step out of a class string — `px-4` yields 4. */
6
+ function paddingStep(cls: string): string | undefined {
7
+ return cls.match(/\bpx-(\d+(?:\.\d+)?)\b/)?.[1];
8
+ }
9
+
10
+ /** Pulls the horizontal margin step out of a class string — `mx-4` yields 4. */
11
+ function marginStep(cls: string): string | undefined {
12
+ return cls.match(/\bmx-(\d+(?:\.\d+)?)\b/)?.[1];
13
+ }
14
+
15
+ /**
16
+ * Position of a class string's `size-icon-*` token on the shared icon scale.
17
+ *
18
+ * Compares by step rather than by points, so a test says what it means and
19
+ * survives a token being retuned in `tokens.css`. `tokens.test.ts` is what
20
+ * keeps that array ordered.
21
+ */
22
+ function iconStep(cls: string): number {
23
+ const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
24
+ return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
25
+ }
26
+
27
+ describe("listGroupVariants root slot", () => {
28
+ test("defaults to the card surface at md", () => {
29
+ const cls = listGroupVariants().root();
30
+ expect(cls).toContain("bg-card");
31
+ expect(cls).toContain("border-border");
32
+ });
33
+
34
+ test("gives every variant a distinct surface", () => {
35
+ const seen = new Set(LIST_GROUP_VARIANTS.map((variant) => listGroupVariants({ variant }).root()));
36
+ expect(seen.size).toBe(LIST_GROUP_VARIANTS.length);
37
+ });
38
+
39
+ test("maps each variant to its surface", () => {
40
+ expect(listGroupVariants({ variant: "default" }).root()).toContain("bg-card");
41
+ expect(listGroupVariants({ variant: "secondary" }).root()).toContain("bg-secondary");
42
+ expect(listGroupVariants({ variant: "tertiary" }).root()).toContain("bg-tertiary");
43
+ expect(listGroupVariants({ variant: "transparent" }).root()).toContain("bg-transparent");
44
+ });
45
+
46
+ test("only the default variant draws a border", () => {
47
+ expect(listGroupVariants({ variant: "default" }).root()).toContain("border-border");
48
+ for (const variant of LIST_GROUP_VARIANTS.filter((name) => name !== "default")) {
49
+ expect(listGroupVariants({ variant }).root()).not.toContain("border-border");
50
+ }
51
+ });
52
+
53
+ // The root's radius has to clip its rows: a pressed row fades to the edge of
54
+ // its own box, and without this the fade squares off the group's corners.
55
+ test("clips its rows at every variant and size", () => {
56
+ for (const variant of LIST_GROUP_VARIANTS) {
57
+ for (const size of LIST_GROUP_SIZES) {
58
+ const cls = listGroupVariants({ size, variant }).root();
59
+ expect(cls).toContain("overflow-hidden");
60
+ expect(cls).toMatch(/\brounded-/);
61
+ }
62
+ }
63
+ });
64
+
65
+ // Rule 1: a React Native View does not cascade colour to a Text descendant,
66
+ // so the text tokens belong on the title and description, never here.
67
+ test("carries no text treatment", () => {
68
+ for (const variant of LIST_GROUP_VARIANTS) {
69
+ expect(listGroupVariants({ variant }).root()).not.toMatch(/\btext-/);
70
+ }
71
+ });
72
+
73
+ test("merges an incoming className last", () => {
74
+ expect(listGroupVariants().root({ className: "mb-6" })).toContain("mb-6");
75
+ expect(listGroupVariants().root({ className: "bg-popover" })).not.toContain("bg-card");
76
+ });
77
+ });
78
+
79
+ describe("listGroupVariants item slot", () => {
80
+ test("lays a row out horizontally across its full width", () => {
81
+ const cls = listGroupVariants().item();
82
+ expect(cls).toContain("flex-row");
83
+ expect(cls).toContain("items-center");
84
+ expect(cls).toContain("w-full");
85
+ });
86
+
87
+ test("gives every size a distinct row metric", () => {
88
+ const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).item()));
89
+ expect(seen.size).toBe(LIST_GROUP_SIZES.length);
90
+ });
91
+
92
+ test("pads and spaces every size", () => {
93
+ for (const size of LIST_GROUP_SIZES) {
94
+ const cls = listGroupVariants({ size }).item();
95
+ expect(cls).toMatch(/\bmin-h-\d/);
96
+ expect(cls).toMatch(/\bpx-\d/);
97
+ expect(cls).toMatch(/\bpy-\d/);
98
+ expect(cls).toMatch(/\bgap-\d/);
99
+ }
100
+ });
101
+
102
+ test("adds the disabled treatment only when disabled", () => {
103
+ expect(listGroupVariants({ isDisabled: true }).item()).toContain("opacity-50");
104
+ expect(listGroupVariants({ isDisabled: false }).item()).not.toContain("opacity-50");
105
+ });
106
+
107
+ // Rule 1 again — the row is a View, so the title owns its own colour.
108
+ test("carries no text treatment", () => {
109
+ for (const size of LIST_GROUP_SIZES) {
110
+ expect(listGroupVariants({ size }).item()).not.toMatch(/\btext-/);
111
+ }
112
+ });
113
+
114
+ test("merges an incoming className last", () => {
115
+ expect(listGroupVariants().item({ className: "opacity-80" })).toContain("opacity-80");
116
+ });
117
+ });
118
+
119
+ describe("listGroupVariants divider slot", () => {
120
+ // The load-bearing coupling: an auto-inserted divider is inset to line up with
121
+ // the row's own padding. Drift between the two is what makes a grouped list
122
+ // look wrong, so the pair is asserted together rather than pinned separately.
123
+ // One slotted tv is what now makes this hard to break — both read one axis.
124
+ test("insets the divider to the row's horizontal padding at every size", () => {
125
+ for (const size of LIST_GROUP_SIZES) {
126
+ const slots = listGroupVariants({ size });
127
+ const inset = marginStep(slots.divider());
128
+ const padding = paddingStep(slots.item());
129
+ expect(inset).toBeDefined();
130
+ expect(inset).toBe(padding);
131
+ }
132
+ });
133
+
134
+ test("gives every size a distinct inset", () => {
135
+ const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).divider()));
136
+ expect(seen.size).toBe(LIST_GROUP_SIZES.length);
137
+ });
138
+
139
+ // The line itself comes from Separator; this only positions it.
140
+ test("carries no colour of its own", () => {
141
+ for (const size of LIST_GROUP_SIZES) {
142
+ expect(listGroupVariants({ size }).divider()).not.toMatch(/\b(bg|text|border)-/);
143
+ }
144
+ });
145
+ });
146
+
147
+ describe("listGroupVariants content slot", () => {
148
+ test("takes the space the prefix and suffix leave", () => {
149
+ expect(listGroupVariants().content()).toContain("flex-1");
150
+ });
151
+ });
152
+
153
+ describe("listGroupVariants title slot", () => {
154
+ test("carries the foreground colour, which the row must not", () => {
155
+ expect(listGroupVariants().title()).toContain("text-foreground");
156
+ });
157
+
158
+ test("gives every size a distinct type scale", () => {
159
+ const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).title()));
160
+ expect(seen.size).toBe(LIST_GROUP_SIZES.length);
161
+ });
162
+
163
+ test("merges an incoming className last", () => {
164
+ expect(listGroupVariants().title({ className: "font-bold" })).toContain("font-bold");
165
+ });
166
+ });
167
+
168
+ describe("listGroupVariants description slot", () => {
169
+ test("sits back from the title on the muted token", () => {
170
+ expect(listGroupVariants().description()).toContain("text-muted-foreground");
171
+ expect(listGroupVariants().description()).not.toContain("text-foreground");
172
+ });
173
+
174
+ test("gives every size a distinct type scale", () => {
175
+ const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).description()));
176
+ expect(seen.size).toBe(LIST_GROUP_SIZES.length);
177
+ });
178
+
179
+ test("stays smaller than the title it sits under", () => {
180
+ const scale = ["text-xs", "text-sm", "text-base", "text-lg"];
181
+ for (const size of LIST_GROUP_SIZES) {
182
+ const slots = listGroupVariants({ size });
183
+ const title = scale.findIndex((step) => slots.title().includes(step));
184
+ const description = scale.findIndex((step) => slots.description().includes(step));
185
+ expect(description).toBeGreaterThanOrEqual(0);
186
+ expect(description).toBeLessThan(title);
187
+ }
188
+ });
189
+ });
190
+
191
+ describe("listGroupVariants icon slots", () => {
192
+ test("give every size a distinct prefix icon token, increasing with it", () => {
193
+ const steps = LIST_GROUP_SIZES.map((size) => iconStep(listGroupVariants({ size }).prefixIcon()));
194
+ expect(steps).not.toContain(-1);
195
+ expect(new Set(steps).size).toBe(LIST_GROUP_SIZES.length);
196
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
197
+ });
198
+
199
+ test("give every size a distinct suffix icon token, increasing with it", () => {
200
+ const steps = LIST_GROUP_SIZES.map((size) => iconStep(listGroupVariants({ size }).suffixIcon()));
201
+ expect(steps).not.toContain(-1);
202
+ expect(new Set(steps).size).toBe(LIST_GROUP_SIZES.length);
203
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
204
+ });
205
+
206
+ // The trailing chevron is a hint, not content: it stays under the leading
207
+ // icon so a row reads left to right rather than being pulled to its edge.
208
+ test("keep the suffix icon smaller than the prefix icon at every size", () => {
209
+ for (const size of LIST_GROUP_SIZES) {
210
+ const slots = listGroupVariants({ size });
211
+ expect(iconStep(slots.suffixIcon())).toBeLessThan(iconStep(slots.prefixIcon()));
212
+ }
213
+ });
214
+
215
+ // An icon takes a colour value rather than a class — the slot sizes it only.
216
+ test("carry no colour", () => {
217
+ for (const size of LIST_GROUP_SIZES) {
218
+ const slots = listGroupVariants({ size });
219
+ expect(slots.prefixIcon()).not.toMatch(/\b(bg|text|border)-/);
220
+ expect(slots.suffixIcon()).not.toMatch(/\b(bg|text|border)-/);
221
+ }
222
+ });
223
+ });
@@ -0,0 +1,107 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+
4
+ export const LIST_GROUP_VARIANTS = ["default", "secondary", "tertiary", "transparent"] as const;
5
+
6
+ export const LIST_GROUP_SIZES = ["sm", "md", "lg"] as const;
7
+
8
+ export type ListGroupVariant = (typeof LIST_GROUP_VARIANTS)[number];
9
+ export type ListGroupSize = (typeof LIST_GROUP_SIZES)[number];
10
+
11
+ /** Theme token an icon in `ListGroup.ItemPrefix` inherits. */
12
+ export const LIST_GROUP_FOREGROUND_TOKEN = "foreground";
13
+
14
+ /** Theme token the default trailing chevron inherits. */
15
+ export const LIST_GROUP_SUFFIX_ICON_TOKEN = "muted-foreground";
16
+
17
+ /**
18
+ * Styling for every part of a list group.
19
+ *
20
+ * Both icon slots name a step on the shared `--spacing-icon-*` scale, the same
21
+ * one `Icon` and `Spinner` use, so a row's glyph lines up with every other glyph
22
+ * in the library rather than carrying a private number.
23
+ *
24
+ * One slotted `tv()` rather than a call per part, because size is not
25
+ * decoration here: it drives the row metrics, the title and description type
26
+ * scale, both icon sizes *and* the divider inset. Those five belong on one axis
27
+ * rather than in five places that can drift apart — the divider inset tracking
28
+ * the row's padding is asserted as a pair in the tests for exactly that reason.
29
+ *
30
+ * `overflow-hidden` on the root is load-bearing rather than tidiness: a pressed
31
+ * row fades to the edge of its own box, and the first and last rows would square
32
+ * off the group's corners without it.
33
+ *
34
+ * The root and item slots hold no `text-*` utility — a React Native `View` does
35
+ * not cascade colour to a `Text` descendant, so the text tokens live on the
36
+ * title and description slots.
37
+ *
38
+ * `w-full` on the item matters because the root clips: without it a short row
39
+ * leaves the press feedback ending mid-card instead of spanning it.
40
+ *
41
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
42
+ * parse React Native's Flow-typed source. See AGENTS.md.
43
+ */
44
+ export const listGroupVariants = tv({
45
+ slots: {
46
+ root: "overflow-hidden border border-transparent",
47
+ item: "w-full flex-row items-center",
48
+ /** Positioning only for a divider the root inserts — the line is a `Separator`. */
49
+ divider: "",
50
+ prefix: "items-center justify-center",
51
+ /** Edge length an `Icon` in the prefix inherits. */
52
+ prefixIcon: "",
53
+ content: "flex-1 justify-center gap-0.5",
54
+ title: "font-medium text-foreground",
55
+ description: "text-muted-foreground",
56
+ suffix: "items-center justify-center",
57
+ /** Edge length of the default trailing chevron. */
58
+ suffixIcon: "",
59
+ },
60
+ variants: {
61
+ variant: {
62
+ default: { root: "border-border bg-card" },
63
+ secondary: { root: "bg-secondary" },
64
+ tertiary: { root: "bg-tertiary" },
65
+ transparent: { root: "bg-transparent" },
66
+ },
67
+ size: {
68
+ sm: {
69
+ root: "rounded-md",
70
+ item: "min-h-12 gap-2.5 px-3 py-2",
71
+ divider: "mx-3",
72
+ prefixIcon: "size-icon-md",
73
+ suffixIcon: "size-icon-xs",
74
+ title: "text-sm",
75
+ description: "text-xs",
76
+ },
77
+ md: {
78
+ root: "rounded-md",
79
+ item: "min-h-14 gap-3 px-4 py-3",
80
+ divider: "mx-4",
81
+ prefixIcon: "size-icon-lg",
82
+ suffixIcon: "size-icon-sm",
83
+ title: "text-base",
84
+ description: "text-sm",
85
+ },
86
+ lg: {
87
+ root: "rounded-md",
88
+ item: "min-h-16 gap-3.5 px-5 py-4",
89
+ divider: "mx-5",
90
+ prefixIcon: "size-icon-xl",
91
+ suffixIcon: "size-icon-md",
92
+ title: "text-lg",
93
+ description: "text-base",
94
+ },
95
+ },
96
+ // The empty `false` branch types the prop as `boolean` rather than `true`.
97
+ // See the note in button.variants.ts.
98
+ isDisabled: { true: { item: "opacity-50" }, false: {} },
99
+ },
100
+ defaultVariants: {
101
+ variant: "default",
102
+ size: "md",
103
+ isDisabled: false,
104
+ },
105
+ });
106
+
107
+ export type ListGroupVariantProps = VariantProps<typeof listGroupVariants>;
@@ -0,0 +1,64 @@
1
+ # Pressable
2
+
3
+ The interaction primitive every pressable component in this library builds on —
4
+ a Gesture Handler tap drives the press feedback and the haptic on the UI thread,
5
+ and only `onPress` and `onLongPress` cross back to JS.
6
+
7
+ `import { Pressable } from "@delacour/react-native-ui/pressable";`
8
+
9
+ ## Files
10
+
11
+ | File | What it holds |
12
+ | --- | --- |
13
+ | `index.ts` | → `@delacour/react-native-ui/pressable` |
14
+ | `pressable.tsx` | The Gesture API primitive |
15
+ | `pressable.variants.ts` | Shared feedback vocabulary, no RN imports |
16
+ | `pressable.variants.test.ts` | |
17
+
18
+ ## Design
19
+
20
+ - **`pressable.variants.ts` holds no `tv()`, on purpose.** Its values are
21
+ opacity and scale *interpolation targets* read by a worklet on the UI thread
22
+ (`1 - pressed.value * (1 - targetOpacity)`), not styles. A worklet cannot
23
+ compile a className, so `tv` is the wrong tool here — a deliberate exception
24
+ to pattern C, not an oversight.
25
+ - **`feedback` is the shared vocabulary**: `scale`, `fade`, `scale-fade`,
26
+ `none`, defined once in `pressable.variants.ts`. Every pressable in the
27
+ library resolves through it rather than keeping a private map. A control
28
+ changes its **default** and nothing else — [`Button`](../button/AGENTS.md) to
29
+ `scale`, [`ListGroup.Item`](../list-group/AGENTS.md) to `fade`. Neither
30
+ narrows the union: a second definition of a prop the control does not change
31
+ is a definition that can drift, which is why the button's own
32
+ `ButtonFeedback` was removed.
33
+ - **`scale-fade` is composed, not spelled out.** Its scale comes from `scale`
34
+ and its opacity from `fade`, so tuning either single-axis mode carries
35
+ through and the name keeps describing what the mode does. A test asserts it.
36
+ - **`pressedScale` / `pressedOpacity` win on the axis they name**, and leave the
37
+ other to `feedback`. They are the escape hatch for a value the named modes do
38
+ not cover, not an alternative API — `??` is the merge, so an explicit `0` is a
39
+ value rather than an absence.
40
+ - **The no-feedback fallback is unnamed on purpose.** A bare `Pressable` presses
41
+ to `{ opacity: 0.9, scale: 0.97 }`, which fades less than `fade` does. Naming
42
+ it would either change what a bare pressable has always done or force
43
+ `scale-fade` to fade less than `fade`.
44
+ - **`disabled` vs `busy`.** Both block the gesture; only `disabled` announces
45
+ the control as disabled. Use `busy` for a temporary state the component clears
46
+ itself, so assistive tech reports a control that is momentarily unavailable
47
+ rather than one that is inert. Neither applies any opacity — that is the
48
+ caller's variant's job.
49
+ - **A worklet crosses back to JS with `scheduleOnRN`**, imported from
50
+ `react-native-worklets` — never Reanimated's `runOnJS`, which since Reanimated 4
51
+ is a deprecated shim that forwards to exactly that call. `scheduleOnRN(fn, ...args)`
52
+ takes the arguments itself, so there is no second call: `runOnJS(onPress)()`
53
+ becomes `scheduleOnRN(onPress)`. The same rename covers `runOnUI` →
54
+ `scheduleOnUI`, which `use-keyboard-state-sync` already uses. A Biome
55
+ `noRestrictedImports` rule in `@delacour/biome-config` fails the build on the
56
+ deprecated names, so this cannot regress quietly.
57
+ - **A worklet's body must be self-contained.** The press feedback runs on the UI
58
+ thread and cannot rely on a module-scope helper: factoring its
59
+ `Math.min(1, Math.max(0, …))` clamp into a `clampUnit` helper — itself marked
60
+ `"worklet"` — crashes the UI thread with `undefined is not a function`, because
61
+ the helper is not captured into the worklet's closure. No unit test sees it,
62
+ since on the JS thread the helper resolves perfectly.
63
+ [`Screen`](../screen/AGENTS.md)'s border ramps write the same clamp out twice
64
+ for this reason.
@@ -0,0 +1,9 @@
1
+ export { type HapticFeedback, Pressable, type PressableProps, playHaptic } from "./pressable";
2
+ export {
3
+ PRESSABLE_FEEDBACK,
4
+ PRESSABLE_FEEDBACK_FALLBACK,
5
+ PRESSABLE_FEEDBACKS,
6
+ type PressableFeedback,
7
+ type PressedState,
8
+ resolvePressedState,
9
+ } from "./pressable.variants";