@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.
- package/LICENSE +15 -0
- package/README.md +91 -0
- package/dist/components/Button/Button.d.ts +16 -0
- package/dist/components/Button/Button.d.ts.map +1 -0
- package/dist/components/Button/Button.js +44 -0
- package/dist/components/Button/Button.js.map +1 -0
- package/dist/components/Button/buttonRadius.d.ts +3 -0
- package/dist/components/Button/buttonRadius.d.ts.map +1 -0
- package/dist/components/Button/buttonRadius.js +17 -0
- package/dist/components/Button/buttonRadius.js.map +1 -0
- package/dist/components/Button/index.d.ts +3 -0
- package/dist/components/Button/index.d.ts.map +1 -0
- package/dist/components/Button/index.js +8 -0
- package/dist/components/Button/index.js.map +1 -0
- package/dist/components/Field/FieldText.d.ts +3 -0
- package/dist/components/Field/FieldText.d.ts.map +1 -0
- package/dist/components/Field/FieldText.js +9 -0
- package/dist/components/Field/FieldText.js.map +1 -0
- package/dist/components/Field/FieldTextarea.d.ts +3 -0
- package/dist/components/Field/FieldTextarea.d.ts.map +1 -0
- package/dist/components/Field/FieldTextarea.js +10 -0
- package/dist/components/Field/FieldTextarea.js.map +1 -0
- package/dist/components/Field/fieldChrome.d.ts +10 -0
- package/dist/components/Field/fieldChrome.d.ts.map +1 -0
- package/dist/components/Field/fieldChrome.js +100 -0
- package/dist/components/Field/fieldChrome.js.map +1 -0
- package/dist/components/Field/fieldThemeRoles.d.ts +13 -0
- package/dist/components/Field/fieldThemeRoles.d.ts.map +1 -0
- package/dist/components/Field/fieldThemeRoles.js +15 -0
- package/dist/components/Field/fieldThemeRoles.js.map +1 -0
- package/dist/components/Field/index.d.ts +6 -0
- package/dist/components/Field/index.d.ts.map +1 -0
- package/dist/components/Field/index.js +10 -0
- package/dist/components/Field/index.js.map +1 -0
- package/dist/components/Field/resolveFieldBorder.d.ts +16 -0
- package/dist/components/Field/resolveFieldBorder.d.ts.map +1 -0
- package/dist/components/Field/resolveFieldBorder.js +20 -0
- package/dist/components/Field/resolveFieldBorder.js.map +1 -0
- package/dist/components/Field/resolveFieldColor.d.ts +7 -0
- package/dist/components/Field/resolveFieldColor.d.ts.map +1 -0
- package/dist/components/Field/resolveFieldColor.js +21 -0
- package/dist/components/Field/resolveFieldColor.js.map +1 -0
- package/dist/components/Field/types.d.ts +46 -0
- package/dist/components/Field/types.d.ts.map +1 -0
- package/dist/components/Field/types.js +3 -0
- package/dist/components/Field/types.js.map +1 -0
- package/dist/components/Field/useResolvedFieldColor.d.ts +2 -0
- package/dist/components/Field/useResolvedFieldColor.d.ts.map +1 -0
- package/dist/components/Field/useResolvedFieldColor.js +15 -0
- package/dist/components/Field/useResolvedFieldColor.js.map +1 -0
- package/dist/components/Form/Form.d.ts +3 -0
- package/dist/components/Form/Form.d.ts.map +1 -0
- package/dist/components/Form/Form.js +100 -0
- package/dist/components/Form/Form.js.map +1 -0
- package/dist/components/Form/FormCloseButton.d.ts +4 -0
- package/dist/components/Form/FormCloseButton.d.ts.map +1 -0
- package/dist/components/Form/FormCloseButton.js +17 -0
- package/dist/components/Form/FormCloseButton.js.map +1 -0
- package/dist/components/Form/index.d.ts +4 -0
- package/dist/components/Form/index.d.ts.map +1 -0
- package/dist/components/Form/index.js +8 -0
- package/dist/components/Form/index.js.map +1 -0
- package/dist/components/Form/resolveThemeColor.d.ts +11 -0
- package/dist/components/Form/resolveThemeColor.d.ts.map +1 -0
- package/dist/components/Form/resolveThemeColor.js +21 -0
- package/dist/components/Form/resolveThemeColor.js.map +1 -0
- package/dist/components/Form/types.d.ts +30 -0
- package/dist/components/Form/types.d.ts.map +1 -0
- package/dist/components/Form/types.js +3 -0
- package/dist/components/Form/types.js.map +1 -0
- package/dist/components/Form/useResolvedThemeColor.d.ts +2 -0
- package/dist/components/Form/useResolvedThemeColor.d.ts.map +1 -0
- package/dist/components/Form/useResolvedThemeColor.js +15 -0
- package/dist/components/Form/useResolvedThemeColor.js.map +1 -0
- package/dist/components/Form/withAlpha.d.ts +2 -0
- package/dist/components/Form/withAlpha.d.ts.map +1 -0
- package/dist/components/Form/withAlpha.js +12 -0
- package/dist/components/Form/withAlpha.js.map +1 -0
- package/dist/components/LinkBlocks/LinkBlocks.d.ts +3 -0
- package/dist/components/LinkBlocks/LinkBlocks.d.ts.map +1 -0
- package/dist/components/LinkBlocks/LinkBlocks.js +75 -0
- package/dist/components/LinkBlocks/LinkBlocks.js.map +1 -0
- package/dist/components/LinkBlocks/filterGroups.d.ts +3 -0
- package/dist/components/LinkBlocks/filterGroups.d.ts.map +1 -0
- package/dist/components/LinkBlocks/filterGroups.js +7 -0
- package/dist/components/LinkBlocks/filterGroups.js.map +1 -0
- package/dist/components/LinkBlocks/index.d.ts +4 -0
- package/dist/components/LinkBlocks/index.d.ts.map +1 -0
- package/dist/components/LinkBlocks/index.js +8 -0
- package/dist/components/LinkBlocks/index.js.map +1 -0
- package/dist/components/LinkBlocks/types.d.ts +26 -0
- package/dist/components/LinkBlocks/types.d.ts.map +1 -0
- package/dist/components/LinkBlocks/types.js +3 -0
- package/dist/components/LinkBlocks/types.js.map +1 -0
- package/dist/components/RadioField/RadioField.d.ts +3 -0
- package/dist/components/RadioField/RadioField.d.ts.map +1 -0
- package/dist/components/RadioField/RadioField.js +76 -0
- package/dist/components/RadioField/RadioField.js.map +1 -0
- package/dist/components/RadioField/RadioMark.d.ts +7 -0
- package/dist/components/RadioField/RadioMark.d.ts.map +1 -0
- package/dist/components/RadioField/RadioMark.js +32 -0
- package/dist/components/RadioField/RadioMark.js.map +1 -0
- package/dist/components/RadioField/index.d.ts +4 -0
- package/dist/components/RadioField/index.d.ts.map +1 -0
- package/dist/components/RadioField/index.js +8 -0
- package/dist/components/RadioField/index.js.map +1 -0
- package/dist/components/RadioField/radioThemeRoles.d.ts +11 -0
- package/dist/components/RadioField/radioThemeRoles.d.ts.map +1 -0
- package/dist/components/RadioField/radioThemeRoles.js +13 -0
- package/dist/components/RadioField/radioThemeRoles.js.map +1 -0
- package/dist/components/RadioField/types.d.ts +24 -0
- package/dist/components/RadioField/types.d.ts.map +1 -0
- package/dist/components/RadioField/types.js +3 -0
- package/dist/components/RadioField/types.js.map +1 -0
- package/dist/components/Select/Select.d.ts +3 -0
- package/dist/components/Select/Select.d.ts.map +1 -0
- package/dist/components/Select/Select.js +137 -0
- package/dist/components/Select/Select.js.map +1 -0
- package/dist/components/Select/SelectCloseButton.d.ts +4 -0
- package/dist/components/Select/SelectCloseButton.d.ts.map +1 -0
- package/dist/components/Select/SelectCloseButton.js +17 -0
- package/dist/components/Select/SelectCloseButton.js.map +1 -0
- package/dist/components/Select/index.d.ts +6 -0
- package/dist/components/Select/index.d.ts.map +1 -0
- package/dist/components/Select/index.js +14 -0
- package/dist/components/Select/index.js.map +1 -0
- package/dist/components/Select/resolveSelectDetentIndex.d.ts +9 -0
- package/dist/components/Select/resolveSelectDetentIndex.d.ts.map +1 -0
- package/dist/components/Select/resolveSelectDetentIndex.js +16 -0
- package/dist/components/Select/resolveSelectDetentIndex.js.map +1 -0
- package/dist/components/Select/selectThemeRoles.d.ts +12 -0
- package/dist/components/Select/selectThemeRoles.d.ts.map +1 -0
- package/dist/components/Select/selectThemeRoles.js +14 -0
- package/dist/components/Select/selectThemeRoles.js.map +1 -0
- package/dist/components/Select/types.d.ts +35 -0
- package/dist/components/Select/types.d.ts.map +1 -0
- package/dist/components/Select/types.js +3 -0
- package/dist/components/Select/types.js.map +1 -0
- package/dist/components/index.d.ts +7 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +27 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/index.d.ts +2 -0
- package/dist/layout/index.d.ts.map +1 -0
- package/dist/layout/index.js +6 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/layout/screenPadding.d.ts +3 -0
- package/dist/layout/screenPadding.d.ts.map +1 -0
- package/dist/layout/screenPadding.js +6 -0
- package/dist/layout/screenPadding.js.map +1 -0
- package/dist/theme/ThemeProvider.d.ts +11 -0
- package/dist/theme/ThemeProvider.d.ts.map +1 -0
- package/dist/theme/ThemeProvider.js +11 -0
- package/dist/theme/ThemeProvider.js.map +1 -0
- package/dist/theme/index.d.ts +6 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +12 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/resolveColorScheme.d.ts +2 -0
- package/dist/theme/resolveColorScheme.d.ts.map +1 -0
- package/dist/theme/resolveColorScheme.js +7 -0
- package/dist/theme/resolveColorScheme.js.map +1 -0
- package/dist/theme/types.d.ts +8 -0
- package/dist/theme/types.d.ts.map +1 -0
- package/dist/theme/types.js +3 -0
- package/dist/theme/types.js.map +1 -0
- package/dist/theme/useColorScheme.d.ts +2 -0
- package/dist/theme/useColorScheme.d.ts.map +1 -0
- package/dist/theme/useColorScheme.js +16 -0
- package/dist/theme/useColorScheme.js.map +1 -0
- package/dist/theme/useThemeColor.d.ts +5 -0
- package/dist/theme/useThemeColor.d.ts.map +1 -0
- package/dist/theme/useThemeColor.js +25 -0
- package/dist/theme/useThemeColor.js.map +1 -0
- package/docs/components/button.md +162 -0
- package/docs/components/field.md +128 -0
- package/docs/components/form.md +86 -0
- package/docs/components/link-blocks.md +120 -0
- package/docs/components/radio-field.md +100 -0
- package/docs/components/select.md +59 -0
- package/docs/superpowers/plans/2026-09-24-field.md +583 -0
- package/docs/superpowers/plans/2026-09-24-link-blocks.md +414 -0
- package/docs/superpowers/plans/2026-09-24-theme-provider-button.md +493 -0
- package/docs/superpowers/plans/2026-09-25-radio-field.md +516 -0
- package/docs/superpowers/plans/2026-09-25-select.md +22 -0
- package/docs/superpowers/specs/2026-09-24-field-design.md +205 -0
- package/docs/superpowers/specs/2026-09-24-link-blocks-design.md +167 -0
- package/docs/superpowers/specs/2026-09-24-theme-provider-button-design.md +178 -0
- package/docs/superpowers/specs/2026-09-25-radio-field-design.md +136 -0
- package/docs/superpowers/specs/2026-09-25-select-design.md +164 -0
- package/package.json +75 -0
- package/src/components/Button/Button.tsx +97 -0
- package/src/components/Button/buttonRadius.test.ts +16 -0
- package/src/components/Button/buttonRadius.ts +24 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Field/FieldText.tsx +6 -0
- package/src/components/Field/FieldTextarea.tsx +18 -0
- package/src/components/Field/fieldChrome.tsx +219 -0
- package/src/components/Field/fieldThemeRoles.ts +13 -0
- package/src/components/Field/index.ts +5 -0
- package/src/components/Field/resolveFieldBorder.test.ts +76 -0
- package/src/components/Field/resolveFieldBorder.ts +40 -0
- package/src/components/Field/resolveFieldColor.test.ts +92 -0
- package/src/components/Field/resolveFieldColor.ts +26 -0
- package/src/components/Field/types.ts +53 -0
- package/src/components/Field/useResolvedFieldColor.ts +15 -0
- package/src/components/Form/Form.tsx +205 -0
- package/src/components/Form/FormCloseButton.tsx +19 -0
- package/src/components/Form/index.ts +3 -0
- package/src/components/Form/resolveThemeColor.ts +24 -0
- package/src/components/Form/types.ts +30 -0
- package/src/components/Form/useResolvedThemeColor.ts +15 -0
- package/src/components/Form/withAlpha.test.ts +13 -0
- package/src/components/Form/withAlpha.ts +9 -0
- package/src/components/LinkBlocks/LinkBlocks.tsx +165 -0
- package/src/components/LinkBlocks/filterGroups.test.ts +37 -0
- package/src/components/LinkBlocks/filterGroups.ts +7 -0
- package/src/components/LinkBlocks/index.ts +7 -0
- package/src/components/LinkBlocks/types.ts +28 -0
- package/src/components/RadioField/RadioField.tsx +168 -0
- package/src/components/RadioField/RadioMark.tsx +54 -0
- package/src/components/RadioField/index.ts +3 -0
- package/src/components/RadioField/radioThemeRoles.test.ts +15 -0
- package/src/components/RadioField/radioThemeRoles.ts +11 -0
- package/src/components/RadioField/types.ts +25 -0
- package/src/components/Select/Select.tsx +279 -0
- package/src/components/Select/SelectCloseButton.tsx +19 -0
- package/src/components/Select/index.ts +13 -0
- package/src/components/Select/resolveSelectDetentIndex.test.ts +20 -0
- package/src/components/Select/resolveSelectDetentIndex.ts +16 -0
- package/src/components/Select/selectThemeRoles.ts +12 -0
- package/src/components/Select/types.ts +39 -0
- package/src/components/index.ts +42 -0
- package/src/index.ts +50 -0
- package/src/layout/index.ts +1 -0
- package/src/layout/screenPadding.ts +2 -0
- package/src/theme/ThemeProvider.tsx +20 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/resolveColorScheme.test.ts +16 -0
- package/src/theme/resolveColorScheme.ts +5 -0
- package/src/theme/types.ts +5 -0
- package/src/theme/useColorScheme.ts +13 -0
- package/src/theme/useThemeColor.ts +25 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Field (FieldText / FieldTextarea) — design
|
|
2
|
+
|
|
3
|
+
Date: 2026-09-24
|
|
4
|
+
Package: `@studio-piot/native-ui`
|
|
5
|
+
Status: approved
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Add presentational form controls **FieldText** and **FieldTextarea**. Apps own colours, clear icons, and form state. The package renders label, input chrome (including focus and error borders), optional clear affordance, and error text. No brand names, theme role catalogues, or icon libraries live in this package.
|
|
10
|
+
|
|
11
|
+
Replace the current example `src/components/Field/` files (app-coupled imports) with this contract.
|
|
12
|
+
|
|
13
|
+
## Decisions
|
|
14
|
+
|
|
15
|
+
| Topic | Choice |
|
|
16
|
+
|---|---|
|
|
17
|
+
| API shape | Single controls: `FieldText`, `FieldTextarea` with optional `label` / `error` |
|
|
18
|
+
| Internals | Shared chrome helper (label, border, focus, error, clear) |
|
|
19
|
+
| Colours | Required colour props (same pattern as `Button` / `LinkBlocks`) |
|
|
20
|
+
| Focus | Resting `borderWidth: 1`; focused `borderWidth: 2` with padding compensation so layout does not jump |
|
|
21
|
+
| Error | Message under the control; border uses `errorColor` when `error` is a non-empty string |
|
|
22
|
+
| Focus + error | Error colour wins; double stroke still applies while focused |
|
|
23
|
+
| Clear | Shown when `value` is non-empty **and** `clearIcon` is provided. Optional `onClear`; default clears via `onChangeText("")` |
|
|
24
|
+
| Radius | Reuse `ButtonRadius` / `buttonRadiusValue`. Default `"xl"` |
|
|
25
|
+
| Width | Always full width of the parent |
|
|
26
|
+
| Disabled | `editable={false}` → opacity `0.4` on the control (same treatment as `Button`) |
|
|
27
|
+
| Brand coupling | None |
|
|
28
|
+
|
|
29
|
+
## Architecture
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
FieldText ──┐
|
|
33
|
+
├── fieldChrome (shared layout + border/focus/error/clear)
|
|
34
|
+
FieldTextarea ─┘
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- Public exports: `FieldText`, `FieldTextarea` only (no compound `Field` / `FieldLabel` / `FieldError` in the public API).
|
|
38
|
+
- Shared helper is package-internal; not required to be a public export.
|
|
39
|
+
- Controlled only: `value` + `onChangeText` required.
|
|
40
|
+
|
|
41
|
+
## Colour props (required)
|
|
42
|
+
|
|
43
|
+
| Prop | Role |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `borderColor` | Resting border |
|
|
46
|
+
| `focusedBorderColor` | Focused border (when not in error) |
|
|
47
|
+
| `errorColor` | Error border and error text |
|
|
48
|
+
| `textColor` | Input value text |
|
|
49
|
+
| `labelColor` | Label above the input |
|
|
50
|
+
| `placeholderColor` | Placeholder |
|
|
51
|
+
| `backgroundColor` | Input fill |
|
|
52
|
+
|
|
53
|
+
Callers typically resolve these with `useThemeColor` (package or app) before passing them in. The Field components do not call theme hooks themselves.
|
|
54
|
+
|
|
55
|
+
## Props
|
|
56
|
+
|
|
57
|
+
### Shared (`FieldText` and `FieldTextarea`)
|
|
58
|
+
|
|
59
|
+
| Prop | Type | Required | Notes |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| `value` | `string` | yes | Controlled |
|
|
62
|
+
| `onChangeText` | `(text: string) => void` | yes | |
|
|
63
|
+
| `borderColor` | `string` | yes | |
|
|
64
|
+
| `focusedBorderColor` | `string` | yes | |
|
|
65
|
+
| `errorColor` | `string` | yes | |
|
|
66
|
+
| `textColor` | `string` | yes | |
|
|
67
|
+
| `labelColor` | `string` | yes | |
|
|
68
|
+
| `placeholderColor` | `string` | yes | |
|
|
69
|
+
| `backgroundColor` | `string` | yes | |
|
|
70
|
+
| `label` | `string` | no | Above the input |
|
|
71
|
+
| `error` | `string` | no | Under the input; truthy drives error border |
|
|
72
|
+
| `placeholder` | `string` | no | |
|
|
73
|
+
| `radius` | `ButtonRadius` | no | Default `"xl"` |
|
|
74
|
+
| `editable` | `boolean` | no | Default `true` |
|
|
75
|
+
| `clearIcon` | `React.ReactNode` | no | Clear shown only with non-empty `value` and this prop |
|
|
76
|
+
| `onClear` | `() => void` | no | Default: `() => onChangeText("")` |
|
|
77
|
+
| `style` | `StyleProp<ViewStyle>` | no | Outer column wrapper |
|
|
78
|
+
| `inputStyle` | `StyleProp<TextStyle>` | no | Extra styles on `TextInput` |
|
|
79
|
+
| Common `TextInput` passthroughs | — | no | e.g. `secureTextEntry`, `keyboardType`, `autoCapitalize`, `autoComplete`, `textContentType`, `onFocus` / `onBlur`, `ref` |
|
|
80
|
+
|
|
81
|
+
Do **not** invert `editable` (the Ribe example’s `editable={!editable}` bug must not be copied).
|
|
82
|
+
|
|
83
|
+
### `FieldTextarea` extras
|
|
84
|
+
|
|
85
|
+
| Prop | Type | Required | Notes |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `numberOfLines` | `number` | no | Default `4` |
|
|
88
|
+
| — | — | — | Always multiline; `textAlignVertical: "top"`; min height derived from line count |
|
|
89
|
+
|
|
90
|
+
## Layout and behaviour
|
|
91
|
+
|
|
92
|
+
### Column
|
|
93
|
+
|
|
94
|
+
Vertical stack with a small gap (~6):
|
|
95
|
+
|
|
96
|
+
1. Optional label — caption-sized (13), weight 600, colour `labelColor`
|
|
97
|
+
2. Input shell — full width
|
|
98
|
+
3. Optional error text — size 13, colour `errorColor`
|
|
99
|
+
|
|
100
|
+
### Input shell
|
|
101
|
+
|
|
102
|
+
- `backgroundColor`, `borderRadius` from `radius`
|
|
103
|
+
- Resting: `borderWidth: 1`, colour `borderColor`
|
|
104
|
+
- Focused (no error): `borderWidth: 2`, colour `focusedBorderColor`; reduce horizontal padding by 1 so the control does not grow
|
|
105
|
+
- Error (with or without focus): border colour `errorColor`; if also focused, keep `borderWidth: 2`
|
|
106
|
+
- Value text: `textColor`; placeholder: `placeholderColor`
|
|
107
|
+
- Single-line (`FieldText`): min height ~48–50, vertical padding aligned with package Button density
|
|
108
|
+
- Textarea: taller min height from `numberOfLines`; top-aligned text
|
|
109
|
+
|
|
110
|
+
### Clear
|
|
111
|
+
|
|
112
|
+
- Absolute position at the trailing end of the input
|
|
113
|
+
- Extra right padding on the `TextInput` when the clear control is visible
|
|
114
|
+
- `TouchableOpacity` / `Pressable` with adequate hitSlop
|
|
115
|
+
- Package does not import icon libraries; `clearIcon` is opaque `ReactNode`
|
|
116
|
+
|
|
117
|
+
### Accessibility
|
|
118
|
+
|
|
119
|
+
- Associate label with the input where practical (`accessibilityLabel` from `label` when present)
|
|
120
|
+
- Error text available to assistive tech (e.g. announced with the field or as related text)
|
|
121
|
+
- Clear control has an accessibility label (e.g. "Clear")
|
|
122
|
+
|
|
123
|
+
## Out of scope (v1)
|
|
124
|
+
|
|
125
|
+
- Uncontrolled mode (`defaultValue`)
|
|
126
|
+
- Validation / form libraries
|
|
127
|
+
- Left adornments, prefixes, floating labels
|
|
128
|
+
- Built-in clear glyph without `clearIcon`
|
|
129
|
+
- Looking up theme roles inside Field
|
|
130
|
+
- Migrating Ribe or Optimum forms onto these components (consumers can follow later)
|
|
131
|
+
|
|
132
|
+
## Package layout
|
|
133
|
+
|
|
134
|
+
| Path | Job |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `src/components/Field/FieldText.tsx` | Single-line public component |
|
|
137
|
+
| `src/components/Field/FieldTextarea.tsx` | Multiline public component |
|
|
138
|
+
| `src/components/Field/fieldChrome.tsx` | Shared label / border / focus / error / clear (name flexible) |
|
|
139
|
+
| `src/components/Field/types.ts` | Shared props types |
|
|
140
|
+
| `src/components/Field/index.ts` | Barrel |
|
|
141
|
+
| Remove | App-coupled example files (`Field.tsx`, `FieldLabel.tsx`, `FieldInput.tsx`, `FieldError.tsx`, and the old `FieldTextarea.tsx` if replaced) |
|
|
142
|
+
| `src/components/index.ts` | Re-export |
|
|
143
|
+
| `src/index.ts` | Public export |
|
|
144
|
+
| `docs/components/field.md` | Usage doc (linked from README) |
|
|
145
|
+
|
|
146
|
+
Reuse `ButtonRadius` / `buttonRadiusValue` from the Button module.
|
|
147
|
+
|
|
148
|
+
## Example (app)
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { FieldText, FieldTextarea, useThemeColor } from "@studio-piot/native-ui";
|
|
152
|
+
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
153
|
+
|
|
154
|
+
const border = useThemeColor("border");
|
|
155
|
+
const focused = useThemeColor("tint");
|
|
156
|
+
const error = useThemeColor("danger");
|
|
157
|
+
const text = useThemeColor("text");
|
|
158
|
+
const textSecondary = useThemeColor("textSecondary");
|
|
159
|
+
const surface = useThemeColor("surface");
|
|
160
|
+
|
|
161
|
+
<FieldText
|
|
162
|
+
label="Email"
|
|
163
|
+
value={email}
|
|
164
|
+
onChangeText={setEmail}
|
|
165
|
+
placeholder="you@example.com"
|
|
166
|
+
error={emailError}
|
|
167
|
+
borderColor={border}
|
|
168
|
+
focusedBorderColor={focused}
|
|
169
|
+
errorColor={error}
|
|
170
|
+
textColor={text}
|
|
171
|
+
labelColor={textSecondary}
|
|
172
|
+
placeholderColor={textSecondary}
|
|
173
|
+
backgroundColor={surface}
|
|
174
|
+
keyboardType="email-address"
|
|
175
|
+
autoCapitalize="none"
|
|
176
|
+
clearIcon={<Ionicons name="close-circle" size={20} color={textSecondary} />}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<FieldTextarea
|
|
180
|
+
label="Notes"
|
|
181
|
+
value={notes}
|
|
182
|
+
onChangeText={setNotes}
|
|
183
|
+
borderColor={border}
|
|
184
|
+
focusedBorderColor={focused}
|
|
185
|
+
errorColor={error}
|
|
186
|
+
textColor={text}
|
|
187
|
+
labelColor={textSecondary}
|
|
188
|
+
placeholderColor={textSecondary}
|
|
189
|
+
backgroundColor={surface}
|
|
190
|
+
/>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Colour role names above are illustrative; the app chooses its own map keys.
|
|
194
|
+
|
|
195
|
+
## Verification
|
|
196
|
+
|
|
197
|
+
- `npm test` and `npm run build` in the package succeed.
|
|
198
|
+
- Typecheck a consumer that renders labelled `FieldText` (with `error` and `clearIcon`) and `FieldTextarea`.
|
|
199
|
+
- Manual: focus doubles stroke without layout jump; error colour + message; clear only when icon + value; disabled opacity; `editable` is not inverted.
|
|
200
|
+
|
|
201
|
+
## Related
|
|
202
|
+
|
|
203
|
+
- Button + theme: `docs/superpowers/specs/2026-09-24-theme-provider-button-design.md`
|
|
204
|
+
- LinkBlocks: `docs/superpowers/specs/2026-09-24-link-blocks-design.md`
|
|
205
|
+
- Usage (after implementation): `docs/components/field.md`
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# LinkBlocks — design
|
|
2
|
+
|
|
3
|
+
Date: 2026-09-24
|
|
4
|
+
Package: `@studio-piot/native-ui`
|
|
5
|
+
Status: approved
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Add a presentational **LinkBlocks** component: one or more rounded groups of tappable rows (optional icon, label, trailing chevron), driven by a config object. Section titles sit outside and above each group. The parent app owns colours, icons, and navigation (`onPress` per item). No brand names, role catalogues, or router dependencies in the package.
|
|
10
|
+
|
|
11
|
+
## Decisions
|
|
12
|
+
|
|
13
|
+
| Topic | Choice |
|
|
14
|
+
|---|---|
|
|
15
|
+
| API shape | Config-driven `LinkBlocks` with `blocks: LinkBlockGroup[]` |
|
|
16
|
+
| Navigation | `onPress` per item only. Package does not import Expo Router or Linking |
|
|
17
|
+
| Icons | Optional `React.ReactNode` per item. Package does not depend on icon libraries |
|
|
18
|
+
| Colours | Required colour props on the component (same pattern as `Button`) |
|
|
19
|
+
| Chevron | Drawn by the package (text chevron). Colour from `chevronColor` |
|
|
20
|
+
| Radius | Same Tailwind-style steps as `Button`. Default `"xl"` |
|
|
21
|
+
| Width | Always full width of the parent |
|
|
22
|
+
| Brand coupling | None |
|
|
23
|
+
|
|
24
|
+
## Data
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
type LinkBlockItem = {
|
|
28
|
+
key: string;
|
|
29
|
+
label: string;
|
|
30
|
+
onPress: () => void;
|
|
31
|
+
icon?: React.ReactNode;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type LinkBlockGroup = {
|
|
36
|
+
key: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
items: LinkBlockItem[];
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `items` must be a non-empty array for a group to render. Empty groups are skipped (no empty surface, no orphan title).
|
|
43
|
+
- `key` is stable for React lists; the parent chooses the string.
|
|
44
|
+
|
|
45
|
+
## Props
|
|
46
|
+
|
|
47
|
+
| Prop | Type | Required | Notes |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `blocks` | `LinkBlockGroup[]` | yes | One or more groups |
|
|
50
|
+
| `surfaceColor` | `string` | yes | Rounded container fill |
|
|
51
|
+
| `labelColor` | `string` | yes | Row label |
|
|
52
|
+
| `dividerColor` | `string` | yes | Hairline between rows |
|
|
53
|
+
| `chevronColor` | `string` | yes | Trailing chevron |
|
|
54
|
+
| `titleColor` | `string` | no | Section title above a block. Required in practice when any group has `title`; if a titled group is rendered without `titleColor`, fall back to `labelColor` |
|
|
55
|
+
| `radius` | `ButtonRadius` | no | Default `"xl"` (reuse `buttonRadiusValue`) |
|
|
56
|
+
| `style` | `StyleProp<ViewStyle>` | no | Outer stack wrapper |
|
|
57
|
+
|
|
58
|
+
Reuse `ButtonRadius` / `buttonRadiusValue` from the Button module so radius stays consistent across components.
|
|
59
|
+
|
|
60
|
+
## Layout and behaviour
|
|
61
|
+
|
|
62
|
+
### Stack
|
|
63
|
+
|
|
64
|
+
- Vertical stack of groups with a fixed gap (16).
|
|
65
|
+
- Each group: optional title, then the rounded surface.
|
|
66
|
+
|
|
67
|
+
### Title
|
|
68
|
+
|
|
69
|
+
- Outside the surface, above it.
|
|
70
|
+
- Caption-sized type (13), weight 600.
|
|
71
|
+
- Colour: `titleColor` if provided, else `labelColor`.
|
|
72
|
+
|
|
73
|
+
### Surface
|
|
74
|
+
|
|
75
|
+
- `backgroundColor: surfaceColor`.
|
|
76
|
+
- `borderRadius` from `radius`.
|
|
77
|
+
- Full width of the parent.
|
|
78
|
+
- Overflow hidden so row presses respect the radius.
|
|
79
|
+
|
|
80
|
+
### Row
|
|
81
|
+
|
|
82
|
+
- `TouchableOpacity` (or `Pressable`) with `accessibilityRole="button"`.
|
|
83
|
+
- Min height 52, horizontal padding 16, horizontal row: optional icon slot, flex label, chevron.
|
|
84
|
+
- Icon slot: fixed width (~28) when any item in that group has an icon; otherwise no leading gutter. If only some items have icons, keep the slot so labels align.
|
|
85
|
+
- Label: font size 17, colour `labelColor`.
|
|
86
|
+
- Chevron: package-owned `›` (or equivalent), colour `chevronColor`, not selectable.
|
|
87
|
+
- `disabled`: non-interactive, opacity `0.4` (same treatment as `Button`).
|
|
88
|
+
|
|
89
|
+
### Dividers
|
|
90
|
+
|
|
91
|
+
- Between consecutive items only (not above the first or below the last).
|
|
92
|
+
- Hairline height (`StyleSheet.hairlineWidth`), colour `dividerColor`.
|
|
93
|
+
- Leading inset so the line starts after the icon column (when present) plus a small gap — not full-bleed to the left edge. Trailing edge can meet the padding.
|
|
94
|
+
|
|
95
|
+
### Out of scope (v1)
|
|
96
|
+
|
|
97
|
+
- Premium / gradient / locked row variants
|
|
98
|
+
- Swipe actions, badges, or trailing custom accessories (chevron only)
|
|
99
|
+
- Built-in navigation or `href`
|
|
100
|
+
- Looking up theme roles inside the package
|
|
101
|
+
- Migrating Ribe Profile onto LinkBlocks (consumer can do that later)
|
|
102
|
+
|
|
103
|
+
## Package layout
|
|
104
|
+
|
|
105
|
+
| Path | Job |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `src/components/LinkBlocks/LinkBlocks.tsx` | Component |
|
|
108
|
+
| `src/components/LinkBlocks/types.ts` | `LinkBlockItem`, `LinkBlockGroup`, props |
|
|
109
|
+
| `src/components/LinkBlocks/index.ts` | Barrel |
|
|
110
|
+
| `src/components/index.ts` | Re-export |
|
|
111
|
+
| `src/index.ts` | Public export |
|
|
112
|
+
| `docs/components/link-blocks.md` | Usage doc (linked from README) |
|
|
113
|
+
|
|
114
|
+
## Example (app)
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { LinkBlocks } from "@studio-piot/native-ui";
|
|
118
|
+
import { useThemeColor } from "@/hooks/useThemeColor";
|
|
119
|
+
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
120
|
+
import { router } from "expo-router";
|
|
121
|
+
|
|
122
|
+
const surface = useThemeColor("surface");
|
|
123
|
+
const text = useThemeColor("text");
|
|
124
|
+
const textSecondary = useThemeColor("textSecondary");
|
|
125
|
+
const border = useThemeColor("border");
|
|
126
|
+
|
|
127
|
+
<LinkBlocks
|
|
128
|
+
surfaceColor={surface}
|
|
129
|
+
labelColor={text}
|
|
130
|
+
dividerColor={border}
|
|
131
|
+
chevronColor={textSecondary}
|
|
132
|
+
titleColor={textSecondary}
|
|
133
|
+
blocks={[
|
|
134
|
+
{
|
|
135
|
+
key: "settings",
|
|
136
|
+
title: "Settings",
|
|
137
|
+
items: [
|
|
138
|
+
{
|
|
139
|
+
key: "units",
|
|
140
|
+
label: "Units of Measure",
|
|
141
|
+
icon: <Ionicons name="options-outline" size={22} color={text} />,
|
|
142
|
+
onPress: () => router.push("/settings/units"),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
key: "notifications",
|
|
146
|
+
label: "Notification Settings",
|
|
147
|
+
icon: <Ionicons name="notifications-outline" size={22} color={text} />,
|
|
148
|
+
onPress: () => router.push("/settings/notifications"),
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
]}
|
|
153
|
+
/>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
On a Stone (or grey) page background, the app passes a lighter `surface` (e.g. white). On a white page, the app chooses a contrasting surface — the package does not compute “lighter than background”.
|
|
157
|
+
|
|
158
|
+
## Verification
|
|
159
|
+
|
|
160
|
+
- `npm test` and `npm run build` in the package succeed.
|
|
161
|
+
- Typecheck a consumer that renders one titled multi-item block and one untitled single-item block.
|
|
162
|
+
- Manual: pressed row fires `onPress`; disabled row does not; dividers and title placement match the reference layout.
|
|
163
|
+
|
|
164
|
+
## Related
|
|
165
|
+
|
|
166
|
+
- Button + theme: `docs/superpowers/specs/2026-09-24-theme-provider-button-design.md`
|
|
167
|
+
- Usage (after implementation): `docs/components/link-blocks.md`
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Theme provider and Button — design
|
|
2
|
+
|
|
3
|
+
Date: 2026-09-24
|
|
4
|
+
Package: `@studio-piot/native-ui`
|
|
5
|
+
Status: approved
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Add a project-agnostic theme layer and the first reusable component, `Button`. Apps own their palettes and semantic roles. The package resolves light/dark from React Native and renders controls from colours the caller passes in. No app-specific brand names, role names, or palettes live in this package.
|
|
10
|
+
|
|
11
|
+
## Decisions
|
|
12
|
+
|
|
13
|
+
| Topic | Choice |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Theme injection | Package `ThemeProvider` + `useThemeColor` / `useColorScheme` |
|
|
16
|
+
| Colour ownership | Apps supply `{ light, dark }` maps of string roles → hex (or any colour string) |
|
|
17
|
+
| Button colours | Required props (`backgroundColor`, `labelColor`); not looked up from theme roles |
|
|
18
|
+
| Scheme source | React Native `useColorScheme`; anything other than `"dark"` is light |
|
|
19
|
+
| Disabled | Opacity on the control; no disabled colour roles |
|
|
20
|
+
| Width | Always full width of the parent (`width: "100%"`). Not configurable |
|
|
21
|
+
| Radius | Prop `radius` with Tailwind-style steps (see below). Default `"xl"` |
|
|
22
|
+
| Layout | Package-owned colour-free `StyleSheet` for the button shell and label |
|
|
23
|
+
| Brand coupling | None. No brand colour names or app-named roles in the package |
|
|
24
|
+
|
|
25
|
+
## Theme
|
|
26
|
+
|
|
27
|
+
### `ThemeProvider`
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
type ThemeColors = Record<string, string>;
|
|
31
|
+
|
|
32
|
+
type ThemeProviderProps<T extends ThemeColors = ThemeColors> = {
|
|
33
|
+
colors: { light: T; dark: T };
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
};
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- Mounts a React context with the `colors` maps.
|
|
39
|
+
- Does not read storage or set `Appearance`. Apps that override the window scheme (for example with `Appearance.setColorScheme`) keep doing that themselves; this provider only supplies maps.
|
|
40
|
+
- Using `useThemeColor` or `useColorScheme` outside the provider throws a clear error.
|
|
41
|
+
|
|
42
|
+
### `useColorScheme`
|
|
43
|
+
|
|
44
|
+
Returns `"light" | "dark"`. Reads React Native’s `useColorScheme` and treats every value other than `"dark"` as light (`null`, `undefined`, `"unspecified"`).
|
|
45
|
+
|
|
46
|
+
### `useThemeColor`
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
function useThemeColor<T extends ThemeColors>(
|
|
50
|
+
colorName: keyof T & string,
|
|
51
|
+
overrides?: { light?: string; dark?: string },
|
|
52
|
+
): string
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Resolution:
|
|
56
|
+
|
|
57
|
+
1. Resolved scheme from `useColorScheme()`.
|
|
58
|
+
2. If `overrides` has a string for that scheme, return it.
|
|
59
|
+
3. Otherwise return `colors[scheme][colorName]`.
|
|
60
|
+
|
|
61
|
+
A missing role is a runtime error (no silent fallback colour). TypeScript narrows `colorName` when the app passes a typed `colors` map into the provider (via generics on the provider / context).
|
|
62
|
+
|
|
63
|
+
Apps may keep their own theme hooks. The package hook is optional for call sites that want one shared API across Studio native apps.
|
|
64
|
+
|
|
65
|
+
## Button
|
|
66
|
+
|
|
67
|
+
Presentational. Theme-aware only in the sense that callers typically resolve colours with `useThemeColor` (package or app) before passing them in.
|
|
68
|
+
|
|
69
|
+
### Props
|
|
70
|
+
|
|
71
|
+
| Prop | Type | Required | Notes |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| `label` | `string` | yes | Visible label |
|
|
74
|
+
| `onPress` | `() => void` | yes | |
|
|
75
|
+
| `backgroundColor` | `string` | yes | Filled: fill. Outline: border and label |
|
|
76
|
+
| `labelColor` | `string` | yes | Filled: label and spinner. Outline: unused for the label (label uses `backgroundColor`) |
|
|
77
|
+
| `disabled` | `boolean` | no | Default `false` |
|
|
78
|
+
| `loading` | `boolean` | no | Default `false`; shows a spinner instead of the label |
|
|
79
|
+
| `variant` | `"filled" \| "outline"` | no | Default `"filled"` |
|
|
80
|
+
| `radius` | `ButtonRadius` | no | Default `"xl"`. See radius table |
|
|
81
|
+
| `style` | `StyleProp<ViewStyle>` | no | Merged after the package shell style |
|
|
82
|
+
| `labelStyle` | `StyleProp<TextStyle>` | no | Merged after the package label style |
|
|
83
|
+
|
|
84
|
+
`ButtonRadius` = `"none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"`.
|
|
85
|
+
|
|
86
|
+
### Behaviour
|
|
87
|
+
|
|
88
|
+
- **Filled:** background `backgroundColor`; label and spinner `labelColor`.
|
|
89
|
+
- **Outline:** transparent fill; border width 1 and label colour `backgroundColor`; spinner uses `backgroundColor`.
|
|
90
|
+
- **Disabled or loading:** control is non-interactive; opacity reduced (for example `0.4`). Same colours as enabled — no separate disabled palette.
|
|
91
|
+
- Accessibility: `accessibilityRole="button"`; `accessibilityState` reflects `disabled` and `busy` when loading.
|
|
92
|
+
- Uses React Native `TouchableOpacity`, `Text`, and `ActivityIndicator`. No app-local text components.
|
|
93
|
+
|
|
94
|
+
### Layout (package stylesheet)
|
|
95
|
+
|
|
96
|
+
Colour-free defaults:
|
|
97
|
+
|
|
98
|
+
- Shell: always `width: "100%"` of its parent, min height `52`, centred content
|
|
99
|
+
- Label: font size `17`, font weight `600`
|
|
100
|
+
|
|
101
|
+
Radius follows Tailwind’s named scale, mapped to density-independent pixels (same ratios as Tailwind’s rem steps at a 16px root):
|
|
102
|
+
|
|
103
|
+
| `radius` | Border radius | Tailwind analogue |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `none` | `0` | `rounded-none` |
|
|
106
|
+
| `sm` | `2` | `rounded-sm` |
|
|
107
|
+
| `md` | `6` | `rounded-md` |
|
|
108
|
+
| `lg` | `8` | `rounded-lg` |
|
|
109
|
+
| `xl` | `12` | `rounded-xl` (default) |
|
|
110
|
+
| `2xl` | `16` | `rounded-2xl` |
|
|
111
|
+
| `3xl` | `24` | `rounded-3xl` |
|
|
112
|
+
| `full` | `999` | `rounded-full` (pill) |
|
|
113
|
+
|
|
114
|
+
Call sites may still pass `style` / `labelStyle` for spacing overrides. They must not rely on `style` to change width: the package always stretches to the parent.
|
|
115
|
+
|
|
116
|
+
### Example (app-side)
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { Button, ThemeProvider, useThemeColor } from "@studio-piot/native-ui";
|
|
120
|
+
import { Colors } from "./config/colors";
|
|
121
|
+
|
|
122
|
+
function SaveButton({ onPress }: { onPress: () => void }) {
|
|
123
|
+
return (
|
|
124
|
+
<Button
|
|
125
|
+
label="Save"
|
|
126
|
+
onPress={onPress}
|
|
127
|
+
backgroundColor={useThemeColor("buttonBackground")}
|
|
128
|
+
labelColor={useThemeColor("buttonLabel")}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Root
|
|
134
|
+
<ThemeProvider colors={Colors}>
|
|
135
|
+
<App />
|
|
136
|
+
</ThemeProvider>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Role names such as `buttonBackground` belong to the app’s `Colors` type, not to this package.
|
|
140
|
+
|
|
141
|
+
## Package layout
|
|
142
|
+
|
|
143
|
+
| Path | Job |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `src/theme/ThemeProvider.tsx` | Context + provider |
|
|
146
|
+
| `src/theme/useColorScheme.ts` | Light/dark resolution |
|
|
147
|
+
| `src/theme/useThemeColor.ts` | Role → colour |
|
|
148
|
+
| `src/theme/index.ts` | Theme exports |
|
|
149
|
+
| `src/components/Button/Button.tsx` | Button |
|
|
150
|
+
| `src/components/Button/buttonRadius.ts` | Radius name → number |
|
|
151
|
+
| `src/components/Button/index.ts` | Button barrel |
|
|
152
|
+
| `src/components/index.ts` | Component barrel |
|
|
153
|
+
| `src/index.ts` | Public entry (`version`, theme, components) |
|
|
154
|
+
|
|
155
|
+
Exports:
|
|
156
|
+
|
|
157
|
+
- `@studio-piot/native-ui` — theme helpers, `Button`, `version`
|
|
158
|
+
- `@studio-piot/native-ui/components` — components only
|
|
159
|
+
|
|
160
|
+
Peer dependencies remain `react` and `react-native`. No new runtime dependencies.
|
|
161
|
+
|
|
162
|
+
## Out of scope
|
|
163
|
+
|
|
164
|
+
- Shipping any brand palette or semantic role catalogue
|
|
165
|
+
- Preference storage or `Appearance.setColorScheme` inside the package
|
|
166
|
+
- Migrating app screens onto the package Button (consumer apps do that separately)
|
|
167
|
+
- Other components (inputs, text, layouts)
|
|
168
|
+
- Visual regression / Storybook
|
|
169
|
+
|
|
170
|
+
## Verification
|
|
171
|
+
|
|
172
|
+
- `npm run build` in the package succeeds.
|
|
173
|
+
- Typecheck of a consumer that mounts `ThemeProvider` with a typed `Colors` map and renders `Button` with resolved colour props.
|
|
174
|
+
- Manual: filled and outline, enabled / disabled / loading, light and dark scheme (scheme change via the app’s own preference or the simulator appearance).
|
|
175
|
+
|
|
176
|
+
## Consumer note (linking)
|
|
177
|
+
|
|
178
|
+
Local apps may depend on the package via `file:` (or a published version) and must keep Metro `watchFolders` / `nodeModulesPaths` pointed at the package when it lives outside the app root, as already set up for local development.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# RadioField — design
|
|
2
|
+
|
|
3
|
+
Date: 2026-09-25
|
|
4
|
+
Package: `@studio-piot/native-ui`
|
|
5
|
+
Status: approved
|
|
6
|
+
Apps: Ribe Appearance screen migration in the same pass
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
Add a presentational **RadioField**: one controlled radio group in a LinkBlocks-style rounded card. Label (and optional description) on the left, radio control on the right. Colours follow Field’s `field*` theme roles, plus one new selected-control role. Migrate Ribe’s Appearance (theme preference) screen to use it.
|
|
11
|
+
|
|
12
|
+
## Decisions
|
|
13
|
+
|
|
14
|
+
| Topic | Choice |
|
|
15
|
+
|---|---|
|
|
16
|
+
| API shape | Single group: `value` + `onChange` + `options[]` |
|
|
17
|
+
| Layout | LinkBlocks card: rounded surface, hairline dividers, no icon column |
|
|
18
|
+
| Row content | Label + optional description left; radio right |
|
|
19
|
+
| Colours | Theme defaults from Field roles; optional prop overrides (Field pattern) |
|
|
20
|
+
| Selected colour | New theme role `fieldRadioSelected` (not reused from focus/border) |
|
|
21
|
+
| Radio drawing | Package-drawn Views (ring + selected fill/dot); no icon libraries |
|
|
22
|
+
| Radius | Reuse `ButtonRadius` / `buttonRadiusValue`. Default `"xl"` |
|
|
23
|
+
| Width | Always full width of the parent |
|
|
24
|
+
| Disabled | Per-option `disabled` → opacity `0.4` on that row |
|
|
25
|
+
| Multi-group | Out of scope (no RadioBlocks) |
|
|
26
|
+
| Brand coupling | None |
|
|
27
|
+
|
|
28
|
+
## Architecture
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
RadioField
|
|
32
|
+
├── optional group label (above card)
|
|
33
|
+
└── card surface
|
|
34
|
+
└── option rows (divider between)
|
|
35
|
+
├── label + description
|
|
36
|
+
└── RadioMark (selected / unselected)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- Public export: `RadioField` (+ types). Internal mark helper need not be public.
|
|
40
|
+
- Controlled only: `value` and `onChange` required.
|
|
41
|
+
- Resolves colours via `ThemeProvider` when override props are omitted (same approach as current Field).
|
|
42
|
+
|
|
43
|
+
## Theme roles
|
|
44
|
+
|
|
45
|
+
| Usage | Theme role |
|
|
46
|
+
|---|---|
|
|
47
|
+
| Card fill | `fieldBackground` |
|
|
48
|
+
| Option label | `fieldText` |
|
|
49
|
+
| Option description | `fieldDescription` |
|
|
50
|
+
| Dividers / unselected ring | `fieldBorder` |
|
|
51
|
+
| Selected radio fill | `fieldRadioSelected` (**new**) |
|
|
52
|
+
| Optional group label above card | `fieldLabel` |
|
|
53
|
+
|
|
54
|
+
Apps must add `fieldRadioSelected` to light and dark maps. Existing Field `field*` roles are reused.
|
|
55
|
+
|
|
56
|
+
### Optional colour overrides
|
|
57
|
+
|
|
58
|
+
| Prop | Default role |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `backgroundColor` | `fieldBackground` |
|
|
61
|
+
| `labelColor` | `fieldText` |
|
|
62
|
+
| `descriptionColor` | `fieldDescription` |
|
|
63
|
+
| `dividerColor` | `fieldBorder` |
|
|
64
|
+
| `radioColor` | `fieldBorder` (unselected ring) |
|
|
65
|
+
| `selectedColor` | `fieldRadioSelected` |
|
|
66
|
+
| `titleColor` | `fieldLabel` (group label above card) |
|
|
67
|
+
|
|
68
|
+
## Props
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
type RadioOption<T extends string = string> = {
|
|
72
|
+
value: T;
|
|
73
|
+
label: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
disabled?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type RadioFieldProps<T extends string = string> = {
|
|
79
|
+
value: T | null;
|
|
80
|
+
onChange: (value: T) => void;
|
|
81
|
+
options: RadioOption<T>[];
|
|
82
|
+
label?: string;
|
|
83
|
+
radius?: ButtonRadius;
|
|
84
|
+
// optional colour overrides (table above)
|
|
85
|
+
style?: StyleProp<ViewStyle>;
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Prop | Notes |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `value` | Controlled selection; `null` = none selected |
|
|
92
|
+
| `onChange` | Fires with the tapped option’s `value` |
|
|
93
|
+
| `options` | Stable `value` strings; empty list renders nothing (no empty card) |
|
|
94
|
+
| `label` | Optional caption above the card (LinkBlocks title density) |
|
|
95
|
+
| `radius` | Default `"xl"` (12) |
|
|
96
|
+
|
|
97
|
+
## Layout
|
|
98
|
+
|
|
99
|
+
Aligned with LinkBlocks row metrics:
|
|
100
|
+
|
|
101
|
+
- Card: full width, `overflow: "hidden"`, radius from `buttonRadiusValue`
|
|
102
|
+
- Row: `minHeight` 52, horizontal padding 16, label column `flex: 1`, radio trailing
|
|
103
|
+
- Label: font size 17; description: font size 13 under label, `fieldDescription`
|
|
104
|
+
- Divider: hairline between rows, full width of the card content edge (no icon inset)
|
|
105
|
+
- Radio mark: ~22pt outer diameter; unselected = ring (`fieldBorder`); selected = filled circle (`fieldRadioSelected`) with small center hole in `fieldBackground`
|
|
106
|
+
|
|
107
|
+
Gap between stacked group label and card: 8 (same as LinkBlocks).
|
|
108
|
+
|
|
109
|
+
## Accessibility
|
|
110
|
+
|
|
111
|
+
- Card / list container: `accessibilityRole="radiogroup"`
|
|
112
|
+
- Each row: `accessibilityRole="radio"`, `accessibilityState={{ selected, disabled }}`, `accessibilityLabel` from option label (description may be included in the accessibility label or as hint where platform supports it)
|
|
113
|
+
- Radio mark is decorative (`accessibilityElementsHidden` / `importantForAccessibility="no"`); the row is the hit target
|
|
114
|
+
|
|
115
|
+
## App work (Ribe)
|
|
116
|
+
|
|
117
|
+
1. Add `fieldRadioSelected` to `ThemeColors` light/dark (e.g. charcoal light / stone dark — solid control matching the reference).
|
|
118
|
+
2. Replace Appearance’s checkmark `Pressable` list with `RadioField` for `automatic` | `light` | `dark`.
|
|
119
|
+
3. Keep current option labels; optionally add a short description on Automatic for parity with “Use Device Settings” style copy (product choice at implement time; default to no new copy unless already decided).
|
|
120
|
+
4. Ensure Field `field*` roles already present (from Field migration) remain.
|
|
121
|
+
|
|
122
|
+
## Out of scope
|
|
123
|
+
|
|
124
|
+
- Multi-group / titled RadioBlocks
|
|
125
|
+
- Leading icons
|
|
126
|
+
- Multi-select
|
|
127
|
+
- Migrating Optimum or other apps
|
|
128
|
+
- Liquid-glass / native UIKit radio controls
|
|
129
|
+
- Keyboard form chrome (Form) changes
|
|
130
|
+
|
|
131
|
+
## Success criteria
|
|
132
|
+
|
|
133
|
+
- Package exports `RadioField` with usage docs
|
|
134
|
+
- Theme defaults documented; `fieldRadioSelected` required for selected state
|
|
135
|
+
- Ribe Appearance uses `RadioField` and looks like the LinkBlocks card + radio reference
|
|
136
|
+
- `npm test` / `npm run build` (package) and Ribe `pnpm typecheck` pass
|