@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,87 @@
1
+ import { createContext, type ReactElement, type ReactNode, type RefObject, use } from "react";
2
+ import type { TextInput } from "react-native";
3
+ import type { InputSize, InputVariant } from "./input.variants";
4
+
5
+ export type InputGroupContextValue = {
6
+ /** Visual variant of the box every part of the group sits in. */
7
+ variant: InputVariant;
8
+ /** Size of the box, which also drives the field's type scale and a decorator's icon. */
9
+ size: InputSize;
10
+ /** Whether the group reports an invalid value. */
11
+ isInvalid: boolean;
12
+ /** Whether the group is disabled. */
13
+ isDisabled: boolean;
14
+ /** Whether the field inside the group holds focus. */
15
+ isFocused: boolean;
16
+ /** Reported by the field on focus and blur, so the box can light up around it. */
17
+ setFocused: (isFocused: boolean) => void;
18
+ /**
19
+ * The field the group wraps, so tapping the box's gutter can focus it.
20
+ *
21
+ * A lone field is the whole tap target; inside a group the field only covers
22
+ * the middle of the box, and a tap on the padding either side would otherwise
23
+ * do nothing.
24
+ */
25
+ fieldRef: RefObject<TextInput | null>;
26
+ };
27
+
28
+ const InputGroupContext = createContext<InputGroupContextValue | null>(null);
29
+
30
+ /**
31
+ * Supplies the enclosing group's box, state and field ref to its subtree.
32
+ *
33
+ * The group owns the box, so it owns the axes that draw one: an `Input` inside
34
+ * a group reads `variant`, `size`, `isInvalid` and `isDisabled` from here rather
35
+ * than from its own props, the same way a `ListGroup.Item` takes no `variant`.
36
+ * One box, one set of axes — two would be two things that can disagree.
37
+ *
38
+ * Lives in its own module, importing nothing but React and a type-only
39
+ * `TextInput`, so `input.tsx` can read it without importing `./input-group` and
40
+ * closing a cycle back through the root. See AGENTS.md rule 3.
41
+ */
42
+ export function InputGroupProvider({
43
+ value,
44
+ children,
45
+ }: {
46
+ value: InputGroupContextValue;
47
+ children: ReactNode;
48
+ }): ReactElement {
49
+ return <InputGroupContext value={value}>{children}</InputGroupContext>;
50
+ }
51
+ InputGroupProvider.displayName = "DelacourUI.Input.Group.Provider";
52
+
53
+ /** The enclosing group's context, or null for a field standing on its own. */
54
+ export function useInputGroupContext(): InputGroupContextValue | null {
55
+ return use(InputGroupContext);
56
+ }
57
+
58
+ /**
59
+ * Reads the enclosing `Input.Group`'s box and state.
60
+ *
61
+ * For a custom part that has to match the group it sits in. Throws outside one —
62
+ * use {@link useInputGroupContext} where the group is optional, as `Input`
63
+ * itself does.
64
+ */
65
+ export function useInputGroup(): InputGroupContextValue {
66
+ const context = useInputGroupContext();
67
+ if (!context) {
68
+ throw new Error("useInputGroup must be called inside an <Input.Group>.");
69
+ }
70
+ return context;
71
+ }
72
+
73
+ /**
74
+ * The enclosing group's context, for a compound part that cannot work without
75
+ * one.
76
+ *
77
+ * Internal: deliberately not re-exported from `index.ts`. A caller outside the
78
+ * library wants {@link useInputGroup}, whose error message names the hook rather
79
+ * than a part.
80
+ */
81
+ export function useInputGroupPart(component: string): InputGroupContextValue {
82
+ const context = useInputGroupContext();
83
+ if (!context) {
84
+ throw new Error(`${component} must be rendered inside an <Input.Group>.`);
85
+ }
86
+ return context;
87
+ }
@@ -0,0 +1,184 @@
1
+ import { type ReactElement, type Ref, useCallback, useState } from "react";
2
+ import { TextInput, type TextInputProps } from "react-native";
3
+ import { composeRefs } from "../../lib/compose-refs";
4
+ import { useButtonGroupItemContext } from "../button/button.context";
5
+ import { resolveButtonSizeStep } from "../button/button.variants";
6
+ import { useFieldContext } from "../field/field.context";
7
+ import { useInputGroupContext } from "./input.context";
8
+ import {
9
+ type InputSize,
10
+ type InputVariant,
11
+ resolveInputFieldClass,
12
+ resolvePlaceholderAccentClass,
13
+ resolveSelectionAccentClass,
14
+ } from "./input.variants";
15
+ import { InputGroup } from "./input-group";
16
+
17
+ /**
18
+ * The events React Native hands a field's focus and blur callbacks.
19
+ *
20
+ * Read off `TextInputProps` rather than named outright: React Native renamed
21
+ * these between versions, and a restated type would go stale silently — the
22
+ * handler would still compile against the old shape and fail to match the prop.
23
+ */
24
+ type InputFocusEvent = Parameters<NonNullable<TextInputProps["onFocus"]>>[0];
25
+ type InputBlurEvent = Parameters<NonNullable<TextInputProps["onBlur"]>>[0];
26
+
27
+ export type InputProps = Omit<TextInputProps, "placeholderTextColorClassName"> & {
28
+ /** Visual variant of the box. Ignored inside an `Input.Group`, which owns the box. */
29
+ variant?: InputVariant;
30
+ /** Size of the box and the value's type scale. Ignored inside an `Input.Group`. */
31
+ size?: InputSize;
32
+ /**
33
+ * Reports an invalid value. Ignored inside an `Input.Group`, which owns it;
34
+ * inherited from an enclosing `Field` when neither names it.
35
+ */
36
+ isInvalid?: boolean;
37
+ /**
38
+ * Blocks editing and fades the field. Ignored inside an `Input.Group`, which
39
+ * owns it; inherited from an enclosing `Field` when neither names it.
40
+ */
41
+ isDisabled?: boolean;
42
+ /**
43
+ * Placeholder colour, as an `accent-*` utility — `accent-muted-foreground`.
44
+ * Any other utility resolves to nothing; see `input.variants.ts`.
45
+ */
46
+ placeholderColorClassName?: string;
47
+ /**
48
+ * Caret and selection colour, as an `accent-*` utility. Defaults to the
49
+ * primary token, or the destructive one while the field is invalid.
50
+ */
51
+ selectionColorClassName?: string;
52
+ ref?: Ref<TextInput>;
53
+ };
54
+
55
+ function InputRoot({
56
+ variant,
57
+ size,
58
+ isInvalid,
59
+ isDisabled,
60
+ className,
61
+ placeholderColorClassName,
62
+ selectionColorClassName,
63
+ editable,
64
+ multiline,
65
+ onBlur,
66
+ onFocus,
67
+ ref,
68
+ ...props
69
+ }: InputProps): ReactElement {
70
+ const group = useInputGroupContext();
71
+ const field = useFieldContext();
72
+ // A field sitting directly in a `Button.Group` *is* the box, so it draws the
73
+ // run's corner itself. One nested in an `Input.Group` is not: that row owns
74
+ // the box and reads the same context, so the member axes below are simply
75
+ // never consulted — `resolveInputFieldClass` drops the `root` slot entirely
76
+ // when the field is grouped.
77
+ const member = useButtonGroupItemContext();
78
+ const [ownFocused, setOwnFocused] = useState(false);
79
+
80
+ // Nearest wins, and there are three sources for the two state axes.
81
+ //
82
+ // Inside a group the box belongs to the group, so the axes that draw one do
83
+ // too — the same way a `ListGroup.Item` takes no `variant`. An enclosing
84
+ // `Field` is the outermost of the three and therefore the last fallback, so
85
+ // a field with no props of its own turns destructive with the `Field` around it
86
+ // while `<Input isInvalid={false} />` still opts out of one.
87
+ const resolved = {
88
+ isDisabled: group?.isDisabled ?? isDisabled ?? field?.isDisabled ?? member?.isDisabled ?? false,
89
+ isInvalid: group?.isInvalid ?? isInvalid ?? field?.isInvalid ?? false,
90
+ size: group?.size ?? (member ? resolveButtonSizeStep(member.size) : size) ?? "md",
91
+ variant: group?.variant ?? variant ?? "primary",
92
+ };
93
+
94
+ // The group's box has to light up around a field it does not contain the
95
+ // focus of, so focus is reported upward when there is a group and held here
96
+ // when there is not. Picking the setter is a plain branch, not a hook, so
97
+ // hook order is the same either way.
98
+ const isFocused = group ? group.isFocused : ownFocused;
99
+ const setFocused = group ? group.setFocused : setOwnFocused;
100
+
101
+ const handleFocus = useCallback(
102
+ (event: InputFocusEvent) => {
103
+ setFocused(true);
104
+ onFocus?.(event);
105
+ },
106
+ [onFocus, setFocused]
107
+ );
108
+
109
+ const handleBlur = useCallback(
110
+ (event: InputBlurEvent) => {
111
+ setFocused(false);
112
+ onBlur?.(event);
113
+ },
114
+ [onBlur, setFocused]
115
+ );
116
+
117
+ const selectionAccent = resolveSelectionAccentClass({
118
+ className: selectionColorClassName,
119
+ isInvalid: resolved.isInvalid,
120
+ });
121
+
122
+ return (
123
+ <TextInput
124
+ className={resolveInputFieldClass({
125
+ ...resolved,
126
+ className,
127
+ groupPosition: member?.position ?? "none",
128
+ isFocused,
129
+ isGrouped: group !== null,
130
+ isMultiline: multiline === true,
131
+ isSeamed: member?.isSeamed ?? false,
132
+ orientation: member?.orientation ?? "horizontal",
133
+ })}
134
+ cursorColorClassName={selectionAccent}
135
+ multiline={multiline}
136
+ placeholderTextColorClassName={resolvePlaceholderAccentClass(placeholderColorClassName)}
137
+ selectionColorClassName={selectionAccent}
138
+ textAlignVertical={multiline ? "top" : undefined}
139
+ {...props}
140
+ editable={resolved.isDisabled ? false : editable}
141
+ onBlur={handleBlur}
142
+ onFocus={handleFocus}
143
+ ref={composeRefs(ref, group?.fieldRef)}
144
+ />
145
+ );
146
+ }
147
+
148
+ /**
149
+ * A text field.
150
+ *
151
+ * The `TextInput` this renders is uniwind's, not React Native's — uniwind's
152
+ * Metro resolver rewrites the `react-native` import — which is what lets a
153
+ * className drive the three props that take a colour *value* rather than a
154
+ * style: the placeholder, the caret and the selection highlight. No
155
+ * `withUniwind` wrapper is involved, so AGENTS.md rule 7 is untouched. Those
156
+ * classNames must be `accent-*` utilities; see `input.variants.ts` for why.
157
+ *
158
+ * Focus styling is driven from React state rather than a `focus:` class.
159
+ * Uniwind tracks focus per component, so a `focus:` utility would light up a
160
+ * lone field and do nothing for the box `Input.Group` draws around a grouped
161
+ * one — and the decision would sit somewhere `bun test` cannot reach.
162
+ *
163
+ * Everything `TextInput` accepts is accepted here, with one name removed:
164
+ * uniwind's `placeholderTextColorClassName` is replaced by
165
+ * `placeholderColorClassName`, so there is exactly one name for the placeholder
166
+ * colour rather than two that can disagree.
167
+ *
168
+ * Wrap it in an `Input.Group` to put an icon, an affix or a control inside the
169
+ * field's own box.
170
+ *
171
+ * @example
172
+ * <Input onChangeText={setEmail} placeholder="Email" value={email} />
173
+ *
174
+ * @example
175
+ * <Input isInvalid={!isValid} size="lg" variant="secondary" />
176
+ *
177
+ * @example
178
+ * <Input multiline numberOfLines={4} placeholder="Notes" />
179
+ */
180
+ export const Input = Object.assign(InputRoot, {
181
+ /** A row that puts prefix and suffix content inside the field's own box. */
182
+ Group: InputGroup,
183
+ displayName: "DelacourUI.Input",
184
+ });
@@ -0,0 +1,10 @@
1
+ import type { ViewProps } from "react-native";
2
+
3
+ /**
4
+ * The shape of a group's leading and trailing slots.
5
+ *
6
+ * Shared by `Input.Group.Prefix` and `Input.Group.Suffix`, which are identical
7
+ * apart from where the row places them, so it lives in a leaf rather than in
8
+ * one of the two files arbitrarily.
9
+ */
10
+ export type InputSlotProps = ViewProps & { className?: string };
@@ -0,0 +1,369 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ICON_SIZE_TOKENS, INPUT_SIZE_TOKENS, INPUT_TEXT_TOKENS } from "../../styles/tokens";
3
+ import {
4
+ BUTTON_GROUP_ORIENTATIONS,
5
+ BUTTON_GROUP_POSITIONS,
6
+ BUTTON_SIZES,
7
+ buttonVariants,
8
+ resolveButtonSizeStep,
9
+ } from "../button/button.variants";
10
+ import {
11
+ INPUT_INVALID_SELECTION_ACCENT_CLASS,
12
+ INPUT_PLACEHOLDER_ACCENT_CLASS,
13
+ INPUT_SELECTION_ACCENT_CLASS,
14
+ INPUT_SIZES,
15
+ INPUT_VARIANTS,
16
+ type InputBoxState,
17
+ inputVariants,
18
+ resolveInputFieldClass,
19
+ resolveInputGroupClass,
20
+ resolvePlaceholderAccentClass,
21
+ resolveSelectionAccentClass,
22
+ } from "./input.variants";
23
+
24
+ /** The `--spacing-input-*` token a class string sizes its box from, fixed or floored. */
25
+ function heightToken(cls: string): string | undefined {
26
+ return cls.match(/\bmin-h-(input-[\w-]+)\b/)?.[1] ?? cls.match(/\bh-(input-[\w-]+)\b/)?.[1];
27
+ }
28
+
29
+ /** The `--text-input-*` token a class string sets its type scale from. */
30
+ function textToken(cls: string): string | undefined {
31
+ return cls.match(/\btext-(input-[\w-]+)\b/)?.[1];
32
+ }
33
+
34
+ /**
35
+ * Position of a class string's `size-icon-*` token on the shared icon scale.
36
+ *
37
+ * Compares by step rather than by points, so the test says what it means and
38
+ * survives a token being retuned in `tokens.css`.
39
+ */
40
+ function iconStep(cls: string): number {
41
+ const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
42
+ return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
43
+ }
44
+
45
+ /** The four on/off axes a box is drawn from, beside its variant and size. */
46
+ const BOOLEAN_BOX_AXES = ["isDisabled", "isFocused", "isInvalid", "isMultiline"] as const;
47
+
48
+ /**
49
+ * Every combination of the axes that change the box.
50
+ *
51
+ * The boolean axes are enumerated as a bit pattern rather than as four nested
52
+ * loops: the sweep is the point of the parity tests, so it has to cover the
53
+ * whole matrix, and four more levels of indentation would bury what it covers.
54
+ */
55
+ function everyBoxState(): InputBoxState[] {
56
+ const states: InputBoxState[] = [];
57
+
58
+ for (const variant of INPUT_VARIANTS) {
59
+ for (const size of INPUT_SIZES) {
60
+ for (let mask = 0; mask < 1 << BOOLEAN_BOX_AXES.length; mask += 1) {
61
+ const state: InputBoxState = { size, variant };
62
+ for (const [bit, axis] of BOOLEAN_BOX_AXES.entries()) {
63
+ state[axis] = (mask & (1 << bit)) !== 0;
64
+ }
65
+ states.push(state);
66
+ }
67
+ }
68
+ }
69
+
70
+ return states;
71
+ }
72
+
73
+ /** Every `rounded-*` class a root string carries, in emission order. */
74
+ function radiusClasses(cls: string): string[] {
75
+ return cls.match(/\brounded-[\w-]+\b/g) ?? [];
76
+ }
77
+
78
+ describe("the root slot", () => {
79
+ test("gives every variant a distinct treatment", () => {
80
+ const seen = new Set(INPUT_VARIANTS.map((variant) => inputVariants({ variant }).root()));
81
+ expect(seen.size).toBe(INPUT_VARIANTS.length);
82
+ });
83
+
84
+ test("draws a border in every variant, so focus and invalid have something to recolour", () => {
85
+ for (const variant of INPUT_VARIANTS) {
86
+ expect(inputVariants({ variant }).root()).toMatch(/\bborder-/);
87
+ }
88
+ });
89
+
90
+ // A React Native `View` does not cascade colour to a `Text` descendant, and
91
+ // when the field is grouped this slot lands on a View. Text colour belongs
92
+ // to the field. See AGENTS.md rule 1.
93
+ test("carries no text colour", () => {
94
+ for (const state of everyBoxState()) {
95
+ expect(inputVariants(state).root()).not.toMatch(/\btext-/);
96
+ }
97
+ });
98
+
99
+ test("takes a height token from the input scale at every size", () => {
100
+ for (const size of INPUT_SIZES) {
101
+ const token = heightToken(inputVariants({ size }).root());
102
+ expect(INPUT_SIZE_TOKENS).toContain(token as (typeof INPUT_SIZE_TOKENS)[number]);
103
+ }
104
+ });
105
+
106
+ test("steps its height with its size", () => {
107
+ const steps = INPUT_SIZES.map((size) =>
108
+ INPUT_SIZE_TOKENS.indexOf(heightToken(inputVariants({ size }).root()) as never)
109
+ );
110
+ expect(steps).toEqual([0, 1, 2]);
111
+ });
112
+
113
+ test("marks focus and invalid with different borders", () => {
114
+ const focused = inputVariants({ isFocused: true }).root();
115
+ const invalid = inputVariants({ isInvalid: true }).root();
116
+ const resting = inputVariants().root();
117
+ expect(focused).not.toBe(resting);
118
+ expect(invalid).not.toBe(resting);
119
+ expect(focused).not.toBe(invalid);
120
+ });
121
+
122
+ // Focus is the transient state and invalid the reported one: a field that
123
+ // went grey the moment it was tapped would drop the only signal it has that
124
+ // its value is wrong, exactly while the value is being corrected.
125
+ test("keeps an invalid field invalid while it is focused", () => {
126
+ for (const variant of INPUT_VARIANTS) {
127
+ const both = inputVariants({ isFocused: true, isInvalid: true, variant }).root();
128
+ const invalid = inputVariants({ isInvalid: true, variant }).root();
129
+ expect(both).toContain("border-destructive");
130
+ expect(both).not.toContain("border-ring");
131
+ expect(new Set(both.split(" "))).toEqual(new Set(invalid.split(" ")));
132
+ }
133
+ });
134
+
135
+ test("fades a disabled field", () => {
136
+ expect(inputVariants({ isDisabled: true }).root()).toContain("opacity-50");
137
+ expect(inputVariants({ isDisabled: false }).root()).not.toContain("opacity-50");
138
+ });
139
+
140
+ test("merges an incoming className last", () => {
141
+ expect(inputVariants().root({ className: "mb-6" })).toContain("mb-6");
142
+ expect(inputVariants().root({ className: "bg-popover" })).not.toContain("bg-card");
143
+ });
144
+ });
145
+
146
+ describe("a multiline field", () => {
147
+ // A fixed height would clip the second line; a floor lets the box grow with
148
+ // the text while still matching a single-line field at rest.
149
+ test("turns the height into a floor rather than a fixed measure", () => {
150
+ for (const size of INPUT_SIZES) {
151
+ const single = inputVariants({ isMultiline: false, size }).root();
152
+ const multi = inputVariants({ isMultiline: true, size }).root();
153
+ expect(single).toMatch(/(?<![\w-])h-input-/);
154
+ expect(single).not.toMatch(/\bmin-h-input-/);
155
+ expect(multi).toMatch(/\bmin-h-input-/);
156
+ expect(multi).not.toMatch(/(?<![\w-])h-input-/);
157
+ }
158
+ });
159
+
160
+ test("floors at exactly the height a single-line field takes", () => {
161
+ for (const size of INPUT_SIZES) {
162
+ expect(heightToken(inputVariants({ isMultiline: true, size }).root())).toBe(
163
+ heightToken(inputVariants({ isMultiline: false, size }).root())
164
+ );
165
+ }
166
+ });
167
+
168
+ // With the box free to grow, centring would drift the decorators down the
169
+ // side of a paragraph. They belong on the first line.
170
+ test("aligns the row to the top", () => {
171
+ for (const size of INPUT_SIZES) {
172
+ const row = resolveInputGroupClass({ isMultiline: true, size });
173
+ expect(row).toContain("items-start");
174
+ expect(row).not.toContain("items-center");
175
+ }
176
+ });
177
+ });
178
+
179
+ describe("the field slot", () => {
180
+ test("carries the text colour, which the root must not", () => {
181
+ expect(inputVariants().field()).toMatch(/\btext-foreground\b/);
182
+ });
183
+
184
+ test("takes a type scale token paired with its height", () => {
185
+ for (const [index, size] of INPUT_SIZES.entries()) {
186
+ expect(textToken(inputVariants({ size }).field())).toBe(INPUT_TEXT_TOKENS[index]);
187
+ expect(heightToken(inputVariants({ size }).root())).toBe(INPUT_SIZE_TOKENS[index]);
188
+ }
189
+ });
190
+
191
+ test("stretches only when it is sharing a row", () => {
192
+ expect(inputVariants({ isGrouped: true }).field()).toContain("flex-1");
193
+ expect(inputVariants({ isGrouped: false }).field()).not.toContain("flex-1");
194
+ });
195
+
196
+ test("merges an incoming className last", () => {
197
+ expect(inputVariants().field({ className: "text-destructive" })).toContain("text-destructive");
198
+ expect(inputVariants().field({ className: "text-destructive" })).not.toContain("text-foreground");
199
+ });
200
+ });
201
+
202
+ describe("a decorator", () => {
203
+ test("sizes its icon on the shared icon scale", () => {
204
+ for (const size of INPUT_SIZES) {
205
+ expect(iconStep(inputVariants({ size }).decoratorIcon())).toBeGreaterThanOrEqual(0);
206
+ }
207
+ });
208
+
209
+ test("steps that icon up with the field's size", () => {
210
+ const steps = INPUT_SIZES.map((size) => iconStep(inputVariants({ size }).decoratorIcon()));
211
+ expect(steps).toEqual([...steps].sort((a, b) => a - b));
212
+ expect(new Set(steps).size).toBe(INPUT_SIZES.length);
213
+ });
214
+
215
+ // An icon takes a colour value rather than a class — the slot sizes it only.
216
+ test("puts no colour on the icon slot", () => {
217
+ for (const size of INPUT_SIZES) {
218
+ expect(inputVariants({ size }).decoratorIcon()).not.toMatch(/\b(bg|text|border)-/);
219
+ }
220
+ });
221
+
222
+ test("sets affix text a step quieter than the value beside it", () => {
223
+ for (const size of INPUT_SIZES) {
224
+ const text = inputVariants({ size }).decoratorText();
225
+ expect(text).toContain("text-muted-foreground");
226
+ expect(textToken(text)).toBe(textToken(inputVariants({ size }).field()));
227
+ }
228
+ });
229
+
230
+ test("turns its affix text destructive when the field is invalid", () => {
231
+ expect(inputVariants({ isInvalid: true }).decoratorText()).toContain("text-destructive");
232
+ expect(inputVariants({ isInvalid: true }).decoratorText()).not.toContain("text-muted-foreground");
233
+ });
234
+ });
235
+
236
+ // The whole point of the design: one slot holds the box, and it lands on the
237
+ // `TextInput` when the field stands alone or on the group's row when it does
238
+ // not. These are the tests that make "identical by construction" a property the
239
+ // suite checks rather than a comment.
240
+ describe("the standalone and grouped boxes", () => {
241
+ test("wear exactly the same chrome, wherever the chrome lands", () => {
242
+ for (const state of everyBoxState()) {
243
+ const chrome = inputVariants(state).root().split(" ");
244
+ const alone = resolveInputFieldClass({ ...state, isGrouped: false }).split(" ");
245
+ const row = resolveInputGroupClass(state).split(" ");
246
+
247
+ for (const utility of chrome) {
248
+ // `items-start` is the one class a grouped multiline row adds on
249
+ // top of the box, since a lone field has nothing to align.
250
+ if (utility === "items-start") continue;
251
+ expect(alone).toContain(utility);
252
+ expect(row).toContain(utility);
253
+ }
254
+ }
255
+ });
256
+
257
+ test("never draw two boxes: a grouped field carries no chrome of its own", () => {
258
+ for (const state of everyBoxState()) {
259
+ const grouped = resolveInputFieldClass({ ...state, isGrouped: true });
260
+ expect(grouped).not.toMatch(/\bborder\b|\bborder-/);
261
+ expect(grouped).not.toMatch(/\bbg-/);
262
+ expect(grouped).not.toMatch(/\brounded/);
263
+ expect(grouped).not.toMatch(/h-input-/);
264
+ expect(grouped).not.toMatch(/\bpx-/);
265
+ }
266
+ });
267
+
268
+ test("keep the text on the field, never on the row", () => {
269
+ for (const state of everyBoxState()) {
270
+ expect(resolveInputGroupClass(state)).not.toMatch(/\btext-/);
271
+ expect(resolveInputFieldClass({ ...state, isGrouped: true })).toMatch(/\btext-/);
272
+ expect(resolveInputFieldClass({ ...state, isGrouped: false })).toMatch(/\btext-/);
273
+ }
274
+ });
275
+
276
+ test("lay the row out as a row, which a lone field has no need to be", () => {
277
+ const state = { size: "md" as const, variant: "primary" as const };
278
+ expect(resolveInputGroupClass(state)).toContain("flex-row");
279
+ expect(resolveInputFieldClass({ ...state, isGrouped: false })).not.toContain("flex-row");
280
+ });
281
+
282
+ test("merge an incoming className last, on either path", () => {
283
+ expect(resolveInputFieldClass({ className: "h-12", isGrouped: false })).toContain("h-12");
284
+ expect(resolveInputFieldClass({ className: "h-12", isGrouped: false })).not.toContain("h-input-md");
285
+ expect(resolveInputGroupClass({ className: "w-full" })).toContain("w-full");
286
+ });
287
+ });
288
+
289
+ // Uniwind resolves these through `accentColor`, so the value has to be an
290
+ // `accent-*` utility — anything else warns once and draws nothing.
291
+ describe("the accent resolvers", () => {
292
+ test("name accent utilities, since nothing else resolves", () => {
293
+ for (const value of [
294
+ INPUT_PLACEHOLDER_ACCENT_CLASS,
295
+ INPUT_SELECTION_ACCENT_CLASS,
296
+ INPUT_INVALID_SELECTION_ACCENT_CLASS,
297
+ ]) {
298
+ expect(value).toMatch(/^accent-/);
299
+ }
300
+ });
301
+
302
+ test("default the placeholder to the muted token", () => {
303
+ expect(resolvePlaceholderAccentClass()).toBe(INPUT_PLACEHOLDER_ACCENT_CLASS);
304
+ });
305
+
306
+ test("let a caller replace the placeholder colour", () => {
307
+ expect(resolvePlaceholderAccentClass("accent-destructive")).toBe("accent-destructive");
308
+ });
309
+
310
+ test("turn the caret and selection destructive while the field is invalid", () => {
311
+ expect(resolveSelectionAccentClass({ isInvalid: false })).toBe(INPUT_SELECTION_ACCENT_CLASS);
312
+ expect(resolveSelectionAccentClass({ isInvalid: true })).toBe(INPUT_INVALID_SELECTION_ACCENT_CLASS);
313
+ });
314
+
315
+ test("let a caller override even the invalid selection colour", () => {
316
+ expect(resolveSelectionAccentClass({ className: "accent-info", isInvalid: true })).toBe("accent-info");
317
+ });
318
+ });
319
+
320
+ describe("a joined field's corner", () => {
321
+ test("draws the generic ramp when it stands on its own", () => {
322
+ // A field and the button beside it are meant to be retunable apart, so a
323
+ // lone field takes `--radius`, never the button's shape token.
324
+ expect(radiusClasses(inputVariants({ size: "sm" }).root())).toEqual(["rounded-md"]);
325
+ expect(radiusClasses(inputVariants({ size: "md" }).root())).toEqual(["rounded-lg"]);
326
+ expect(radiusClasses(inputVariants({ size: "lg" }).root())).toEqual(["rounded-lg"]);
327
+ });
328
+
329
+ test("draws the group's corner, cell for cell, when it is joined", () => {
330
+ // The whole point of teaching a field to join: a run capped by a field at
331
+ // one end and a button at the other has to draw one arc, not two ramps
332
+ // meeting in the middle. Asserted against the button's own variants
333
+ // rather than against a copied string, so the two cannot drift apart.
334
+ // Swept over the button's own six sizes, reading each one's step: a field
335
+ // has no square form, so it joins a run at the step the group is built on.
336
+ for (const buttonSize of BUTTON_SIZES) {
337
+ const size = resolveButtonSizeStep(buttonSize);
338
+ for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
339
+ for (const groupPosition of BUTTON_GROUP_POSITIONS) {
340
+ expect(radiusClasses(inputVariants({ groupPosition, orientation, size }).root())).toEqual(
341
+ radiusClasses(buttonVariants({ groupPosition, orientation, size: buttonSize }).root())
342
+ );
343
+ }
344
+ }
345
+ }
346
+ });
347
+
348
+ test("overlaps its neighbour exactly as a button does", () => {
349
+ for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
350
+ const field = inputVariants({ isSeamed: true, orientation }).root();
351
+ const button = buttonVariants({ isSeamed: true, orientation }).root();
352
+ const seam = /\B-(?:ms|mt)-px\b/;
353
+
354
+ expect(field.match(seam)?.[0]).toBe(button.match(seam)?.[0]);
355
+ expect(inputVariants({ isSeamed: false, orientation }).root()).not.toMatch(seam);
356
+ }
357
+ });
358
+
359
+ test("never mixes a physical corner with a logical one", () => {
360
+ for (const size of INPUT_SIZES) {
361
+ for (const groupPosition of BUTTON_GROUP_POSITIONS) {
362
+ expect(inputVariants({ groupPosition, orientation: "horizontal", size }).root()).not.toMatch(
363
+ /\brounded-[trbl]-/
364
+ );
365
+ expect(inputVariants({ groupPosition, orientation: "vertical", size }).root()).not.toMatch(/\brounded-[se]-/);
366
+ }
367
+ }
368
+ });
369
+ });