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

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 +193 -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 +434 -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 +242 -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 +246 -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 +150 -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 +105 -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 +293 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +220 -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 +112 -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 +47 -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 +95 -0
  108. package/src/components/field/field.tsx +157 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +240 -0
  111. package/src/components/field/field.variants.ts +156 -0
  112. package/src/components/field/index.ts +22 -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 +74 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +261 -0
  146. package/src/components/pressable/pressable.variants.test.ts +128 -0
  147. package/src/components/pressable/pressable.variants.ts +80 -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 +245 -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 +580 -0
  159. package/src/components/radio/radio.variants.ts +271 -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 +50 -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 +193 -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 +856 -0
  202. package/src/components/slider/slider.variants.ts +661 -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 +287 -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 +670 -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,29 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import { useFieldContext } from "./field.context";
4
+ import type { FieldTextProps } from "./field.types";
5
+ import { fieldVariants, resolveFieldTextColor } from "./field.variants";
6
+
7
+ /**
8
+ * Supporting copy for the control — what the value is for, or what shape it
9
+ * takes.
10
+ *
11
+ * Stays on the muted token whether the field is valid or not, so an appearing
12
+ * `Field.Error` is the one line that changed. It does not fade with a disabled
13
+ * field either: the control already dims itself, and a dimmed explanation on top
14
+ * of a dimmed control reads as two problems rather than one state.
15
+ */
16
+ export function FieldDescription({ className, color, ...props }: FieldTextProps): ReactElement {
17
+ const field = useFieldContext();
18
+ const isInvalid = field?.isInvalid ?? false;
19
+ const isDisabled = field?.isDisabled ?? false;
20
+
21
+ return (
22
+ <Text.Caption
23
+ className={fieldVariants({ isDisabled, isInvalid }).description({ className })}
24
+ color={color ?? resolveFieldTextColor("description", isInvalid)}
25
+ {...props}
26
+ />
27
+ );
28
+ }
29
+ FieldDescription.displayName = "DelacourUI.Field.Description";
@@ -0,0 +1,61 @@
1
+ import { Children, type ReactElement, type ReactNode } from "react";
2
+ import { Text } from "../text";
3
+ import { useFieldContext } from "./field.context";
4
+ import type { FieldTextProps } from "./field.types";
5
+ import { fieldVariants, resolveFieldTextColor } from "./field.variants";
6
+
7
+ /**
8
+ * `Text` renders `Animated.Text`, so its `children` may also be a Reanimated
9
+ * `SharedValue<ReactNode>`. An error message is a string, and this part has to
10
+ * count its children to know whether to render at all, so the narrower type is
11
+ * both the honest one and the one that can be counted.
12
+ */
13
+ export type FieldErrorProps = Omit<FieldTextProps, "children"> & { children?: ReactNode };
14
+
15
+ /**
16
+ * What is wrong with the value.
17
+ *
18
+ * Renders nothing when it has no children, so
19
+ * `<Field.Error>{errors.email}</Field.Error>` disappears on its own once the
20
+ * value is fixed and needs no conditional at the call site.
21
+ *
22
+ * Deliberately **not** gated on the field's `isInvalid`. A part that swallowed
23
+ * the children a caller actually wrote, because of a prop on a sibling, would be
24
+ * a part whose absence is unexplainable from the call site — and pairing the two
25
+ * is the caller's own `isInvalid={!!error}`, which is one expression, in view.
26
+ *
27
+ * Always destructive, in a valid field as much as an invalid one: an error message is
28
+ * never the calm case.
29
+ */
30
+ export function FieldError({ className, color, children, ...props }: FieldErrorProps): ReactElement | null {
31
+ const field = useFieldContext();
32
+ const isInvalid = field?.isInvalid ?? false;
33
+ const isDisabled = field?.isDisabled ?? false;
34
+
35
+ if (!hasContent(children)) return null;
36
+
37
+ return (
38
+ <Text.Caption
39
+ accessibilityLiveRegion="polite"
40
+ className={fieldVariants({ isDisabled, isInvalid }).error({ className })}
41
+ color={color ?? resolveFieldTextColor("error", isInvalid)}
42
+ role="alert"
43
+ {...props}
44
+ >
45
+ {children}
46
+ </Text.Caption>
47
+ );
48
+ }
49
+ FieldError.displayName = "DelacourUI.Field.Error";
50
+
51
+ /**
52
+ * Whether there is anything worth drawing a line of destructive text for.
53
+ *
54
+ * `Children.toArray` drops the `null`, `undefined` and booleans a conditional
55
+ * child leaves behind, so `{error && <Text>{error}</Text>}` counts as empty
56
+ * rather than as one child. An empty string is dropped too — it is what a
57
+ * cleared validation message usually is.
58
+ */
59
+ function hasContent(children: ReactNode): boolean {
60
+ return Children.toArray(children).some((child) => child !== "");
61
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactElement } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { fieldVariants } from "./field.variants";
4
+
5
+ export type FieldGroupProps = ViewProps & { className?: string };
6
+
7
+ /**
8
+ * A stack of fields, spaced so that two fields never read as one.
9
+ *
10
+ * Its gap is the loosest step in the ladder — looser than the gap inside a
11
+ * field — which is the whole mechanism by which a label attaches to the control
12
+ * under it rather than to the one above.
13
+ *
14
+ * **Inserts no dividers**, unlike `ListGroup`. A list of rows without lines is a
15
+ * wall of text, but fields are already separated by whitespace and a rule
16
+ * between every one of them is noise. Place a `Field.Separator` where a form
17
+ * genuinely changes subject.
18
+ */
19
+ export function FieldGroup({ className, ...props }: FieldGroupProps): ReactElement {
20
+ return <View className={fieldVariants().group({ className })} {...props} />;
21
+ }
22
+ FieldGroup.displayName = "DelacourUI.Field.Group";
@@ -0,0 +1,47 @@
1
+ import { type ReactElement, useEffect } from "react";
2
+ import { Text } from "../text";
3
+ import { useFieldContext } from "./field.context";
4
+ import type { FieldTextProps } from "./field.types";
5
+ import { fieldVariants, resolveFieldLabelText, resolveFieldTextColor } from "./field.variants";
6
+
7
+ /**
8
+ * The name of the control this field holds.
9
+ *
10
+ * Renders `Text.Label` and passes it a colour, never a size or a weight — the
11
+ * type scale belongs to the preset, and restating it here would be a second
12
+ * definition of `Text.Label` that could drift from it. It turns destructive with the
13
+ * control when the field is invalid, so the pair reads as one state.
14
+ *
15
+ * There is no `htmlFor`. React Native has no `<label>` element and no
16
+ * label-for-control association, which is also why there is no `Field.Title`:
17
+ * on the web that part exists only because a `<div>` is not a `<label>`, and
18
+ * here both would be the same `Text`. **The association is made by hand
19
+ * instead**: a string label hands its text to the field's context, and a control
20
+ * inside with no text of its own — a `Slider.Thumb` — reads it as its accessible
21
+ * name. `resolveFieldLabelText` decides what qualifies.
22
+ */
23
+ export function FieldLabel({ className, color, children, ...props }: FieldTextProps): ReactElement {
24
+ const field = useFieldContext();
25
+ const isInvalid = field?.isInvalid ?? false;
26
+ const isDisabled = field?.isDisabled ?? false;
27
+
28
+ const registerLabel = field?.registerLabel;
29
+ const labelText = resolveFieldLabelText(children);
30
+
31
+ useEffect(() => {
32
+ if (!registerLabel) return;
33
+ registerLabel(labelText);
34
+ return () => registerLabel(null);
35
+ }, [labelText, registerLabel]);
36
+
37
+ return (
38
+ <Text.Label
39
+ className={fieldVariants({ isDisabled, isInvalid }).label({ className })}
40
+ color={color ?? resolveFieldTextColor("label", isInvalid)}
41
+ {...props}
42
+ >
43
+ {children}
44
+ </Text.Label>
45
+ );
46
+ }
47
+ FieldLabel.displayName = "DelacourUI.Field.Label";
@@ -0,0 +1,34 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import type { FieldTextProps } from "./field.types";
4
+ import { type FieldLegendVariant, fieldVariants } from "./field.variants";
5
+
6
+ export type FieldLegendProps = FieldTextProps & {
7
+ /** `legend` titles a set; `label` matches the fields under it. */
8
+ variant?: FieldLegendVariant;
9
+ };
10
+
11
+ /**
12
+ * The title of a `Field.Set`.
13
+ *
14
+ * Two sizes, chosen by picking a `Text` preset rather than by naming a scale:
15
+ * `legend` renders `Text.Paragraph` with a medium weight — a heading for a
16
+ * section of a form — and `label` renders `Text.Label`, which is the treatment
17
+ * the fields below it already use. Reach for `label` when the set is nested
18
+ * inside another, where a second full-size heading would compete with the first.
19
+ *
20
+ * Reads no context: a legend titles a set, and a set holds no state of its own —
21
+ * invalid and disabled belong to each `Field` inside it. That is also why it
22
+ * takes no `isInvalid`; a whole section turning destructive says less than the one
23
+ * field that is wrong.
24
+ */
25
+ export function FieldLegend({ className, variant = "legend", ...props }: FieldLegendProps): ReactElement {
26
+ const legendClassName = fieldVariants({ variant }).legend({ className });
27
+
28
+ if (variant === "label") {
29
+ return <Text.Label className={legendClassName} {...props} />;
30
+ }
31
+
32
+ return <Text.Paragraph className={legendClassName} {...props} />;
33
+ }
34
+ FieldLegend.displayName = "DelacourUI.Field.Legend";
@@ -0,0 +1,81 @@
1
+ import { Children, type ReactElement, type ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { Separator } from "../separator";
4
+ import { Text } from "../text";
5
+ import { TextClassProvider } from "../text/text.context";
6
+ import { fieldVariants } from "./field.variants";
7
+
8
+ export type FieldSeparatorProps = Omit<ViewProps, "children"> & {
9
+ className?: string;
10
+ /** Optional label, drawn between two rules rather than on top of one. */
11
+ children?: ReactNode;
12
+ };
13
+
14
+ /**
15
+ * A rule between two sections of a form, optionally labelled.
16
+ *
17
+ * With children it draws **two rules with the label between them**, not one rule
18
+ * with the label sitting on top. The web version absolutely-positions a single
19
+ * rule and punches a hole in it with an opaque `bg-background` label, which is
20
+ * only invisible while the separator sits on exactly that colour — put it on a
21
+ * card, a sheet or a tinted section and the hole shows as a block of the wrong
22
+ * shade. Two rules and a gap assume nothing about what is behind them.
23
+ *
24
+ * Hidden from assistive technology when it is unlabelled, for the reason
25
+ * `Separator` itself is: a bare line carries nothing a screen reader can use. A
26
+ * labelled one keeps its text, which is the part worth announcing.
27
+ */
28
+ export function FieldSeparator({ className, children, ...props }: FieldSeparatorProps): ReactElement {
29
+ const slots = fieldVariants();
30
+ const lineClassName = slots.separatorLine();
31
+
32
+ if (!hasContent(children)) {
33
+ return <Separator className={className} {...props} />;
34
+ }
35
+
36
+ return (
37
+ <View className={slots.separator({ className })} {...props}>
38
+ <Separator className={lineClassName} />
39
+ <TextClassProvider value={undefined}>{wrapTextChildren(children)}</TextClassProvider>
40
+ <Separator className={lineClassName} />
41
+ </View>
42
+ );
43
+ }
44
+ FieldSeparator.displayName = "DelacourUI.Field.Separator";
45
+
46
+ /** Whether there is a label to make room for between the rules. */
47
+ function hasContent(children: ReactNode): boolean {
48
+ return Children.toArray(children).some((child) => child !== "");
49
+ }
50
+
51
+ /**
52
+ * Wraps a bare string label in a `Text.Caption`.
53
+ *
54
+ * React Native cannot render a string outside a `<Text>`, and
55
+ * `<Field.Separator>Or</Field.Separator>` is the shortest thing anyone will
56
+ * write. Consecutive strings collapse into one, so an interpolated label is a
57
+ * single run rather than several spaced apart by the row's own gap.
58
+ */
59
+ function wrapTextChildren(children: ReactNode): ReactNode {
60
+ const items = Children.toArray(children);
61
+ const output: ReactNode[] = [];
62
+ let run: (string | number)[] = [];
63
+
64
+ const flushRun = () => {
65
+ if (run.length === 0) return;
66
+ output.push(<Text.Caption key={`label-${output.length}`}>{run.join("")}</Text.Caption>);
67
+ run = [];
68
+ };
69
+
70
+ for (const child of items) {
71
+ if (typeof child === "string" || typeof child === "number") {
72
+ run.push(child);
73
+ continue;
74
+ }
75
+ flushRun();
76
+ output.push(child);
77
+ }
78
+ flushRun();
79
+
80
+ return output;
81
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactElement } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { fieldVariants } from "./field.variants";
4
+
5
+ export type FieldSetProps = ViewProps & { className?: string };
6
+
7
+ /**
8
+ * A named section of a form — a legend, an optional description, and the fields
9
+ * under them.
10
+ *
11
+ * The step above `Field.Group`: a group is spacing and a set is meaning. Reach
12
+ * for a set when the fields share a heading a reader needs, and for a bare group
13
+ * when they are simply a list.
14
+ *
15
+ * Holds no state. Invalid and disabled belong to each `Field`, because a section
16
+ * that turned destructive as a whole would say less than the one field that is
17
+ * actually wrong.
18
+ */
19
+ export function FieldSet({ className, ...props }: FieldSetProps): ReactElement {
20
+ return <View className={fieldVariants().set({ className })} {...props} />;
21
+ }
22
+ FieldSet.displayName = "DelacourUI.Field.Set";
@@ -0,0 +1,95 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { FieldOrientation } from "./field.variants";
3
+
4
+ export type FieldContextValue = {
5
+ /** Whether the field reports an invalid value. */
6
+ isInvalid: boolean;
7
+ /** Whether the field's control is unavailable. */
8
+ isDisabled: boolean;
9
+ /** Which way the field's parts are laid out. */
10
+ orientation: FieldOrientation;
11
+ /**
12
+ * A control inside offers its press here, so the whole row can drive it.
13
+ *
14
+ * The counterpart of `isInvalid` travelling the other way: state cascades
15
+ * down, and the one thing a control can hand back up is "tapping my label
16
+ * should do this". Pass `null` on unmount. A field holds one control, so a
17
+ * second registration replaces the first.
18
+ */
19
+ registerPress: (press: (() => void) | null) => void;
20
+ /**
21
+ * The text of the `Field.Label` inside, or `null` while there is none.
22
+ *
23
+ * What a control reads for its accessible name when it holds no text of its
24
+ * own — a `Slider.Thumb` is a capsule, and a `Switch` is a pill. React Native
25
+ * has no `<label for>`; this is that association, carried by the same context
26
+ * the state cascades down.
27
+ */
28
+ label: string | null;
29
+ /**
30
+ * A `Field.Label` offers its text here. Pass `null` on unmount.
31
+ *
32
+ * The counterpart of {@link FieldContextValue.registerPress} going the same
33
+ * way: a control hands its press up, a label hands its name down. A field
34
+ * holds one label, so a second registration replaces the first.
35
+ */
36
+ registerLabel: (label: string | null) => void;
37
+ };
38
+
39
+ const FieldContext = createContext<FieldContextValue | null>(null);
40
+
41
+ /**
42
+ * Supplies the enclosing field's state to its subtree.
43
+ *
44
+ * This is the whole reason `Field` has a context rather than a set of classes.
45
+ * On the web, shadcn cascades a field's invalid state to its control with
46
+ * `group-data-[invalid=true]/field:` — a parent-scoped selector. Uniwind has no
47
+ * equivalent: its compiler reads `data-*` off a single flat selector and its
48
+ * runtime matches them against the props of **the same component**, so a class
49
+ * on a `Field` cannot reach the `Input` inside it. A context can.
50
+ *
51
+ * So `<Field isInvalid>` turns the control inside it destructive with nothing said at
52
+ * the control, and the same channel will serve `Checkbox` and `Radio` when they
53
+ * land. An explicit prop on the control still wins — see `Input`'s ladder.
54
+ *
55
+ * Lives in its own module, importing nothing but React and a type, so a
56
+ * component in another folder can read it without importing `../field` and
57
+ * closing a cycle. See AGENTS.md rule 3.
58
+ *
59
+ * **There is no `useFieldPart` here**, unlike every other compound in this
60
+ * package, because no part of this one needs a `Field` to work. A description
61
+ * under a `Field.Set` describes the whole set, and a label under one is a
62
+ * section heading — both are compositions the web kit documents, and a hook that
63
+ * threw would turn them into a red box. Every part reads
64
+ * {@link useFieldContext} and falls back to "not invalid, not disabled", so
65
+ * state applies where there is a field and nothing happens where there is not.
66
+ */
67
+ export function FieldProvider({ value, children }: { value: FieldContextValue; children: ReactNode }): ReactElement {
68
+ return <FieldContext value={value}>{children}</FieldContext>;
69
+ }
70
+ FieldProvider.displayName = "DelacourUI.Field.Provider";
71
+
72
+ /**
73
+ * The enclosing field's state, or null outside a `<Field>`.
74
+ *
75
+ * This is the export a control reads. It is nullable because a control has to
76
+ * work perfectly well on its own — a `Field` is a layout a caller opts into, not
77
+ * a wrapper anything requires.
78
+ */
79
+ export function useFieldContext(): FieldContextValue | null {
80
+ return use(FieldContext);
81
+ }
82
+
83
+ /**
84
+ * Reads the enclosing field's state.
85
+ *
86
+ * For a custom part that has to match the field it sits in. Throws outside one —
87
+ * use {@link useFieldContext} where the field is optional, as every control does.
88
+ */
89
+ export function useField(): FieldContextValue {
90
+ const context = useFieldContext();
91
+ if (!context) {
92
+ throw new Error("useField must be called inside a <Field>.");
93
+ }
94
+ return context;
95
+ }
@@ -0,0 +1,157 @@
1
+ import { type ReactElement, useCallback, useMemo, useState } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { Pressable } from "../pressable";
4
+ import { type FieldContextValue, FieldProvider } from "./field.context";
5
+ import { type FieldOrientation, fieldVariants, resolveFieldInteractive } from "./field.variants";
6
+ import { FieldContent } from "./field-content";
7
+ import { FieldDescription } from "./field-description";
8
+ import { FieldError } from "./field-error";
9
+ import { FieldGroup } from "./field-group";
10
+ import { FieldLabel } from "./field-label";
11
+ import { FieldLegend } from "./field-legend";
12
+ import { FieldSeparator } from "./field-separator";
13
+ import { FieldSet } from "./field-set";
14
+
15
+ export type FieldProps = ViewProps & {
16
+ /** `vertical` stacks label over control; `horizontal` puts them side by side. */
17
+ orientation?: FieldOrientation;
18
+ /** Reports an invalid value. The label and the control inside both turn destructive. */
19
+ isInvalid?: boolean;
20
+ /** The control inside is unavailable. Fades the label and disables the control. */
21
+ isDisabled?: boolean;
22
+ className?: string;
23
+ };
24
+
25
+ function FieldRoot({
26
+ orientation = "vertical",
27
+ isInvalid = false,
28
+ isDisabled = false,
29
+ className,
30
+ ...props
31
+ }: FieldProps): ReactElement {
32
+ // State rather than a ref, because the answer changes what the row renders
33
+ // as. A control registers in an effect, so this costs one extra render on
34
+ // mount and none afterwards — the registered callback is stable.
35
+ const [controlPress, setControlPress] = useState<(() => void) | null>(null);
36
+
37
+ // The updater form would call `press` instead of storing it.
38
+ const registerPress = useCallback((press: (() => void) | null) => setControlPress(() => press), []);
39
+
40
+ // The label's text, for a control inside to read as its accessible name.
41
+ // State for the same reason as the press: a control renders differently for
42
+ // it, and the label registers in an effect, so it costs one render on mount.
43
+ const [label, setLabel] = useState<string | null>(null);
44
+ const registerLabel = useCallback((next: string | null) => setLabel(next), []);
45
+
46
+ const context = useMemo<FieldContextValue>(
47
+ () => ({ isDisabled, isInvalid, label, orientation, registerLabel, registerPress }),
48
+ [isDisabled, isInvalid, label, orientation, registerLabel, registerPress]
49
+ );
50
+
51
+ const rootClassName = fieldVariants({ isDisabled, isInvalid, orientation }).root({ className });
52
+
53
+ // A field is layout until a control asks for the row — see `resolveFieldInteractive`.
54
+ if (!resolveFieldInteractive(controlPress)) {
55
+ return (
56
+ <FieldProvider value={context}>
57
+ <View accessibilityRole="none" className={rootClassName} {...props} />
58
+ </FieldProvider>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <FieldProvider value={context}>
64
+ <Pressable
65
+ // The control inside is the accessibility element, not this row.
66
+ // Without it a screen reader would announce the label twice and
67
+ // offer a wrapper with no state of its own.
68
+ accessible={false}
69
+ className={rootClassName}
70
+ disabled={isDisabled}
71
+ feedback="none"
72
+ onPress={controlPress}
73
+ {...props}
74
+ />
75
+ </FieldProvider>
76
+ );
77
+ }
78
+
79
+ /**
80
+ * One control, with its label, its description and its error.
81
+ *
82
+ * The layout every form in this library repeats, done once — and the one place a
83
+ * field's state is written down. `<Field isInvalid>` turns the label destructive
84
+ * **and the control inside it too**, with nothing said at the control: `Input`
85
+ * and `Checkbox` both read this field's context, and `Radio` will read the same one.
86
+ *
87
+ * That cascade is a React context rather than a class because it has to be.
88
+ * Uniwind's data selectors match against the props of the component carrying the
89
+ * class, so there is no parent-scoped `group-data-*` for a `Field` to style its
90
+ * control with. See `field.context.tsx`.
91
+ *
92
+ * A control's own prop still wins, so one field inside an invalid group can opt
93
+ * out with `<Input isInvalid={false} />`.
94
+ *
95
+ * **The label names the control, too.** `Field.Label` hands its text down the
96
+ * same context, so a control with no text of its own — a `Slider.Thumb`, a
97
+ * `Switch` — reads "Volume" to a screen reader rather than a bare number. React
98
+ * Native has no `<label for>`; this is that association. Only a plain string or
99
+ * number label is lent, since a label built from elements has no text to read
100
+ * before it renders.
101
+ *
102
+ * **The whole row drives the control**, once one offers a press through the same
103
+ * context — so tapping "Accept the terms", or the description under it, ticks
104
+ * the `Checkbox` beside it. A checkbox in a form is a small square next to a
105
+ * sentence, and the sentence is the part people aim at. The row is a `Pressable`
106
+ * with `feedback="none"` only while a control has registered; a field of static
107
+ * text mounts no gesture detector at all.
108
+ *
109
+ * The text parts render the `Text` presets and pass a colour, never a size —
110
+ * `Field.Label` *is* `Text.Label`. There is no `Field.Title`: on the web it
111
+ * exists because a `<div>` is not a `<label>`, and React Native has neither.
112
+ *
113
+ * @example
114
+ * <Field>
115
+ * <Field.Label>Full name</Field.Label>
116
+ * <Input placeholder="Ada Lovelace" />
117
+ * <Field.Description>This appears on invoices.</Field.Description>
118
+ * </Field>
119
+ *
120
+ * @example
121
+ * <Field isInvalid={!isValid}>
122
+ * <Field.Label>Email</Field.Label>
123
+ * <Input inputMode="email" onChangeText={setEmail} value={email} />
124
+ * <Field.Error>{error}</Field.Error>
125
+ * </Field>
126
+ *
127
+ * @example
128
+ * <Field.Set>
129
+ * <Field.Legend>Profile</Field.Legend>
130
+ * <Field.Description>Shown on invoices and emails.</Field.Description>
131
+ * <Field.Group>
132
+ * <Field>
133
+ * <Field.Label>Username</Field.Label>
134
+ * <Input autoCapitalize="none" />
135
+ * </Field>
136
+ * </Field.Group>
137
+ * </Field.Set>
138
+ */
139
+ export const Field = Object.assign(FieldRoot, {
140
+ /** A named section of a form — a legend, a description, and the fields under them. */
141
+ Set: FieldSet,
142
+ /** The title of a `Field.Set`. `variant="label"` for a nested one. */
143
+ Legend: FieldLegend,
144
+ /** A stack of fields, spaced so two never read as one. Inserts no dividers. */
145
+ Group: FieldGroup,
146
+ /** The control's name. Turns destructive with the control when the field is invalid. */
147
+ Label: FieldLabel,
148
+ /** Supporting copy. Stays muted in every state, so an error is the line that changed. */
149
+ Description: FieldDescription,
150
+ /** What is wrong with the value. Renders nothing when it has no children. */
151
+ Error: FieldError,
152
+ /** A label and its description as one block, for a horizontal field. */
153
+ Content: FieldContent,
154
+ /** A rule between sections, optionally labelled between two rules. */
155
+ Separator: FieldSeparator,
156
+ displayName: "DelacourUI.Field",
157
+ });
@@ -0,0 +1,15 @@
1
+ import type { TextProps } from "../text";
2
+
3
+ /**
4
+ * The shape of a field's text parts.
5
+ *
6
+ * Shared by `Field.Label`, `Field.Description`, `Field.Error` and
7
+ * `Field.Legend`, which differ only in the preset they render and the colour
8
+ * they resolve, so it lives in a leaf rather than in one of the four files
9
+ * arbitrarily.
10
+ *
11
+ * `variant` is omitted because each part already is one — naming a second would
12
+ * let a caller turn a description into a heading and lose the type scale the
13
+ * component exists to keep.
14
+ */
15
+ export type FieldTextProps = Omit<TextProps, "variant">;