@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,76 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ resolveFieldBorder,
5
+ shouldShowFieldClear,
6
+ } from "./resolveFieldBorder.ts";
7
+
8
+ const colors = {
9
+ borderColor: "#aaa",
10
+ focusedBorderColor: "#00f",
11
+ errorColor: "#f00",
12
+ };
13
+
14
+ describe("resolveFieldBorder", () => {
15
+ it("resting: width 1 and borderColor", () => {
16
+ const next = resolveFieldBorder({
17
+ isFocused: false,
18
+ hasError: false,
19
+ ...colors,
20
+ });
21
+ assert.equal(next.borderWidth, 1);
22
+ assert.equal(next.borderColor, "#aaa");
23
+ assert.equal(next.paddingHorizontal, 14);
24
+ });
25
+
26
+ it("focused without error: width 2, focused colour, padding -1", () => {
27
+ const next = resolveFieldBorder({
28
+ isFocused: true,
29
+ hasError: false,
30
+ ...colors,
31
+ });
32
+ assert.equal(next.borderWidth, 2);
33
+ assert.equal(next.borderColor, "#00f");
34
+ assert.equal(next.paddingHorizontal, 13);
35
+ });
36
+
37
+ it("error without focus: width 1, error colour", () => {
38
+ const next = resolveFieldBorder({
39
+ isFocused: false,
40
+ hasError: true,
41
+ ...colors,
42
+ });
43
+ assert.equal(next.borderWidth, 1);
44
+ assert.equal(next.borderColor, "#f00");
45
+ assert.equal(next.paddingHorizontal, 14);
46
+ });
47
+
48
+ it("focused + error: width 2, error colour wins", () => {
49
+ const next = resolveFieldBorder({
50
+ isFocused: true,
51
+ hasError: true,
52
+ ...colors,
53
+ });
54
+ assert.equal(next.borderWidth, 2);
55
+ assert.equal(next.borderColor, "#f00");
56
+ assert.equal(next.paddingHorizontal, 13);
57
+ });
58
+ });
59
+
60
+ describe("shouldShowFieldClear", () => {
61
+ it("false when value empty", () => {
62
+ assert.equal(shouldShowFieldClear("", true), false);
63
+ });
64
+
65
+ it("false when no clear icon", () => {
66
+ assert.equal(shouldShowFieldClear("hi", false), false);
67
+ });
68
+
69
+ it("true when value and clear icon", () => {
70
+ assert.equal(shouldShowFieldClear("hi", true), true);
71
+ });
72
+
73
+ it("false when not editable", () => {
74
+ assert.equal(shouldShowFieldClear("hi", true, false), false);
75
+ });
76
+ });
@@ -0,0 +1,40 @@
1
+ const RESTING_PADDING_HORIZONTAL = 14;
2
+
3
+ export type ResolveFieldBorderInput = {
4
+ isFocused: boolean;
5
+ hasError: boolean;
6
+ borderColor: string;
7
+ focusedBorderColor: string;
8
+ errorColor: string;
9
+ restingPaddingHorizontal?: number;
10
+ };
11
+
12
+ export type ResolveFieldBorderResult = {
13
+ borderColor: string;
14
+ borderWidth: number;
15
+ paddingHorizontal: number;
16
+ };
17
+
18
+ export function resolveFieldBorder(
19
+ input: ResolveFieldBorderInput,
20
+ ): ResolveFieldBorderResult {
21
+ const resting =
22
+ input.restingPaddingHorizontal ?? RESTING_PADDING_HORIZONTAL;
23
+ const borderWidth = input.isFocused ? 2 : 1;
24
+ const borderColor = input.hasError
25
+ ? input.errorColor
26
+ : input.isFocused
27
+ ? input.focusedBorderColor
28
+ : input.borderColor;
29
+ const paddingHorizontal = input.isFocused ? resting - 1 : resting;
30
+
31
+ return { borderColor, borderWidth, paddingHorizontal };
32
+ }
33
+
34
+ export function shouldShowFieldClear(
35
+ value: string,
36
+ hasClearIcon: boolean,
37
+ editable = true,
38
+ ): boolean {
39
+ return value.length > 0 && hasClearIcon && editable;
40
+ }
@@ -0,0 +1,92 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { FIELD_THEME_ROLES } from "./fieldThemeRoles.ts";
4
+ import { resolveFieldColor } from "./resolveFieldColor.ts";
5
+
6
+ const ctx = {
7
+ colors: {
8
+ light: {
9
+ fieldBorder: "#aaa",
10
+ fieldFocusedBorder: "#00f",
11
+ fieldError: "#f00",
12
+ fieldText: "#111",
13
+ fieldLabel: "#666",
14
+ fieldPlaceholder: "#666",
15
+ fieldBackground: "#fff",
16
+ fieldDescription: "#777",
17
+ },
18
+ dark: {
19
+ fieldBorder: "#333",
20
+ fieldFocusedBorder: "#88f",
21
+ fieldError: "#f66",
22
+ fieldText: "#eee",
23
+ fieldLabel: "#999",
24
+ fieldPlaceholder: "#999",
25
+ fieldBackground: "#000",
26
+ fieldDescription: "#aaa",
27
+ },
28
+ },
29
+ };
30
+
31
+ describe("resolveFieldColor", () => {
32
+ it("returns the explicit prop when provided", () => {
33
+ assert.equal(
34
+ resolveFieldColor("#abc", FIELD_THEME_ROLES.borderColor, null, "light"),
35
+ "#abc",
36
+ );
37
+ });
38
+
39
+ it("reads the theme role when the prop is omitted", () => {
40
+ assert.equal(
41
+ resolveFieldColor(
42
+ undefined,
43
+ FIELD_THEME_ROLES.borderColor,
44
+ ctx,
45
+ "light",
46
+ ),
47
+ "#aaa",
48
+ );
49
+ assert.equal(
50
+ resolveFieldColor(
51
+ undefined,
52
+ FIELD_THEME_ROLES.focusedBorderColor,
53
+ ctx,
54
+ "dark",
55
+ ),
56
+ "#88f",
57
+ );
58
+ });
59
+
60
+ it("throws when the prop is omitted and ThemeProvider is missing", () => {
61
+ assert.throws(
62
+ () =>
63
+ resolveFieldColor(
64
+ undefined,
65
+ FIELD_THEME_ROLES.textColor,
66
+ null,
67
+ "light",
68
+ ),
69
+ /ThemeProvider/,
70
+ );
71
+ });
72
+
73
+ it("throws when the theme role is missing", () => {
74
+ assert.throws(
75
+ () => resolveFieldColor(undefined, "missingRole", ctx, "light"),
76
+ /Unknown theme colour "missingRole"/,
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("FIELD_THEME_ROLES", () => {
82
+ it("maps each colour prop to a field-prefixed theme role", () => {
83
+ assert.equal(FIELD_THEME_ROLES.borderColor, "fieldBorder");
84
+ assert.equal(FIELD_THEME_ROLES.focusedBorderColor, "fieldFocusedBorder");
85
+ assert.equal(FIELD_THEME_ROLES.errorColor, "fieldError");
86
+ assert.equal(FIELD_THEME_ROLES.textColor, "fieldText");
87
+ assert.equal(FIELD_THEME_ROLES.labelColor, "fieldLabel");
88
+ assert.equal(FIELD_THEME_ROLES.placeholderColor, "fieldPlaceholder");
89
+ assert.equal(FIELD_THEME_ROLES.backgroundColor, "fieldBackground");
90
+ assert.equal(FIELD_THEME_ROLES.descriptionColor, "fieldDescription");
91
+ });
92
+ });
@@ -0,0 +1,26 @@
1
+ import type { ThemeContextValue } from "../../theme/types";
2
+
3
+ /**
4
+ * Resolve a Field colour: explicit prop wins; otherwise read the theme role.
5
+ * Pure helper so it can be unit-tested without rendering.
6
+ */
7
+ export function resolveFieldColor(
8
+ prop: string | undefined,
9
+ role: string,
10
+ ctx: ThemeContextValue | null,
11
+ scheme: "light" | "dark",
12
+ ): string {
13
+ if (prop != null) {
14
+ return prop;
15
+ }
16
+ if (!ctx) {
17
+ throw new Error(
18
+ `Field colour "${role}" was not passed and Field requires ThemeProvider to resolve defaults`,
19
+ );
20
+ }
21
+ const value = ctx.colors[scheme][role];
22
+ if (value == null) {
23
+ throw new Error(`Unknown theme colour "${role}" for scheme "${scheme}"`);
24
+ }
25
+ return value;
26
+ }
@@ -0,0 +1,53 @@
1
+ import type { ReactNode, Ref } from "react";
2
+ import type {
3
+ StyleProp,
4
+ TextInputProps,
5
+ TextStyle,
6
+ ViewStyle,
7
+ } from "react-native";
8
+ import type { ButtonRadius } from "../Button/buttonRadius";
9
+
10
+ export type FieldSharedProps = {
11
+ value: string;
12
+ onChangeText: (text: string) => void;
13
+ /** Defaults to theme role `fieldBorder`. */
14
+ borderColor?: string;
15
+ /** Defaults to theme role `fieldFocusedBorder`. */
16
+ focusedBorderColor?: string;
17
+ /** Defaults to theme role `fieldError`. */
18
+ errorColor?: string;
19
+ /** Defaults to theme role `fieldText`. */
20
+ textColor?: string;
21
+ /** Defaults to theme role `fieldLabel`. */
22
+ labelColor?: string;
23
+ /** Defaults to theme role `fieldPlaceholder`. */
24
+ placeholderColor?: string;
25
+ /** Defaults to theme role `fieldBackground`. */
26
+ backgroundColor?: string;
27
+ label?: string;
28
+ /** Helper text under the input. Hidden while `error` is set. */
29
+ description?: string;
30
+ error?: string;
31
+ placeholder?: string;
32
+ radius?: ButtonRadius;
33
+ editable?: boolean;
34
+ clearIcon?: ReactNode;
35
+ onClear?: () => void;
36
+ style?: StyleProp<ViewStyle>;
37
+ inputStyle?: StyleProp<TextStyle>;
38
+ /** Forwarded to TextInput; do not invert. */
39
+ secureTextEntry?: boolean;
40
+ keyboardType?: TextInputProps["keyboardType"];
41
+ autoCapitalize?: TextInputProps["autoCapitalize"];
42
+ autoComplete?: TextInputProps["autoComplete"];
43
+ textContentType?: TextInputProps["textContentType"];
44
+ onFocus?: TextInputProps["onFocus"];
45
+ onBlur?: TextInputProps["onBlur"];
46
+ ref?: Ref<import("react-native").TextInput>;
47
+ };
48
+
49
+ export type FieldTextProps = FieldSharedProps;
50
+
51
+ export type FieldTextareaProps = FieldSharedProps & {
52
+ numberOfLines?: number;
53
+ };
@@ -0,0 +1,15 @@
1
+ import { useContext } from "react";
2
+ import { useColorScheme as useSystemColorScheme } from "react-native";
3
+ import { resolveColorScheme } from "../../theme/resolveColorScheme";
4
+ import { ThemeContext } from "../../theme/ThemeProvider";
5
+ import { resolveFieldColor } from "./resolveFieldColor";
6
+
7
+ export function useResolvedFieldColor(
8
+ prop: string | undefined,
9
+ role: string,
10
+ ): string {
11
+ const ctx = useContext(ThemeContext);
12
+ const systemScheme = useSystemColorScheme();
13
+ const scheme = resolveColorScheme(systemScheme);
14
+ return resolveFieldColor(prop, role, ctx, scheme);
15
+ }
@@ -0,0 +1,205 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import {
3
+ StyleSheet,
4
+ Text,
5
+ View,
6
+ type LayoutChangeEvent,
7
+ } from "react-native";
8
+ import {
9
+ KeyboardAwareScrollView,
10
+ KeyboardStickyView,
11
+ } from "react-native-keyboard-controller";
12
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
13
+ import { screenPaddingHorizontal } from "../../layout/screenPadding";
14
+ import { Button } from "../Button/Button";
15
+ import { FormCloseButton } from "./FormCloseButton";
16
+ import type { FormProps } from "./types";
17
+ import { useResolvedThemeColor } from "./useResolvedThemeColor";
18
+ import { withAlpha } from "./withAlpha";
19
+
20
+ /** How far the header wash continues below the buttons. */
21
+ const FADE_TAIL = 32;
22
+
23
+ /** Space kept between the focused field and the top of the footer. */
24
+ const FIELD_GAP = 12;
25
+
26
+ /**
27
+ * The scroll view lifts the caret, which sits inside the field. This covers
28
+ * the rest of the control below the caret so the rounded bottom stays visible.
29
+ */
30
+ const FIELD_BELOW_CARET = 36;
31
+
32
+ export function Form({
33
+ title,
34
+ children,
35
+ onClose,
36
+ onSubmit,
37
+ submitLabel = "Save",
38
+ submitting = false,
39
+ submitDisabled = false,
40
+ closeButton,
41
+ submitRadius = "lg",
42
+ paddingHorizontal = screenPaddingHorizontal,
43
+ backgroundColor: backgroundColorProp,
44
+ titleColor: titleColorProp,
45
+ footerBackgroundColor: footerBackgroundColorProp,
46
+ buttonBackgroundColor: buttonBackgroundColorProp,
47
+ buttonLabelColor: buttonLabelColorProp,
48
+ }: FormProps) {
49
+ const insets = useSafeAreaInsets();
50
+ const background = useResolvedThemeColor(backgroundColorProp, "background");
51
+ const titleColor = useResolvedThemeColor(titleColorProp, "text");
52
+ const footerBackground = useResolvedThemeColor(
53
+ footerBackgroundColorProp,
54
+ "surface",
55
+ );
56
+ const buttonBackground = useResolvedThemeColor(
57
+ buttonBackgroundColorProp,
58
+ "buttonBackground",
59
+ );
60
+ const buttonLabel = useResolvedThemeColor(
61
+ buttonLabelColorProp,
62
+ "buttonLabel",
63
+ );
64
+ const [footerHeight, setFooterHeight] = useState(0);
65
+ const [cancelWidth, setCancelWidth] = useState(0);
66
+ const [barHeight, setBarHeight] = useState(0);
67
+ const resolvedBar = barHeight || insets.top + 52;
68
+
69
+ const onFooterLayout = (event: LayoutChangeEvent) => {
70
+ const next = Math.round(event.nativeEvent.layout.height);
71
+ setFooterHeight((current) => (current === next ? current : next));
72
+ };
73
+
74
+ const resolvedClose: ReactNode = closeButton ?? (
75
+ <FormCloseButton onPress={onClose} />
76
+ );
77
+
78
+ return (
79
+ <View style={[styles.screen, { backgroundColor: background }]}>
80
+ <KeyboardAwareScrollView
81
+ automaticallyAdjustKeyboardInsets={false}
82
+ bottomOffset={footerHeight + FIELD_GAP + FIELD_BELOW_CARET}
83
+ contentContainerStyle={[
84
+ styles.content,
85
+ {
86
+ paddingTop: resolvedBar + FADE_TAIL,
87
+ paddingHorizontal,
88
+ },
89
+ ]}
90
+ contentInsetAdjustmentBehavior="never"
91
+ keyboardDismissMode="interactive"
92
+ keyboardShouldPersistTaps="handled"
93
+ showsVerticalScrollIndicator={false}
94
+ style={styles.scroll}
95
+ >
96
+ {children}
97
+ </KeyboardAwareScrollView>
98
+
99
+ <View
100
+ pointerEvents="box-none"
101
+ style={[
102
+ styles.fade,
103
+ {
104
+ height: resolvedBar + FADE_TAIL,
105
+ experimental_backgroundImage: `linear-gradient(to bottom, ${withAlpha(background, 0.98)} 0%, ${withAlpha(background, 0.92)} 55%, ${withAlpha(background, 0)} 100%)`,
106
+ },
107
+ ]}
108
+ >
109
+ <View
110
+ onLayout={(event) => {
111
+ const next = Math.round(event.nativeEvent.layout.height);
112
+ setBarHeight((current) => (current === next ? current : next));
113
+ }}
114
+ pointerEvents="box-none"
115
+ style={[
116
+ styles.header,
117
+ { paddingTop: insets.top, paddingHorizontal },
118
+ ]}
119
+ >
120
+ <View
121
+ onLayout={(event) => {
122
+ const next = Math.round(event.nativeEvent.layout.width);
123
+ setCancelWidth((current) => (current === next ? current : next));
124
+ }}
125
+ >
126
+ {resolvedClose}
127
+ </View>
128
+ <Text
129
+ accessibilityRole="header"
130
+ numberOfLines={1}
131
+ pointerEvents="none"
132
+ style={[styles.title, { flex: 1, color: titleColor }]}
133
+ >
134
+ {title}
135
+ </Text>
136
+ <View style={{ width: cancelWidth }} />
137
+ </View>
138
+ </View>
139
+
140
+ {/*
141
+ The spacer is the home-indicator clearance. `opened` equals that
142
+ spacer, so once the keyboard is up the spacer sits inside the
143
+ keyboard and the button rests on whatever height the keyboard is.
144
+ */}
145
+ <KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }}>
146
+ <View style={[styles.footerShell, { backgroundColor: footerBackground }]}>
147
+ <View
148
+ onLayout={onFooterLayout}
149
+ style={[styles.footer, { paddingHorizontal }]}
150
+ >
151
+ <Button
152
+ backgroundColor={buttonBackground}
153
+ disabled={submitDisabled}
154
+ label={submitLabel}
155
+ labelColor={buttonLabel}
156
+ loading={submitting}
157
+ onPress={onSubmit}
158
+ radius={submitRadius}
159
+ />
160
+ </View>
161
+ <View style={{ height: insets.bottom }} />
162
+ </View>
163
+ </KeyboardStickyView>
164
+ </View>
165
+ );
166
+ }
167
+
168
+ const styles = StyleSheet.create({
169
+ screen: {
170
+ flex: 1,
171
+ },
172
+ scroll: {
173
+ flex: 1,
174
+ },
175
+ content: {
176
+ paddingTop: 20,
177
+ paddingBottom: 24,
178
+ },
179
+ fade: {
180
+ position: "absolute",
181
+ top: 0,
182
+ left: 0,
183
+ right: 0,
184
+ },
185
+ header: {
186
+ flexDirection: "row",
187
+ alignItems: "center",
188
+ },
189
+ title: {
190
+ fontSize: 17,
191
+ fontWeight: "600",
192
+ textAlign: "center",
193
+ },
194
+ footerShell: {
195
+ // Cast upward so the sticky footer separates from the scroll body.
196
+ shadowColor: "#000",
197
+ shadowOffset: { width: 0, height: -4 },
198
+ shadowOpacity: 0.08,
199
+ shadowRadius: 12,
200
+ elevation: 8,
201
+ },
202
+ footer: {
203
+ paddingVertical: 12,
204
+ },
205
+ });
@@ -0,0 +1,19 @@
1
+ import { Pressable, StyleSheet, Text } from "react-native";
2
+ import { useThemeColor } from "../../theme/useThemeColor";
3
+
4
+ export function FormCloseButton({ onPress }: { onPress: () => void }) {
5
+ const text = useThemeColor("text");
6
+
7
+ return (
8
+ <Pressable accessibilityRole="button" hitSlop={8} onPress={onPress}>
9
+ <Text style={[styles.label, { color: text }]}>Cancel</Text>
10
+ </Pressable>
11
+ );
12
+ }
13
+
14
+ const styles = StyleSheet.create({
15
+ label: {
16
+ fontSize: 17,
17
+ fontWeight: "600",
18
+ },
19
+ });
@@ -0,0 +1,3 @@
1
+ export { Form } from "./Form";
2
+ export { FormCloseButton } from "./FormCloseButton";
3
+ export type { FormProps } from "./types";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolve a colour: explicit prop wins; otherwise read the theme role.
3
+ * Pure helper so it can be unit-tested without rendering.
4
+ */
5
+ export function resolveThemeColor(
6
+ prop: string | undefined,
7
+ role: string,
8
+ ctx: { colors: { light: Record<string, string>; dark: Record<string, string> } } | null,
9
+ scheme: "light" | "dark",
10
+ ): string {
11
+ if (prop != null) {
12
+ return prop;
13
+ }
14
+ if (!ctx) {
15
+ throw new Error(
16
+ `Colour "${role}" was not passed and ThemeProvider is required to resolve defaults`,
17
+ );
18
+ }
19
+ const value = ctx.colors[scheme][role];
20
+ if (value == null) {
21
+ throw new Error(`Unknown theme colour "${role}" for scheme "${scheme}"`);
22
+ }
23
+ return value;
24
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ButtonRadius } from "../Button/buttonRadius";
3
+
4
+ export type FormProps = {
5
+ title: string;
6
+ children: ReactNode;
7
+ onClose: () => void;
8
+ onSubmit: () => void;
9
+ submitLabel?: string;
10
+ submitting?: boolean;
11
+ submitDisabled?: boolean;
12
+ /** App-specific Cancel control. Defaults to a plain text Cancel. */
13
+ closeButton?: ReactNode;
14
+ submitRadius?: ButtonRadius;
15
+ /**
16
+ * Horizontal inset for header, scroll content, and footer.
17
+ * Defaults to 16. Pass a value to override.
18
+ */
19
+ paddingHorizontal?: number;
20
+ /** Defaults to theme role `background`. */
21
+ backgroundColor?: string;
22
+ /** Defaults to theme role `text`. */
23
+ titleColor?: string;
24
+ /** Defaults to theme role `surface`. Footer ground. */
25
+ footerBackgroundColor?: string;
26
+ /** Defaults to theme role `buttonBackground`. */
27
+ buttonBackgroundColor?: string;
28
+ /** Defaults to theme role `buttonLabel`. */
29
+ buttonLabelColor?: string;
30
+ };
@@ -0,0 +1,15 @@
1
+ import { useContext } from "react";
2
+ import { useColorScheme as useSystemColorScheme } from "react-native";
3
+ import { resolveColorScheme } from "../../theme/resolveColorScheme";
4
+ import { ThemeContext } from "../../theme/ThemeProvider";
5
+ import { resolveThemeColor } from "./resolveThemeColor";
6
+
7
+ export function useResolvedThemeColor(
8
+ prop: string | undefined,
9
+ role: string,
10
+ ): string {
11
+ const ctx = useContext(ThemeContext);
12
+ const systemScheme = useSystemColorScheme();
13
+ const scheme = resolveColorScheme(systemScheme);
14
+ return resolveThemeColor(prop, role, ctx, scheme);
15
+ }
@@ -0,0 +1,13 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { withAlpha } from "./withAlpha.ts";
4
+
5
+ describe("withAlpha", () => {
6
+ it("converts a hex colour to rgba", () => {
7
+ assert.equal(withAlpha("#EDE7DE", 0.98), "rgba(237, 231, 222, 0.98)");
8
+ });
9
+
10
+ it("expands three-digit hex", () => {
11
+ assert.equal(withAlpha("#abc", 0.5), "rgba(170, 187, 204, 0.5)");
12
+ });
13
+ });
@@ -0,0 +1,9 @@
1
+ export function withAlpha(hex: string, alpha: number): string {
2
+ const raw = hex.replace("#", "");
3
+ const full =
4
+ raw.length === 3 ? raw.split("").map((c) => `${c}${c}`).join("") : raw;
5
+ const r = Number.parseInt(full.slice(0, 2), 16);
6
+ const g = Number.parseInt(full.slice(2, 4), 16);
7
+ const b = Number.parseInt(full.slice(4, 6), 16);
8
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
9
+ }