@studio-piot/native-ui 0.1.0

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 (247) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +91 -0
  3. package/dist/components/Button/Button.d.ts +16 -0
  4. package/dist/components/Button/Button.d.ts.map +1 -0
  5. package/dist/components/Button/Button.js +44 -0
  6. package/dist/components/Button/Button.js.map +1 -0
  7. package/dist/components/Button/buttonRadius.d.ts +3 -0
  8. package/dist/components/Button/buttonRadius.d.ts.map +1 -0
  9. package/dist/components/Button/buttonRadius.js +17 -0
  10. package/dist/components/Button/buttonRadius.js.map +1 -0
  11. package/dist/components/Button/index.d.ts +3 -0
  12. package/dist/components/Button/index.d.ts.map +1 -0
  13. package/dist/components/Button/index.js +8 -0
  14. package/dist/components/Button/index.js.map +1 -0
  15. package/dist/components/Field/FieldText.d.ts +3 -0
  16. package/dist/components/Field/FieldText.d.ts.map +1 -0
  17. package/dist/components/Field/FieldText.js +9 -0
  18. package/dist/components/Field/FieldText.js.map +1 -0
  19. package/dist/components/Field/FieldTextarea.d.ts +3 -0
  20. package/dist/components/Field/FieldTextarea.d.ts.map +1 -0
  21. package/dist/components/Field/FieldTextarea.js +10 -0
  22. package/dist/components/Field/FieldTextarea.js.map +1 -0
  23. package/dist/components/Field/fieldChrome.d.ts +10 -0
  24. package/dist/components/Field/fieldChrome.d.ts.map +1 -0
  25. package/dist/components/Field/fieldChrome.js +100 -0
  26. package/dist/components/Field/fieldChrome.js.map +1 -0
  27. package/dist/components/Field/fieldThemeRoles.d.ts +13 -0
  28. package/dist/components/Field/fieldThemeRoles.d.ts.map +1 -0
  29. package/dist/components/Field/fieldThemeRoles.js +15 -0
  30. package/dist/components/Field/fieldThemeRoles.js.map +1 -0
  31. package/dist/components/Field/index.d.ts +6 -0
  32. package/dist/components/Field/index.d.ts.map +1 -0
  33. package/dist/components/Field/index.js +10 -0
  34. package/dist/components/Field/index.js.map +1 -0
  35. package/dist/components/Field/resolveFieldBorder.d.ts +16 -0
  36. package/dist/components/Field/resolveFieldBorder.d.ts.map +1 -0
  37. package/dist/components/Field/resolveFieldBorder.js +20 -0
  38. package/dist/components/Field/resolveFieldBorder.js.map +1 -0
  39. package/dist/components/Field/resolveFieldColor.d.ts +7 -0
  40. package/dist/components/Field/resolveFieldColor.d.ts.map +1 -0
  41. package/dist/components/Field/resolveFieldColor.js +21 -0
  42. package/dist/components/Field/resolveFieldColor.js.map +1 -0
  43. package/dist/components/Field/types.d.ts +46 -0
  44. package/dist/components/Field/types.d.ts.map +1 -0
  45. package/dist/components/Field/types.js +3 -0
  46. package/dist/components/Field/types.js.map +1 -0
  47. package/dist/components/Field/useResolvedFieldColor.d.ts +2 -0
  48. package/dist/components/Field/useResolvedFieldColor.d.ts.map +1 -0
  49. package/dist/components/Field/useResolvedFieldColor.js +15 -0
  50. package/dist/components/Field/useResolvedFieldColor.js.map +1 -0
  51. package/dist/components/Form/Form.d.ts +3 -0
  52. package/dist/components/Form/Form.d.ts.map +1 -0
  53. package/dist/components/Form/Form.js +100 -0
  54. package/dist/components/Form/Form.js.map +1 -0
  55. package/dist/components/Form/FormCloseButton.d.ts +4 -0
  56. package/dist/components/Form/FormCloseButton.d.ts.map +1 -0
  57. package/dist/components/Form/FormCloseButton.js +17 -0
  58. package/dist/components/Form/FormCloseButton.js.map +1 -0
  59. package/dist/components/Form/index.d.ts +4 -0
  60. package/dist/components/Form/index.d.ts.map +1 -0
  61. package/dist/components/Form/index.js +8 -0
  62. package/dist/components/Form/index.js.map +1 -0
  63. package/dist/components/Form/resolveThemeColor.d.ts +11 -0
  64. package/dist/components/Form/resolveThemeColor.d.ts.map +1 -0
  65. package/dist/components/Form/resolveThemeColor.js +21 -0
  66. package/dist/components/Form/resolveThemeColor.js.map +1 -0
  67. package/dist/components/Form/types.d.ts +30 -0
  68. package/dist/components/Form/types.d.ts.map +1 -0
  69. package/dist/components/Form/types.js +3 -0
  70. package/dist/components/Form/types.js.map +1 -0
  71. package/dist/components/Form/useResolvedThemeColor.d.ts +2 -0
  72. package/dist/components/Form/useResolvedThemeColor.d.ts.map +1 -0
  73. package/dist/components/Form/useResolvedThemeColor.js +15 -0
  74. package/dist/components/Form/useResolvedThemeColor.js.map +1 -0
  75. package/dist/components/Form/withAlpha.d.ts +2 -0
  76. package/dist/components/Form/withAlpha.d.ts.map +1 -0
  77. package/dist/components/Form/withAlpha.js +12 -0
  78. package/dist/components/Form/withAlpha.js.map +1 -0
  79. package/dist/components/LinkBlocks/LinkBlocks.d.ts +3 -0
  80. package/dist/components/LinkBlocks/LinkBlocks.d.ts.map +1 -0
  81. package/dist/components/LinkBlocks/LinkBlocks.js +75 -0
  82. package/dist/components/LinkBlocks/LinkBlocks.js.map +1 -0
  83. package/dist/components/LinkBlocks/filterGroups.d.ts +3 -0
  84. package/dist/components/LinkBlocks/filterGroups.d.ts.map +1 -0
  85. package/dist/components/LinkBlocks/filterGroups.js +7 -0
  86. package/dist/components/LinkBlocks/filterGroups.js.map +1 -0
  87. package/dist/components/LinkBlocks/index.d.ts +4 -0
  88. package/dist/components/LinkBlocks/index.d.ts.map +1 -0
  89. package/dist/components/LinkBlocks/index.js +8 -0
  90. package/dist/components/LinkBlocks/index.js.map +1 -0
  91. package/dist/components/LinkBlocks/types.d.ts +26 -0
  92. package/dist/components/LinkBlocks/types.d.ts.map +1 -0
  93. package/dist/components/LinkBlocks/types.js +3 -0
  94. package/dist/components/LinkBlocks/types.js.map +1 -0
  95. package/dist/components/RadioField/RadioField.d.ts +3 -0
  96. package/dist/components/RadioField/RadioField.d.ts.map +1 -0
  97. package/dist/components/RadioField/RadioField.js +76 -0
  98. package/dist/components/RadioField/RadioField.js.map +1 -0
  99. package/dist/components/RadioField/RadioMark.d.ts +7 -0
  100. package/dist/components/RadioField/RadioMark.d.ts.map +1 -0
  101. package/dist/components/RadioField/RadioMark.js +32 -0
  102. package/dist/components/RadioField/RadioMark.js.map +1 -0
  103. package/dist/components/RadioField/index.d.ts +4 -0
  104. package/dist/components/RadioField/index.d.ts.map +1 -0
  105. package/dist/components/RadioField/index.js +8 -0
  106. package/dist/components/RadioField/index.js.map +1 -0
  107. package/dist/components/RadioField/radioThemeRoles.d.ts +11 -0
  108. package/dist/components/RadioField/radioThemeRoles.d.ts.map +1 -0
  109. package/dist/components/RadioField/radioThemeRoles.js +13 -0
  110. package/dist/components/RadioField/radioThemeRoles.js.map +1 -0
  111. package/dist/components/RadioField/types.d.ts +24 -0
  112. package/dist/components/RadioField/types.d.ts.map +1 -0
  113. package/dist/components/RadioField/types.js +3 -0
  114. package/dist/components/RadioField/types.js.map +1 -0
  115. package/dist/components/Select/Select.d.ts +3 -0
  116. package/dist/components/Select/Select.d.ts.map +1 -0
  117. package/dist/components/Select/Select.js +137 -0
  118. package/dist/components/Select/Select.js.map +1 -0
  119. package/dist/components/Select/SelectCloseButton.d.ts +4 -0
  120. package/dist/components/Select/SelectCloseButton.d.ts.map +1 -0
  121. package/dist/components/Select/SelectCloseButton.js +17 -0
  122. package/dist/components/Select/SelectCloseButton.js.map +1 -0
  123. package/dist/components/Select/index.d.ts +6 -0
  124. package/dist/components/Select/index.d.ts.map +1 -0
  125. package/dist/components/Select/index.js +14 -0
  126. package/dist/components/Select/index.js.map +1 -0
  127. package/dist/components/Select/resolveSelectDetentIndex.d.ts +9 -0
  128. package/dist/components/Select/resolveSelectDetentIndex.d.ts.map +1 -0
  129. package/dist/components/Select/resolveSelectDetentIndex.js +16 -0
  130. package/dist/components/Select/resolveSelectDetentIndex.js.map +1 -0
  131. package/dist/components/Select/selectThemeRoles.d.ts +12 -0
  132. package/dist/components/Select/selectThemeRoles.d.ts.map +1 -0
  133. package/dist/components/Select/selectThemeRoles.js +14 -0
  134. package/dist/components/Select/selectThemeRoles.js.map +1 -0
  135. package/dist/components/Select/types.d.ts +35 -0
  136. package/dist/components/Select/types.d.ts.map +1 -0
  137. package/dist/components/Select/types.js +3 -0
  138. package/dist/components/Select/types.js.map +1 -0
  139. package/dist/components/index.d.ts +7 -0
  140. package/dist/components/index.d.ts.map +1 -0
  141. package/dist/components/index.js +27 -0
  142. package/dist/components/index.js.map +1 -0
  143. package/dist/index.d.ts +10 -0
  144. package/dist/index.d.ts.map +1 -0
  145. package/dist/index.js +66 -0
  146. package/dist/index.js.map +1 -0
  147. package/dist/layout/index.d.ts +2 -0
  148. package/dist/layout/index.d.ts.map +1 -0
  149. package/dist/layout/index.js +6 -0
  150. package/dist/layout/index.js.map +1 -0
  151. package/dist/layout/screenPadding.d.ts +3 -0
  152. package/dist/layout/screenPadding.d.ts.map +1 -0
  153. package/dist/layout/screenPadding.js +6 -0
  154. package/dist/layout/screenPadding.js.map +1 -0
  155. package/dist/theme/ThemeProvider.d.ts +11 -0
  156. package/dist/theme/ThemeProvider.d.ts.map +1 -0
  157. package/dist/theme/ThemeProvider.js +11 -0
  158. package/dist/theme/ThemeProvider.js.map +1 -0
  159. package/dist/theme/index.d.ts +6 -0
  160. package/dist/theme/index.d.ts.map +1 -0
  161. package/dist/theme/index.js +12 -0
  162. package/dist/theme/index.js.map +1 -0
  163. package/dist/theme/resolveColorScheme.d.ts +2 -0
  164. package/dist/theme/resolveColorScheme.d.ts.map +1 -0
  165. package/dist/theme/resolveColorScheme.js +7 -0
  166. package/dist/theme/resolveColorScheme.js.map +1 -0
  167. package/dist/theme/types.d.ts +8 -0
  168. package/dist/theme/types.d.ts.map +1 -0
  169. package/dist/theme/types.js +3 -0
  170. package/dist/theme/types.js.map +1 -0
  171. package/dist/theme/useColorScheme.d.ts +2 -0
  172. package/dist/theme/useColorScheme.d.ts.map +1 -0
  173. package/dist/theme/useColorScheme.js +16 -0
  174. package/dist/theme/useColorScheme.js.map +1 -0
  175. package/dist/theme/useThemeColor.d.ts +5 -0
  176. package/dist/theme/useThemeColor.d.ts.map +1 -0
  177. package/dist/theme/useThemeColor.js +25 -0
  178. package/dist/theme/useThemeColor.js.map +1 -0
  179. package/docs/components/button.md +162 -0
  180. package/docs/components/field.md +128 -0
  181. package/docs/components/form.md +86 -0
  182. package/docs/components/link-blocks.md +120 -0
  183. package/docs/components/radio-field.md +100 -0
  184. package/docs/components/select.md +59 -0
  185. package/docs/superpowers/plans/2026-09-24-field.md +583 -0
  186. package/docs/superpowers/plans/2026-09-24-link-blocks.md +414 -0
  187. package/docs/superpowers/plans/2026-09-24-theme-provider-button.md +493 -0
  188. package/docs/superpowers/plans/2026-09-25-radio-field.md +516 -0
  189. package/docs/superpowers/plans/2026-09-25-select.md +22 -0
  190. package/docs/superpowers/specs/2026-09-24-field-design.md +205 -0
  191. package/docs/superpowers/specs/2026-09-24-link-blocks-design.md +167 -0
  192. package/docs/superpowers/specs/2026-09-24-theme-provider-button-design.md +178 -0
  193. package/docs/superpowers/specs/2026-09-25-radio-field-design.md +136 -0
  194. package/docs/superpowers/specs/2026-09-25-select-design.md +164 -0
  195. package/package.json +75 -0
  196. package/src/components/Button/Button.tsx +97 -0
  197. package/src/components/Button/buttonRadius.test.ts +16 -0
  198. package/src/components/Button/buttonRadius.ts +24 -0
  199. package/src/components/Button/index.ts +2 -0
  200. package/src/components/Field/FieldText.tsx +6 -0
  201. package/src/components/Field/FieldTextarea.tsx +18 -0
  202. package/src/components/Field/fieldChrome.tsx +219 -0
  203. package/src/components/Field/fieldThemeRoles.ts +13 -0
  204. package/src/components/Field/index.ts +5 -0
  205. package/src/components/Field/resolveFieldBorder.test.ts +76 -0
  206. package/src/components/Field/resolveFieldBorder.ts +40 -0
  207. package/src/components/Field/resolveFieldColor.test.ts +92 -0
  208. package/src/components/Field/resolveFieldColor.ts +26 -0
  209. package/src/components/Field/types.ts +53 -0
  210. package/src/components/Field/useResolvedFieldColor.ts +15 -0
  211. package/src/components/Form/Form.tsx +205 -0
  212. package/src/components/Form/FormCloseButton.tsx +19 -0
  213. package/src/components/Form/index.ts +3 -0
  214. package/src/components/Form/resolveThemeColor.ts +24 -0
  215. package/src/components/Form/types.ts +30 -0
  216. package/src/components/Form/useResolvedThemeColor.ts +15 -0
  217. package/src/components/Form/withAlpha.test.ts +13 -0
  218. package/src/components/Form/withAlpha.ts +9 -0
  219. package/src/components/LinkBlocks/LinkBlocks.tsx +165 -0
  220. package/src/components/LinkBlocks/filterGroups.test.ts +37 -0
  221. package/src/components/LinkBlocks/filterGroups.ts +7 -0
  222. package/src/components/LinkBlocks/index.ts +7 -0
  223. package/src/components/LinkBlocks/types.ts +28 -0
  224. package/src/components/RadioField/RadioField.tsx +168 -0
  225. package/src/components/RadioField/RadioMark.tsx +54 -0
  226. package/src/components/RadioField/index.ts +3 -0
  227. package/src/components/RadioField/radioThemeRoles.test.ts +15 -0
  228. package/src/components/RadioField/radioThemeRoles.ts +11 -0
  229. package/src/components/RadioField/types.ts +25 -0
  230. package/src/components/Select/Select.tsx +279 -0
  231. package/src/components/Select/SelectCloseButton.tsx +19 -0
  232. package/src/components/Select/index.ts +13 -0
  233. package/src/components/Select/resolveSelectDetentIndex.test.ts +20 -0
  234. package/src/components/Select/resolveSelectDetentIndex.ts +16 -0
  235. package/src/components/Select/selectThemeRoles.ts +12 -0
  236. package/src/components/Select/types.ts +39 -0
  237. package/src/components/index.ts +42 -0
  238. package/src/index.ts +50 -0
  239. package/src/layout/index.ts +1 -0
  240. package/src/layout/screenPadding.ts +2 -0
  241. package/src/theme/ThemeProvider.tsx +20 -0
  242. package/src/theme/index.ts +5 -0
  243. package/src/theme/resolveColorScheme.test.ts +16 -0
  244. package/src/theme/resolveColorScheme.ts +5 -0
  245. package/src/theme/types.ts +5 -0
  246. package/src/theme/useColorScheme.ts +13 -0
  247. package/src/theme/useThemeColor.ts +25 -0
@@ -0,0 +1,164 @@
1
+ # Select — design
2
+
3
+ Date: 2026-09-25
4
+ Package: `@studio-piot/native-ui`
5
+ Status: approved
6
+ Apps: Ribe create-activity Type field in the same pass
7
+
8
+ ## Goal
9
+
10
+ Ship a presentational **Select**: Field-like trigger that opens a native bottom sheet of options (TrueSheet). Highlight the selected option; choosing one updates value and dismisses the sheet. Drag handle, dimmed backdrop, and snap heights feel like system UI (Strava-style grabber). Ribe wires Type of activity to Select with a glass close control.
11
+
12
+ ## Decisions
13
+
14
+ | Topic | Choice |
15
+ |---|---|
16
+ | API shape | Single `Select`: trigger + internal sheet |
17
+ | Sheet library | `@lodev09/react-native-true-sheet` (native UIKit / Material) |
18
+ | Detents | Default `[0.25, 0.5, 0.75]`; present at `0.5` |
19
+ | Close control | `closeButton` slot; package default plain Cancel; Ribe passes glass |
20
+ | Colours | Field `field*` roles + `fieldRadioSelected` for selected check / emphasis |
21
+ | Trigger | Field-like chrome (label, description, error, chevron, radius) |
22
+ | Options | `{ value, label, disabled? }[]`; scrollable when long |
23
+ | Select behaviour | Tap option → `onChange(value)` + dismiss |
24
+ | Icons / search / grids | Out of scope |
25
+ | Optimum migration | Out of scope this pass (CustomPicker remains until a later PR) |
26
+ | Brand coupling | None in the package |
27
+
28
+ ## Architecture
29
+
30
+ ```
31
+ Select
32
+ ├── trigger (Field-like Pressable)
33
+ └── TrueSheet
34
+ ├── grabber (system)
35
+ ├── header (title + closeButton)
36
+ └── scrollable options
37
+ └── row (label + check when selected)
38
+ ```
39
+
40
+ - Public export: `Select` (+ types). Default close control may be a small internal component (plain Cancel), not required to be public.
41
+ - Controlled only: `value` + `onChange` + `options`.
42
+ - Peer dependency: `@lodev09/react-native-true-sheet` (document in usage docs; matching `devDependency` for types/build).
43
+
44
+ ## Theme roles
45
+
46
+ | Usage | Theme role |
47
+ |---|---|
48
+ | Trigger / sheet list ground | `fieldBackground` |
49
+ | Trigger value / option label | `fieldText` |
50
+ | Placeholder / chevron | `fieldPlaceholder` |
51
+ | Label above trigger | `fieldLabel` |
52
+ | Description under trigger | `fieldDescription` |
53
+ | Trigger border / dividers | `fieldBorder` |
54
+ | Error border + message | `fieldError` |
55
+ | Selected option emphasis / check | `fieldRadioSelected` |
56
+
57
+ Optional colour override props follow the Field pattern (explicit prop wins over theme role).
58
+
59
+ ## Props
60
+
61
+ ```ts
62
+ type SelectOption = {
63
+ value: string;
64
+ label: string;
65
+ disabled?: boolean;
66
+ };
67
+
68
+ type SelectProps = {
69
+ value: string | null;
70
+ onChange: (value: string) => void;
71
+ options: SelectOption[];
72
+ placeholder?: string;
73
+ label?: string;
74
+ description?: string;
75
+ error?: string;
76
+ sheetTitle?: string;
77
+ closeButton?: ReactNode;
78
+ detents?: number[];
79
+ initialDetent?: number;
80
+ radius?: ButtonRadius;
81
+ editable?: boolean;
82
+ // optional colour overrides…
83
+ style?: StyleProp<ViewStyle>;
84
+ };
85
+ ```
86
+
87
+ | Prop | Notes |
88
+ |---|---|
89
+ | `value` | Controlled; `null` / unmatched → show placeholder |
90
+ | `onChange` | Fired with option `value` on row press |
91
+ | `options` | Empty list: trigger still opens an empty sheet (or no-op open — prefer open with empty state text “No options”) |
92
+ | `sheetTitle` | Header title; default `label` or `"Select"` |
93
+ | `closeButton` | Render prop / node for header trailing close; default plain Cancel that dismisses |
94
+ | `detents` | Default `[0.25, 0.5, 0.75]` |
95
+ | `initialDetent` | Default `0.5` (must be one of `detents`) |
96
+ | `radius` | Trigger radius; default `"xl"` |
97
+ | `editable` | `false` → reduced opacity, no open |
98
+
99
+ ## Trigger layout
100
+
101
+ Aligned with Field:
102
+
103
+ - Full width; optional label above, description / error below (error replaces description when set)
104
+ - Pressable row: value or placeholder, trailing chevron
105
+ - Resting border `fieldBorder`; error uses `fieldError`
106
+ - Radius via `buttonRadiusValue` (default `"xl"`)
107
+
108
+ ## Sheet behaviour
109
+
110
+ - Present TrueSheet on trigger press (when `editable`)
111
+ - System grabber; dimmed / fading backdrop (library default)
112
+ - Header: title + `closeButton` (or default Cancel)
113
+ - Scrollable option list; row min height ~52; selected row uses `fieldRadioSelected` for check (and optional label tint)
114
+ - Row press: `onChange` + dismiss
115
+ - Dismiss also via grabber drag / backdrop / close control
116
+ - No search field, no category grids, no leading sport icons in this pass
117
+
118
+ ## Accessibility
119
+
120
+ - Trigger: `accessibilityRole="button"`, label from `label` / placeholder; announce current value when set
121
+ - Options: `accessibilityRole="button"` (or `menuitem` if we use a menu pattern — prefer button + `accessibilityState.selected`)
122
+ - Close control: button with accessible name “Cancel” / “Close”
123
+
124
+ ## App work (Ribe)
125
+
126
+ 1. `npx expo install @lodev09/react-native-true-sheet` (or pnpm equivalent)
127
+ 2. Create-activity: replace Type `FieldText` with `Select`
128
+ - Options: Ride, Run, Swim, Walk, Hike (values lowercase slug or same as label — pick stable string values e.g. `ride`, `run`, …)
129
+ - `error` when empty (same validation copy intent as today)
130
+ - `closeButton` that dismisses the sheet using glass `FormCloseButton` (needs a dismiss callback from Select — either `closeButton` as render prop `(dismiss) => ReactNode` **or** Select clones/passes dismiss via slot convention)
131
+
132
+ ### Close slot + dismiss
133
+
134
+ Prefer **render prop** so the app can wire glass Cancel correctly:
135
+
136
+ ```ts
137
+ closeButton?: ReactNode | ((dismiss: () => void) => ReactNode);
138
+ ```
139
+
140
+ If `ReactNode` only, the default Cancel works but glass Cancel cannot call dismiss unless Select exposes an imperative API. **Decision: support function form** `(dismiss) => ReactNode`, and also accept a static `ReactNode` for simple cases (static node cannot dismiss unless it uses an internal context — for static nodes, wrap with Pressable only in default Cancel).
141
+
142
+ Ribe usage:
143
+
144
+ ```tsx
145
+ closeButton={(dismiss) => <FormCloseButton onPress={dismiss} />}
146
+ ```
147
+
148
+ 3. Keep Name / Notes as Field; form submit still gated on name (and optionally require type — keep current “type empty → error” UX).
149
+
150
+ ## Out of scope
151
+
152
+ - Strava “Top Sports” horizontal chips / search
153
+ - Per-option icons
154
+ - Migrating Optimum `CustomPicker` call sites
155
+ - Multi-select
156
+ - Liquid glass Cancel inside the package
157
+ - New `select*` theme roles
158
+
159
+ ## Success criteria
160
+
161
+ - Package exports `Select` with usage docs and TrueSheet peer documented
162
+ - Sheet: grabber, fade backdrop, detents ¼ / ½ / ¾, open at ½, scrollable options, select-to-dismiss
163
+ - Ribe create-activity Type uses Select + glass close
164
+ - `npm test` / `npm run build` (package) and Ribe `pnpm typecheck` pass
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@studio-piot/native-ui",
3
+ "version": "0.1.0",
4
+ "description": "Shared React Native components for Studio Piot apps.",
5
+ "homepage": "https://github.com/Studio-Piot/studio-native-ui#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/Studio-Piot/studio-native-ui/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Studio-Piot/studio-native-ui.git"
12
+ },
13
+ "license": "ISC",
14
+ "author": "Guillaume Piot",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "react-native": "./dist/index.js",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "react-native": "./dist/index.js",
22
+ "import": "./dist/index.js",
23
+ "require": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./components": {
27
+ "types": "./dist/components/index.d.ts",
28
+ "react-native": "./dist/components/index.js",
29
+ "import": "./dist/components/index.js",
30
+ "require": "./dist/components/index.js",
31
+ "default": "./dist/components/index.js"
32
+ }
33
+ },
34
+ "typesVersions": {
35
+ "*": {
36
+ "*": [
37
+ "./dist/*",
38
+ "./dist/index.d.ts"
39
+ ]
40
+ }
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "src",
45
+ "docs",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "peerDependencies": {
53
+ "@lodev09/react-native-true-sheet": ">=3.7.0",
54
+ "react": ">=19.0.0",
55
+ "react-native": ">=0.76.0",
56
+ "react-native-keyboard-controller": ">=1.14.0",
57
+ "react-native-safe-area-context": ">=4.0.0"
58
+ },
59
+ "scripts": {
60
+ "test": "node --experimental-strip-types --test 'src/**/*.test.ts'",
61
+ "build": "tsc",
62
+ "clean": "rm -rf dist *.tsbuildinfo",
63
+ "dev": "tsc --watch",
64
+ "prepare": "tsc",
65
+ "prepublishOnly": "npm run build"
66
+ },
67
+ "devDependencies": {
68
+ "@lodev09/react-native-true-sheet": "3.11.15",
69
+ "@types/react": "^19.2.2",
70
+ "react-native": "^0.86.3",
71
+ "react-native-keyboard-controller": "1.21.9",
72
+ "react-native-safe-area-context": "~5.7.0",
73
+ "typescript": "^5.9.3"
74
+ }
75
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ ActivityIndicator,
3
+ StyleSheet,
4
+ Text,
5
+ TouchableOpacity,
6
+ type StyleProp,
7
+ type TextStyle,
8
+ type ViewStyle,
9
+ } from "react-native";
10
+ import { buttonRadiusValue, type ButtonRadius } from "./buttonRadius";
11
+
12
+ export type ButtonProps = {
13
+ label: string;
14
+ onPress: () => void;
15
+ backgroundColor: string;
16
+ labelColor: string;
17
+ disabled?: boolean;
18
+ loading?: boolean;
19
+ variant?: "filled" | "outline";
20
+ radius?: ButtonRadius;
21
+ style?: StyleProp<ViewStyle>;
22
+ labelStyle?: StyleProp<TextStyle>;
23
+ };
24
+
25
+ export function Button({
26
+ label,
27
+ onPress,
28
+ backgroundColor,
29
+ labelColor,
30
+ disabled = false,
31
+ loading = false,
32
+ variant = "filled",
33
+ radius = "xl",
34
+ style,
35
+ labelStyle,
36
+ }: ButtonProps) {
37
+ const blocked = disabled || loading;
38
+ const isOutline = variant === "outline";
39
+ const borderRadius = buttonRadiusValue(radius);
40
+
41
+ return (
42
+ <TouchableOpacity
43
+ accessibilityLabel={label}
44
+ accessibilityRole="button"
45
+ accessibilityState={{ disabled: blocked, busy: loading }}
46
+ activeOpacity={0.75}
47
+ disabled={blocked}
48
+ onPress={onPress}
49
+ style={[
50
+ styles.shell,
51
+ { borderRadius },
52
+ isOutline
53
+ ? {
54
+ backgroundColor: "transparent",
55
+ borderWidth: 1,
56
+ borderColor: backgroundColor,
57
+ }
58
+ : { backgroundColor },
59
+ blocked && styles.blocked,
60
+ style,
61
+ ]}
62
+ >
63
+ {loading ? (
64
+ <ActivityIndicator
65
+ color={isOutline ? backgroundColor : labelColor}
66
+ size="small"
67
+ />
68
+ ) : (
69
+ <Text
70
+ style={[
71
+ styles.label,
72
+ { color: isOutline ? backgroundColor : labelColor },
73
+ labelStyle,
74
+ ]}
75
+ >
76
+ {label}
77
+ </Text>
78
+ )}
79
+ </TouchableOpacity>
80
+ );
81
+ }
82
+
83
+ const styles = StyleSheet.create({
84
+ shell: {
85
+ width: "100%",
86
+ minHeight: 52,
87
+ alignItems: "center",
88
+ justifyContent: "center",
89
+ },
90
+ blocked: {
91
+ opacity: 0.4,
92
+ },
93
+ label: {
94
+ fontSize: 17,
95
+ fontWeight: "600",
96
+ },
97
+ });
@@ -0,0 +1,16 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { buttonRadiusValue } from "./buttonRadius.ts";
4
+
5
+ describe("buttonRadiusValue", () => {
6
+ it("maps Tailwind-style steps", () => {
7
+ assert.equal(buttonRadiusValue("none"), 0);
8
+ assert.equal(buttonRadiusValue("sm"), 2);
9
+ assert.equal(buttonRadiusValue("md"), 6);
10
+ assert.equal(buttonRadiusValue("lg"), 8);
11
+ assert.equal(buttonRadiusValue("xl"), 12);
12
+ assert.equal(buttonRadiusValue("2xl"), 16);
13
+ assert.equal(buttonRadiusValue("3xl"), 24);
14
+ assert.equal(buttonRadiusValue("full"), 999);
15
+ });
16
+ });
@@ -0,0 +1,24 @@
1
+ export type ButtonRadius =
2
+ | "none"
3
+ | "sm"
4
+ | "md"
5
+ | "lg"
6
+ | "xl"
7
+ | "2xl"
8
+ | "3xl"
9
+ | "full";
10
+
11
+ const RADIUS: Record<ButtonRadius, number> = {
12
+ none: 0,
13
+ sm: 2,
14
+ md: 6,
15
+ lg: 8,
16
+ xl: 12,
17
+ "2xl": 16,
18
+ "3xl": 24,
19
+ full: 999,
20
+ };
21
+
22
+ export function buttonRadiusValue(radius: ButtonRadius): number {
23
+ return RADIUS[radius];
24
+ }
@@ -0,0 +1,2 @@
1
+ export { Button, type ButtonProps } from "./Button";
2
+ export { buttonRadiusValue, type ButtonRadius } from "./buttonRadius";
@@ -0,0 +1,6 @@
1
+ import { FieldChrome } from "./fieldChrome";
2
+ import type { FieldTextProps } from "./types";
3
+
4
+ export function FieldText(props: FieldTextProps) {
5
+ return <FieldChrome {...props} multiline={false} minHeight={48} />;
6
+ }
@@ -0,0 +1,18 @@
1
+ import { FieldChrome } from "./fieldChrome";
2
+ import type { FieldTextareaProps } from "./types";
3
+
4
+ export function FieldTextarea({
5
+ numberOfLines = 4,
6
+ ...props
7
+ }: FieldTextareaProps) {
8
+ const minHeight = Math.max(100, numberOfLines * 24);
9
+ return (
10
+ <FieldChrome
11
+ {...props}
12
+ multiline
13
+ numberOfLines={numberOfLines}
14
+ minHeight={minHeight}
15
+ textAlignVertical="top"
16
+ />
17
+ );
18
+ }
@@ -0,0 +1,219 @@
1
+ import { useState } from "react";
2
+ import {
3
+ StyleSheet,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ View,
8
+ type TextInputProps,
9
+ } from "react-native";
10
+ import { buttonRadiusValue } from "../Button/buttonRadius";
11
+ import { FIELD_THEME_ROLES } from "./fieldThemeRoles";
12
+ import {
13
+ resolveFieldBorder,
14
+ shouldShowFieldClear,
15
+ } from "./resolveFieldBorder";
16
+ import type { FieldSharedProps } from "./types";
17
+ import { useResolvedFieldColor } from "./useResolvedFieldColor";
18
+
19
+ export type FieldChromeProps = FieldSharedProps & {
20
+ multiline?: boolean;
21
+ numberOfLines?: number;
22
+ minHeight?: number;
23
+ textAlignVertical?: TextInputProps["textAlignVertical"];
24
+ };
25
+
26
+ export function FieldChrome({
27
+ value,
28
+ onChangeText,
29
+ borderColor: borderColorProp,
30
+ focusedBorderColor: focusedBorderColorProp,
31
+ errorColor: errorColorProp,
32
+ textColor: textColorProp,
33
+ labelColor: labelColorProp,
34
+ placeholderColor: placeholderColorProp,
35
+ backgroundColor: backgroundColorProp,
36
+ label,
37
+ description,
38
+ error,
39
+ placeholder,
40
+ radius = "xl",
41
+ editable = true,
42
+ clearIcon,
43
+ onClear,
44
+ style,
45
+ inputStyle,
46
+ multiline = false,
47
+ numberOfLines,
48
+ minHeight = 48,
49
+ textAlignVertical,
50
+ secureTextEntry,
51
+ keyboardType,
52
+ autoCapitalize,
53
+ autoComplete,
54
+ textContentType,
55
+ onFocus,
56
+ onBlur,
57
+ ref,
58
+ }: FieldChromeProps) {
59
+ const borderColor = useResolvedFieldColor(
60
+ borderColorProp,
61
+ FIELD_THEME_ROLES.borderColor,
62
+ );
63
+ const focusedBorderColor = useResolvedFieldColor(
64
+ focusedBorderColorProp,
65
+ FIELD_THEME_ROLES.focusedBorderColor,
66
+ );
67
+ const errorColor = useResolvedFieldColor(
68
+ errorColorProp,
69
+ FIELD_THEME_ROLES.errorColor,
70
+ );
71
+ const textColor = useResolvedFieldColor(
72
+ textColorProp,
73
+ FIELD_THEME_ROLES.textColor,
74
+ );
75
+ const labelColor = useResolvedFieldColor(
76
+ labelColorProp,
77
+ FIELD_THEME_ROLES.labelColor,
78
+ );
79
+ const placeholderColor = useResolvedFieldColor(
80
+ placeholderColorProp,
81
+ FIELD_THEME_ROLES.placeholderColor,
82
+ );
83
+ const backgroundColor = useResolvedFieldColor(
84
+ backgroundColorProp,
85
+ FIELD_THEME_ROLES.backgroundColor,
86
+ );
87
+ const descriptionColor = useResolvedFieldColor(
88
+ undefined,
89
+ FIELD_THEME_ROLES.descriptionColor,
90
+ );
91
+
92
+ const [isFocused, setIsFocused] = useState(false);
93
+ const hasError = Boolean(error);
94
+ const showClear = shouldShowFieldClear(value, clearIcon != null, editable);
95
+ const border = resolveFieldBorder({
96
+ isFocused,
97
+ hasError,
98
+ borderColor,
99
+ focusedBorderColor,
100
+ errorColor,
101
+ });
102
+ const borderRadius = buttonRadiusValue(radius);
103
+
104
+ const handleClear = () => {
105
+ if (onClear) onClear();
106
+ else onChangeText("");
107
+ };
108
+
109
+ return (
110
+ <View style={[styles.column, !editable && styles.blocked, style]}>
111
+ {label ? (
112
+ <Text style={[styles.label, { color: labelColor }]}>{label}</Text>
113
+ ) : null}
114
+ <View style={styles.inputWrapper}>
115
+ <TextInput
116
+ ref={ref}
117
+ value={value}
118
+ onChangeText={onChangeText}
119
+ placeholder={placeholder}
120
+ placeholderTextColor={placeholderColor}
121
+ editable={editable}
122
+ multiline={multiline}
123
+ numberOfLines={numberOfLines}
124
+ textAlignVertical={textAlignVertical}
125
+ secureTextEntry={secureTextEntry}
126
+ keyboardType={keyboardType}
127
+ autoCapitalize={autoCapitalize}
128
+ autoComplete={autoComplete}
129
+ textContentType={textContentType}
130
+ accessibilityLabel={label}
131
+ accessibilityState={{ disabled: !editable }}
132
+ onFocus={(e) => {
133
+ setIsFocused(true);
134
+ onFocus?.(e);
135
+ }}
136
+ onBlur={(e) => {
137
+ setIsFocused(false);
138
+ onBlur?.(e);
139
+ }}
140
+ style={[
141
+ styles.input,
142
+ {
143
+ minHeight,
144
+ color: textColor,
145
+ backgroundColor,
146
+ borderRadius,
147
+ borderColor: border.borderColor,
148
+ borderWidth: border.borderWidth,
149
+ paddingHorizontal: border.paddingHorizontal,
150
+ paddingRight: showClear
151
+ ? border.paddingHorizontal + 28
152
+ : border.paddingHorizontal,
153
+ },
154
+ multiline && styles.multiline,
155
+ inputStyle,
156
+ ]}
157
+ />
158
+ {showClear ? (
159
+ <TouchableOpacity
160
+ accessibilityLabel="Clear"
161
+ accessibilityRole="button"
162
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
163
+ onPress={handleClear}
164
+ style={styles.clearButton}
165
+ >
166
+ {clearIcon}
167
+ </TouchableOpacity>
168
+ ) : null}
169
+ </View>
170
+ {error ? (
171
+ <Text style={[styles.helper, { color: errorColor }]}>{error}</Text>
172
+ ) : description ? (
173
+ <Text style={[styles.helper, { color: descriptionColor }]}>
174
+ {description}
175
+ </Text>
176
+ ) : null}
177
+ </View>
178
+ );
179
+ }
180
+
181
+ const styles = StyleSheet.create({
182
+ column: {
183
+ width: "100%",
184
+ gap: 6,
185
+ },
186
+ blocked: {
187
+ opacity: 0.4,
188
+ },
189
+ label: {
190
+ fontSize: 13,
191
+ fontWeight: "600",
192
+ },
193
+ inputWrapper: {
194
+ position: "relative",
195
+ width: "100%",
196
+ justifyContent: "center",
197
+ },
198
+ input: {
199
+ width: "100%",
200
+ paddingVertical: 12,
201
+ fontSize: 17,
202
+ },
203
+ multiline: {
204
+ paddingTop: 16,
205
+ paddingBottom: 16,
206
+ },
207
+ clearButton: {
208
+ position: "absolute",
209
+ right: 8,
210
+ top: 0,
211
+ bottom: 0,
212
+ justifyContent: "center",
213
+ alignItems: "center",
214
+ zIndex: 2,
215
+ },
216
+ helper: {
217
+ fontSize: 13,
218
+ },
219
+ });
@@ -0,0 +1,13 @@
1
+ /** Theme roles Field uses when colour props are omitted. */
2
+ export const FIELD_THEME_ROLES = {
3
+ borderColor: "fieldBorder",
4
+ focusedBorderColor: "fieldFocusedBorder",
5
+ errorColor: "fieldError",
6
+ textColor: "fieldText",
7
+ labelColor: "fieldLabel",
8
+ placeholderColor: "fieldPlaceholder",
9
+ backgroundColor: "fieldBackground",
10
+ descriptionColor: "fieldDescription",
11
+ } as const;
12
+
13
+ export type FieldThemeColorProp = keyof typeof FIELD_THEME_ROLES;
@@ -0,0 +1,5 @@
1
+ export { FieldText } from "./FieldText";
2
+ export { FieldTextarea } from "./FieldTextarea";
3
+ export { FIELD_THEME_ROLES } from "./fieldThemeRoles";
4
+ export type { FieldThemeColorProp } from "./fieldThemeRoles";
5
+ export type { FieldSharedProps, FieldTextProps, FieldTextareaProps } from "./types";