@studio-piot/native-ui 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +4 -1
  2. package/dist/components/SegmentedControl/SegmentedControl.d.ts +3 -0
  3. package/dist/components/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  4. package/dist/components/SegmentedControl/SegmentedControl.js +181 -0
  5. package/dist/components/SegmentedControl/SegmentedControl.js.map +1 -0
  6. package/dist/components/SegmentedControl/index.d.ts +4 -0
  7. package/dist/components/SegmentedControl/index.d.ts.map +1 -0
  8. package/dist/components/SegmentedControl/index.js +8 -0
  9. package/dist/components/SegmentedControl/index.js.map +1 -0
  10. package/dist/components/SegmentedControl/segmentIndicatorFrame.d.ts +16 -0
  11. package/dist/components/SegmentedControl/segmentIndicatorFrame.d.ts.map +1 -0
  12. package/dist/components/SegmentedControl/segmentIndicatorFrame.js +27 -0
  13. package/dist/components/SegmentedControl/segmentIndicatorFrame.js.map +1 -0
  14. package/dist/components/SegmentedControl/segmentThemeRoles.d.ts +10 -0
  15. package/dist/components/SegmentedControl/segmentThemeRoles.d.ts.map +1 -0
  16. package/dist/components/SegmentedControl/segmentThemeRoles.js +12 -0
  17. package/dist/components/SegmentedControl/segmentThemeRoles.js.map +1 -0
  18. package/dist/components/SegmentedControl/types.d.ts +23 -0
  19. package/dist/components/SegmentedControl/types.d.ts.map +1 -0
  20. package/dist/components/SegmentedControl/types.js +3 -0
  21. package/dist/components/SegmentedControl/types.js.map +1 -0
  22. package/dist/components/Select/Select.d.ts +3 -1
  23. package/dist/components/Select/Select.d.ts.map +1 -1
  24. package/dist/components/Select/Select.js +72 -28
  25. package/dist/components/Select/Select.js.map +1 -1
  26. package/dist/components/Select/SelectCloseButton.d.ts +9 -3
  27. package/dist/components/Select/SelectCloseButton.d.ts.map +1 -1
  28. package/dist/components/Select/SelectCloseButton.js +2 -5
  29. package/dist/components/Select/SelectCloseButton.js.map +1 -1
  30. package/dist/components/Select/presentSelectSheet.d.ts +7 -0
  31. package/dist/components/Select/presentSelectSheet.d.ts.map +1 -0
  32. package/dist/components/Select/presentSelectSheet.js +14 -0
  33. package/dist/components/Select/presentSelectSheet.js.map +1 -0
  34. package/dist/components/Select/selectThemeRoles.d.ts +1 -1
  35. package/dist/components/Select/selectThemeRoles.js +1 -1
  36. package/dist/components/Select/selectThemeRoles.js.map +1 -1
  37. package/dist/components/Select/types.d.ts +2 -2
  38. package/dist/components/Select/types.d.ts.map +1 -1
  39. package/dist/components/Slider/Slider.d.ts +3 -0
  40. package/dist/components/Slider/Slider.d.ts.map +1 -0
  41. package/dist/components/Slider/Slider.js +46 -0
  42. package/dist/components/Slider/Slider.js.map +1 -0
  43. package/dist/components/Slider/index.d.ts +4 -0
  44. package/dist/components/Slider/index.d.ts.map +1 -0
  45. package/dist/components/Slider/index.js +8 -0
  46. package/dist/components/Slider/index.js.map +1 -0
  47. package/dist/components/Slider/sliderThemeRoles.d.ts +8 -0
  48. package/dist/components/Slider/sliderThemeRoles.d.ts.map +1 -0
  49. package/dist/components/Slider/sliderThemeRoles.js +10 -0
  50. package/dist/components/Slider/sliderThemeRoles.js.map +1 -0
  51. package/dist/components/Slider/types.d.ts +18 -0
  52. package/dist/components/Slider/types.d.ts.map +1 -0
  53. package/dist/components/Slider/types.js +3 -0
  54. package/dist/components/Slider/types.js.map +1 -0
  55. package/dist/components/index.d.ts +2 -0
  56. package/dist/components/index.d.ts.map +1 -1
  57. package/dist/components/index.js +7 -1
  58. package/dist/components/index.js.map +1 -1
  59. package/dist/index.d.ts +3 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +8 -2
  62. package/dist/index.js.map +1 -1
  63. package/docs/components/segmented-control.md +95 -0
  64. package/docs/components/select.md +11 -6
  65. package/docs/components/slider.md +65 -0
  66. package/docs/superpowers/plans/2026-09-25-slider.md +35 -0
  67. package/docs/superpowers/specs/2026-09-25-select-design.md +3 -3
  68. package/docs/superpowers/specs/2026-09-25-slider-design.md +117 -0
  69. package/package.json +6 -2
  70. package/src/components/SegmentedControl/SegmentedControl.tsx +234 -0
  71. package/src/components/SegmentedControl/index.ts +6 -0
  72. package/src/components/SegmentedControl/segmentIndicatorFrame.test.ts +39 -0
  73. package/src/components/SegmentedControl/segmentIndicatorFrame.ts +31 -0
  74. package/src/components/SegmentedControl/segmentThemeRoles.test.ts +17 -0
  75. package/src/components/SegmentedControl/segmentThemeRoles.ts +10 -0
  76. package/src/components/SegmentedControl/types.ts +24 -0
  77. package/src/components/Select/Select.tsx +133 -64
  78. package/src/components/Select/SelectCloseButton.tsx +15 -7
  79. package/src/components/Select/selectThemeRoles.test.ts +2 -2
  80. package/src/components/Select/selectThemeRoles.ts +1 -1
  81. package/src/components/Select/types.ts +2 -2
  82. package/src/components/Slider/Slider.tsx +95 -0
  83. package/src/components/Slider/index.ts +6 -0
  84. package/src/components/Slider/sliderThemeRoles.test.ts +12 -0
  85. package/src/components/Slider/sliderThemeRoles.ts +8 -0
  86. package/src/components/Slider/types.ts +18 -0
  87. package/src/components/index.ts +13 -0
  88. package/src/index.ts +14 -1
@@ -15,9 +15,9 @@ Ship a presentational **Select**: Field-like trigger that opens a Gorhom bottom
15
15
  |---|---|
16
16
  | API shape | Single `Select`: trigger + internal sheet |
17
17
  | Sheet library | `@gorhom/bottom-sheet` (`BottomSheetModal`) |
18
- | Snap points | Default `["25%", "50%", "75%"]` (from detents `[0.25, 0.5, 0.75]`); present at mid (`0.5` / index 1) |
18
+ | Sheet height | Content-sized (`enableDynamicSizing`); max = largest detent (default 75% of window) |
19
19
  | Close control | `closeButton` slot; package default **DiscClose** (dark disc + white ×); Ribe uses default |
20
- | Colours | Field `field*` roles + `fieldRadioSelected` for selected check / emphasis |
20
+ | Colours | Field `field*` roles + `fieldSelectSelected` for selected check / emphasis |
21
21
  | Trigger | Field-like chrome (label, description, error, chevron, radius) |
22
22
  | Options | `{ value, label, disabled? }[]`; scrollable when long |
23
23
  | Select behaviour | Tap option → `onChange(value)` + dismiss |
@@ -55,7 +55,7 @@ Select
55
55
  | Description under trigger | `fieldDescription` |
56
56
  | Trigger border / dividers | `fieldBorder` |
57
57
  | Error border + message | `fieldError` |
58
- | Selected option emphasis / check | `fieldRadioSelected` |
58
+ | Selected option emphasis / check | `fieldSelectSelected` |
59
59
 
60
60
  Optional colour override props follow the Field pattern (explicit prop wins over theme role).
61
61
 
@@ -0,0 +1,117 @@
1
+ # Slider — design
2
+
3
+ Date: 2026-09-25
4
+ Package: `@studio-piot/native-ui`
5
+ Status: approved
6
+ Apps: Ribe create-activity Effort field in the same pass
7
+
8
+ ## Goal
9
+
10
+ Ship a presentational **Slider**: label on the left, live numeric value on the top right, community slider track below. Highlight (filled track + thumb) uses a dedicated theme role. Ribe adds an Effort control (1–10) on create-activity.
11
+
12
+ ## Decisions
13
+
14
+ | Topic | Choice |
15
+ |---|---|
16
+ | API shape | Controlled: `value` + `onChange` + `minimumValue` / `maximumValue` |
17
+ | Track library | `@react-native-community/slider` (peer) |
18
+ | Step | Optional `step`; omit = continuous |
19
+ | Value display | `formatValue?: (value: number) => string`; default `String(value)` |
20
+ | Layout | Header row (label left, value right); track full width below |
21
+ | Min/max UI | Props only — no captions under the track |
22
+ | Highlight colour | New theme role `fieldSliderHighlight` |
23
+ | Unfilled track | `fieldBorder` |
24
+ | Label / value text | `fieldLabel` / `fieldText` |
25
+ | Disabled | Optional `disabled` → reduced opacity, non-interactive |
26
+ | Brand coupling | None in the package |
27
+ | Optimum LogForm | Out of scope this pass |
28
+
29
+ ## Architecture
30
+
31
+ ```
32
+ Slider
33
+ ├── header row
34
+ │ ├── label (optional)
35
+ │ └── formatValue(value)
36
+ └── @react-native-community/slider
37
+ ├── minimumTrackTintColor / thumb → fieldSliderHighlight
38
+ └── maximumTrackTintColor → fieldBorder
39
+ ```
40
+
41
+ - Public export: `Slider` (+ types, `SLIDER_THEME_ROLES`).
42
+ - Controlled only.
43
+ - Peer: `@react-native-community/slider` (document in usage docs; matching `devDependency` for types/build).
44
+
45
+ ## Theme roles
46
+
47
+ | Usage | Theme role |
48
+ |---|---|
49
+ | Label | `fieldLabel` |
50
+ | Value | `fieldText` |
51
+ | Filled track + thumb | `fieldSliderHighlight` (**new**) |
52
+ | Unfilled track | `fieldBorder` |
53
+
54
+ Apps must add `fieldSliderHighlight` to light and dark maps. Ribe uses flare for both schemes.
55
+
56
+ ### Optional colour overrides
57
+
58
+ | Prop | Default role |
59
+ |---|---|
60
+ | `labelColor` | `fieldLabel` |
61
+ | `valueColor` | `fieldText` |
62
+ | `highlightColor` | `fieldSliderHighlight` |
63
+ | `trackColor` | `fieldBorder` |
64
+
65
+ ## Props
66
+
67
+ ```ts
68
+ type SliderProps = {
69
+ value: number;
70
+ onChange: (value: number) => void;
71
+ minimumValue: number;
72
+ maximumValue: number;
73
+ step?: number;
74
+ label?: string;
75
+ formatValue?: (value: number) => string;
76
+ disabled?: boolean;
77
+ labelColor?: string;
78
+ valueColor?: string;
79
+ highlightColor?: string;
80
+ trackColor?: string;
81
+ style?: StyleProp<ViewStyle>;
82
+ };
83
+ ```
84
+
85
+ | Prop | Notes |
86
+ |---|---|
87
+ | `value` / `onChange` | Required. Controlled |
88
+ | `minimumValue` / `maximumValue` | Required. Passed through to community Slider |
89
+ | `step` | Optional; omit for continuous |
90
+ | `label` | Optional; omitted → value still shows top-right |
91
+ | `formatValue` | Default `String(value)` |
92
+ | `disabled` | Opacity ~0.4; slider not interactive |
93
+
94
+ ## Layout
95
+
96
+ - Full width of the parent
97
+ - Header: `flexDirection: "row"`, label `flex: 1`, value right-aligned; font sizes aligned with Field (label 13 / semibold, value 17)
98
+ - Gap ~6 between header and track (Field-like)
99
+ - Track height from community Slider default (~40pt hit area)
100
+
101
+ ## Accessibility
102
+
103
+ - Community Slider exposes the native control
104
+ - When `label` is set, pass it as `accessibilityLabel` on the slider (or combine label + formatted value)
105
+ - `accessibilityState.disabled` when `disabled`
106
+
107
+ ## Ribe migration
108
+
109
+ - `Colors` light/dark: `fieldSliderHighlight: flare`
110
+ - create-activity: **Effort** between Type and Notes — `minimumValue={1}`, `maximumValue={10}`, `step={1}`, default value `5`, `formatValue={(n) => String(n)}`
111
+ - Install peer `@react-native-community/slider` (Expo-compatible version)
112
+
113
+ ## Out of scope
114
+
115
+ - Custom-drawn track / dual thumbs / marks
116
+ - Migrating Optimum LogForm sliders
117
+ - Error / description helpers (can follow Field later if needed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studio-piot/native-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "Shared React Native components for Studio Piot apps.",
5
5
  "homepage": "https://github.com/Studio-Piot/studio-native-ui#readme",
6
6
  "bugs": {
@@ -51,12 +51,14 @@
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@gorhom/bottom-sheet": "^5.2.14",
54
+ "@react-native-community/slider": "^5.2.0",
54
55
  "react": ">=19.0.0",
55
56
  "react-native": ">=0.76.0",
56
57
  "react-native-gesture-handler": "^2.32.0",
57
58
  "react-native-keyboard-controller": ">=1.14.0",
58
59
  "react-native-reanimated": "^4.5.1",
59
- "react-native-safe-area-context": ">=4.0.0"
60
+ "react-native-safe-area-context": ">=4.0.0",
61
+ "react-native-screens": ">=4.0.0"
60
62
  },
61
63
  "scripts": {
62
64
  "test": "node --experimental-strip-types --test 'src/**/*.test.ts'",
@@ -68,12 +70,14 @@
68
70
  },
69
71
  "devDependencies": {
70
72
  "@gorhom/bottom-sheet": "^5.2.14",
73
+ "@react-native-community/slider": "^5.2.0",
71
74
  "@types/react": "^19.2.2",
72
75
  "react-native": "^0.86.3",
73
76
  "react-native-gesture-handler": "^2.32.0",
74
77
  "react-native-keyboard-controller": "1.21.9",
75
78
  "react-native-reanimated": "^4.5.1",
76
79
  "react-native-safe-area-context": "~5.7.0",
80
+ "react-native-screens": "~4.26.0",
77
81
  "typescript": "^5.9.3"
78
82
  }
79
83
  }
@@ -0,0 +1,234 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Pressable, StyleSheet, Text, View } from "react-native";
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withTiming,
8
+ } from "react-native-reanimated";
9
+ import { buttonRadiusValue } from "../Button/buttonRadius";
10
+ import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
11
+ import {
12
+ resolveSegmentIndex,
13
+ SEGMENT_INSET,
14
+ segmentIndicatorFrame,
15
+ } from "./segmentIndicatorFrame";
16
+ import { SEGMENTED_THEME_ROLES } from "./segmentThemeRoles";
17
+ import type { SegmentedControlProps } from "./types";
18
+
19
+ const SLIDE_MS = 220;
20
+
21
+ export function SegmentedControl<T extends string = string>({
22
+ value,
23
+ onChange,
24
+ options,
25
+ label,
26
+ radius = "lg",
27
+ disabled = false,
28
+ backgroundColor: backgroundColorProp,
29
+ borderColor: borderColorProp,
30
+ selectedBackgroundColor: selectedBackgroundColorProp,
31
+ labelColor: labelColorProp,
32
+ selectedLabelColor: selectedLabelColorProp,
33
+ titleColor: titleColorProp,
34
+ style,
35
+ }: SegmentedControlProps<T>) {
36
+ const backgroundColor = useResolvedFieldColor(
37
+ backgroundColorProp,
38
+ SEGMENTED_THEME_ROLES.backgroundColor,
39
+ );
40
+ const borderColor = useResolvedFieldColor(
41
+ borderColorProp,
42
+ SEGMENTED_THEME_ROLES.borderColor,
43
+ );
44
+ const selectedBackgroundColor = useResolvedFieldColor(
45
+ selectedBackgroundColorProp,
46
+ SEGMENTED_THEME_ROLES.selectedBackgroundColor,
47
+ );
48
+ const labelColor = useResolvedFieldColor(
49
+ labelColorProp,
50
+ SEGMENTED_THEME_ROLES.labelColor,
51
+ );
52
+ const selectedLabelColor = useResolvedFieldColor(
53
+ selectedLabelColorProp,
54
+ SEGMENTED_THEME_ROLES.selectedLabelColor,
55
+ );
56
+ const titleColor = useResolvedFieldColor(
57
+ titleColorProp,
58
+ SEGMENTED_THEME_ROLES.titleColor,
59
+ );
60
+
61
+ const index = resolveSegmentIndex(value, options);
62
+ const trackWidth = useRef(0);
63
+ const laidOut = useRef(false);
64
+ const indexSV = useSharedValue(0);
65
+ const widthSV = useSharedValue(0);
66
+ const opacitySV = useSharedValue(0);
67
+
68
+ const borderRadius = buttonRadiusValue(radius);
69
+ const pillRadius = Math.max(0, borderRadius - SEGMENT_INSET);
70
+
71
+ const applyTrackWidth = (nextWidth: number, count: number) => {
72
+ trackWidth.current = nextWidth;
73
+ const frame = segmentIndicatorFrame(0, count, nextWidth, SEGMENT_INSET);
74
+ widthSV.value = frame?.width ?? 0;
75
+ };
76
+
77
+ useEffect(() => {
78
+ if (trackWidth.current > 0) {
79
+ applyTrackWidth(trackWidth.current, options.length);
80
+ }
81
+ }, [options.length, widthSV]);
82
+
83
+ useEffect(() => {
84
+ if (!laidOut.current) {
85
+ return;
86
+ }
87
+ if (index < 0) {
88
+ opacitySV.value = withTiming(0, { duration: 160 });
89
+ return;
90
+ }
91
+ opacitySV.value = withTiming(1, { duration: 160 });
92
+ indexSV.value = withTiming(index, {
93
+ duration: SLIDE_MS,
94
+ easing: Easing.out(Easing.cubic),
95
+ });
96
+ }, [index, indexSV, opacitySV]);
97
+
98
+ const indicatorStyle = useAnimatedStyle(() => ({
99
+ width: widthSV.value,
100
+ opacity: opacitySV.value,
101
+ transform: [{ translateX: SEGMENT_INSET + indexSV.value * widthSV.value }],
102
+ }));
103
+
104
+ if (options.length === 0) {
105
+ return null;
106
+ }
107
+
108
+ return (
109
+ <View style={[styles.group, disabled && styles.blocked, style]}>
110
+ {label ? (
111
+ <Text style={[styles.title, { color: titleColor }]}>{label}</Text>
112
+ ) : null}
113
+ <View
114
+ accessibilityLabel={label}
115
+ accessibilityRole="radiogroup"
116
+ style={[
117
+ styles.track,
118
+ { backgroundColor, borderColor, borderRadius },
119
+ ]}
120
+ >
121
+ <Animated.View
122
+ pointerEvents="none"
123
+ style={[
124
+ styles.indicator,
125
+ indicatorStyle,
126
+ {
127
+ backgroundColor: selectedBackgroundColor,
128
+ borderRadius: pillRadius,
129
+ },
130
+ ]}
131
+ />
132
+ <View
133
+ onLayout={(event) => {
134
+ const nextWidth = event.nativeEvent.layout.width;
135
+ if (nextWidth <= 0) {
136
+ return;
137
+ }
138
+ applyTrackWidth(nextWidth, options.length);
139
+ if (!laidOut.current) {
140
+ indexSV.value = index < 0 ? 0 : index;
141
+ opacitySV.value = index < 0 ? 0 : 1;
142
+ laidOut.current = true;
143
+ }
144
+ }}
145
+ style={styles.row}
146
+ >
147
+ {options.map((option) => {
148
+ const selected = value === option.value;
149
+ const blocked = disabled || !!option.disabled;
150
+ return (
151
+ <Pressable
152
+ key={option.value}
153
+ accessibilityLabel={option.label}
154
+ accessibilityRole="radio"
155
+ accessibilityState={{
156
+ selected,
157
+ checked: selected,
158
+ disabled: blocked,
159
+ }}
160
+ disabled={blocked}
161
+ onPress={() => onChange(option.value)}
162
+ style={({ pressed }) => [
163
+ styles.segment,
164
+ pressed && !blocked && styles.pressed,
165
+ option.disabled && styles.segmentBlocked,
166
+ ]}
167
+ >
168
+ <Text
169
+ style={[
170
+ styles.label,
171
+ {
172
+ color: selected ? selectedLabelColor : labelColor,
173
+ fontWeight: selected ? "600" : "500",
174
+ },
175
+ ]}
176
+ >
177
+ {option.label}
178
+ </Text>
179
+ </Pressable>
180
+ );
181
+ })}
182
+ </View>
183
+ </View>
184
+ </View>
185
+ );
186
+ }
187
+
188
+ const styles = StyleSheet.create({
189
+ group: {
190
+ width: "100%",
191
+ gap: 8,
192
+ },
193
+ blocked: {
194
+ opacity: 0.4,
195
+ },
196
+ title: {
197
+ fontSize: 13,
198
+ fontWeight: "600",
199
+ paddingHorizontal: 4,
200
+ },
201
+ track: {
202
+ width: "100%",
203
+ borderWidth: 1,
204
+ padding: SEGMENT_INSET,
205
+ overflow: "hidden",
206
+ },
207
+ indicator: {
208
+ position: "absolute",
209
+ top: SEGMENT_INSET,
210
+ bottom: SEGMENT_INSET,
211
+ left: 0,
212
+ },
213
+ row: {
214
+ flexDirection: "row",
215
+ },
216
+ segment: {
217
+ flex: 1,
218
+ minHeight: 36,
219
+ paddingVertical: 8,
220
+ paddingHorizontal: 8,
221
+ alignItems: "center",
222
+ justifyContent: "center",
223
+ },
224
+ pressed: {
225
+ opacity: 0.65,
226
+ },
227
+ segmentBlocked: {
228
+ opacity: 0.4,
229
+ },
230
+ label: {
231
+ fontSize: 15,
232
+ textAlign: "center",
233
+ },
234
+ });
@@ -0,0 +1,6 @@
1
+ export { SegmentedControl } from "./SegmentedControl";
2
+ export {
3
+ SEGMENTED_THEME_ROLES,
4
+ type SegmentedThemeColorProp,
5
+ } from "./segmentThemeRoles";
6
+ export type { SegmentedOption, SegmentedControlProps } from "./types";
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ resolveSegmentIndex,
5
+ segmentIndicatorFrame,
6
+ SEGMENT_INSET,
7
+ } from "./segmentIndicatorFrame.ts";
8
+
9
+ describe("resolveSegmentIndex", () => {
10
+ const options = [{ value: "on" }, { value: "off" }, { value: "auto" }];
11
+
12
+ it("returns the matching option index", () => {
13
+ assert.equal(resolveSegmentIndex("off", options), 1);
14
+ });
15
+
16
+ it("returns -1 when nothing is selected", () => {
17
+ assert.equal(resolveSegmentIndex(null, options), -1);
18
+ assert.equal(resolveSegmentIndex("missing", options), -1);
19
+ });
20
+ });
21
+
22
+ describe("segmentIndicatorFrame", () => {
23
+ it("keeps the same inset before the first segment and after the last", () => {
24
+ const rowWidth = 300;
25
+ const first = segmentIndicatorFrame(0, 3, rowWidth, SEGMENT_INSET);
26
+ const last = segmentIndicatorFrame(2, 3, rowWidth, SEGMENT_INSET);
27
+ assert.deepEqual(first, { x: SEGMENT_INSET, width: 100 });
28
+ assert.deepEqual(last, { x: 203, width: 100 });
29
+ assert.ok(last);
30
+ const trackInnerWidth = rowWidth + SEGMENT_INSET * 2;
31
+ assert.equal(trackInnerWidth - (last.x + last.width), SEGMENT_INSET);
32
+ });
33
+
34
+ it("returns null when there is no segment to highlight", () => {
35
+ assert.equal(segmentIndicatorFrame(-1, 3, 300), null);
36
+ assert.equal(segmentIndicatorFrame(0, 0, 300), null);
37
+ assert.equal(segmentIndicatorFrame(0, 2, 0), null);
38
+ });
39
+ });
@@ -0,0 +1,31 @@
1
+ /** Gap between the track edge and the sliding selection fill. */
2
+ export const SEGMENT_INSET = 3;
3
+
4
+ export function resolveSegmentIndex<T extends string>(
5
+ value: T | null,
6
+ options: readonly { value: T }[],
7
+ ): number {
8
+ if (value == null) {
9
+ return -1;
10
+ }
11
+ return options.findIndex((option) => option.value === value);
12
+ }
13
+
14
+ /**
15
+ * Equal-width selection fill for a segment row.
16
+ * `segmentRowWidth` is the row already inset from the track edge.
17
+ * `inset` is that same gap, applied on the leading side (and, by symmetry, the trailing side).
18
+ * `index` 0 is the leading segment. Returns null when there is nothing to draw.
19
+ */
20
+ export function segmentIndicatorFrame(
21
+ index: number,
22
+ count: number,
23
+ segmentRowWidth: number,
24
+ inset: number = SEGMENT_INSET,
25
+ ): { x: number; width: number } | null {
26
+ if (index < 0 || count < 1 || segmentRowWidth <= 0) {
27
+ return null;
28
+ }
29
+ const width = segmentRowWidth / count;
30
+ return { x: inset + index * width, width };
31
+ }
@@ -0,0 +1,17 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { SEGMENTED_THEME_ROLES } from "./segmentThemeRoles.ts";
4
+
5
+ describe("SEGMENTED_THEME_ROLES", () => {
6
+ it("maps colour props onto existing Field and radio roles", () => {
7
+ assert.equal(SEGMENTED_THEME_ROLES.backgroundColor, "fieldBackground");
8
+ assert.equal(SEGMENTED_THEME_ROLES.borderColor, "fieldBorder");
9
+ assert.equal(
10
+ SEGMENTED_THEME_ROLES.selectedBackgroundColor,
11
+ "fieldRadioSelected",
12
+ );
13
+ assert.equal(SEGMENTED_THEME_ROLES.labelColor, "fieldText");
14
+ assert.equal(SEGMENTED_THEME_ROLES.selectedLabelColor, "fieldBackground");
15
+ assert.equal(SEGMENTED_THEME_ROLES.titleColor, "fieldLabel");
16
+ });
17
+ });
@@ -0,0 +1,10 @@
1
+ export const SEGMENTED_THEME_ROLES = {
2
+ backgroundColor: "fieldBackground",
3
+ borderColor: "fieldBorder",
4
+ selectedBackgroundColor: "fieldRadioSelected",
5
+ labelColor: "fieldText",
6
+ selectedLabelColor: "fieldBackground",
7
+ titleColor: "fieldLabel",
8
+ } as const;
9
+
10
+ export type SegmentedThemeColorProp = keyof typeof SEGMENTED_THEME_ROLES;
@@ -0,0 +1,24 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+ import type { ButtonRadius } from "../Button/buttonRadius";
3
+
4
+ export type SegmentedOption<T extends string = string> = {
5
+ value: T;
6
+ label: string;
7
+ disabled?: boolean;
8
+ };
9
+
10
+ export type SegmentedControlProps<T extends string = string> = {
11
+ value: T | null;
12
+ onChange: (value: T) => void;
13
+ options: SegmentedOption<T>[];
14
+ label?: string;
15
+ radius?: ButtonRadius;
16
+ disabled?: boolean;
17
+ backgroundColor?: string;
18
+ borderColor?: string;
19
+ selectedBackgroundColor?: string;
20
+ labelColor?: string;
21
+ selectedLabelColor?: string;
22
+ titleColor?: string;
23
+ style?: StyleProp<ViewStyle>;
24
+ };