@studio-piot/native-ui 0.1.0 → 0.2.2

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 (76) hide show
  1. package/README.md +3 -1
  2. package/dist/components/Select/Select.d.ts +3 -1
  3. package/dist/components/Select/Select.d.ts.map +1 -1
  4. package/dist/components/Select/Select.js +81 -19
  5. package/dist/components/Select/Select.js.map +1 -1
  6. package/dist/components/Select/SelectCloseButton.d.ts +9 -2
  7. package/dist/components/Select/SelectCloseButton.d.ts.map +1 -1
  8. package/dist/components/Select/SelectCloseButton.js +14 -6
  9. package/dist/components/Select/SelectCloseButton.js.map +1 -1
  10. package/dist/components/Select/index.d.ts +3 -3
  11. package/dist/components/Select/index.d.ts.map +1 -1
  12. package/dist/components/Select/index.js +2 -1
  13. package/dist/components/Select/index.js.map +1 -1
  14. package/dist/components/Select/presentSelectSheet.d.ts +7 -0
  15. package/dist/components/Select/presentSelectSheet.d.ts.map +1 -0
  16. package/dist/components/Select/presentSelectSheet.js +14 -0
  17. package/dist/components/Select/presentSelectSheet.js.map +1 -0
  18. package/dist/components/Select/resolveSelectDetentIndex.d.ts +2 -0
  19. package/dist/components/Select/resolveSelectDetentIndex.d.ts.map +1 -1
  20. package/dist/components/Select/resolveSelectDetentIndex.js +5 -0
  21. package/dist/components/Select/resolveSelectDetentIndex.js.map +1 -1
  22. package/dist/components/Select/selectThemeRoles.d.ts +1 -1
  23. package/dist/components/Select/selectThemeRoles.js +1 -1
  24. package/dist/components/Select/selectThemeRoles.js.map +1 -1
  25. package/dist/components/Select/trueSheetReanimated.d.ts +13 -0
  26. package/dist/components/Select/trueSheetReanimated.d.ts.map +1 -0
  27. package/dist/components/Select/trueSheetReanimated.js +14 -0
  28. package/dist/components/Select/trueSheetReanimated.js.map +1 -0
  29. package/dist/components/Select/types.d.ts +3 -1
  30. package/dist/components/Select/types.d.ts.map +1 -1
  31. package/dist/components/Slider/Slider.d.ts +3 -0
  32. package/dist/components/Slider/Slider.d.ts.map +1 -0
  33. package/dist/components/Slider/Slider.js +46 -0
  34. package/dist/components/Slider/Slider.js.map +1 -0
  35. package/dist/components/Slider/index.d.ts +4 -0
  36. package/dist/components/Slider/index.d.ts.map +1 -0
  37. package/dist/components/Slider/index.js +8 -0
  38. package/dist/components/Slider/index.js.map +1 -0
  39. package/dist/components/Slider/sliderThemeRoles.d.ts +8 -0
  40. package/dist/components/Slider/sliderThemeRoles.d.ts.map +1 -0
  41. package/dist/components/Slider/sliderThemeRoles.js +10 -0
  42. package/dist/components/Slider/sliderThemeRoles.js.map +1 -0
  43. package/dist/components/Slider/types.d.ts +18 -0
  44. package/dist/components/Slider/types.d.ts.map +1 -0
  45. package/dist/components/Slider/types.js +3 -0
  46. package/dist/components/Slider/types.js.map +1 -0
  47. package/dist/components/index.d.ts +2 -1
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/index.js +5 -1
  50. package/dist/components/index.js.map +1 -1
  51. package/dist/index.d.ts +3 -2
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -2
  54. package/dist/index.js.map +1 -1
  55. package/docs/components/select.md +32 -10
  56. package/docs/components/slider.md +65 -0
  57. package/docs/superpowers/plans/2026-09-25-select.md +26 -8
  58. package/docs/superpowers/plans/2026-09-25-slider.md +35 -0
  59. package/docs/superpowers/specs/2026-09-25-select-design.md +39 -67
  60. package/docs/superpowers/specs/2026-09-25-slider-design.md +117 -0
  61. package/package.json +12 -4
  62. package/src/components/Select/Select.tsx +188 -75
  63. package/src/components/Select/SelectCloseButton.tsx +32 -7
  64. package/src/components/Select/index.ts +6 -2
  65. package/src/components/Select/resolveSelectDetentIndex.test.ts +15 -1
  66. package/src/components/Select/resolveSelectDetentIndex.ts +5 -0
  67. package/src/components/Select/selectThemeRoles.test.ts +16 -0
  68. package/src/components/Select/selectThemeRoles.ts +1 -1
  69. package/src/components/Select/types.ts +3 -1
  70. package/src/components/Slider/Slider.tsx +95 -0
  71. package/src/components/Slider/index.ts +6 -0
  72. package/src/components/Slider/sliderThemeRoles.test.ts +12 -0
  73. package/src/components/Slider/sliderThemeRoles.ts +8 -0
  74. package/src/components/Slider/types.ts +18 -0
  75. package/src/components/index.ts +8 -1
  76. package/src/index.ts +9 -2
@@ -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.1.0",
3
+ "version": "0.2.2",
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": {
@@ -50,11 +50,15 @@
50
50
  "access": "public"
51
51
  },
52
52
  "peerDependencies": {
53
- "@lodev09/react-native-true-sheet": ">=3.7.0",
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",
57
+ "react-native-gesture-handler": "^2.32.0",
56
58
  "react-native-keyboard-controller": ">=1.14.0",
57
- "react-native-safe-area-context": ">=4.0.0"
59
+ "react-native-reanimated": "^4.5.1",
60
+ "react-native-safe-area-context": ">=4.0.0",
61
+ "react-native-screens": ">=4.0.0"
58
62
  },
59
63
  "scripts": {
60
64
  "test": "node --experimental-strip-types --test 'src/**/*.test.ts'",
@@ -65,11 +69,15 @@
65
69
  "prepublishOnly": "npm run build"
66
70
  },
67
71
  "devDependencies": {
68
- "@lodev09/react-native-true-sheet": "3.11.15",
72
+ "@gorhom/bottom-sheet": "^5.2.14",
73
+ "@react-native-community/slider": "^5.2.0",
69
74
  "@types/react": "^19.2.2",
70
75
  "react-native": "^0.86.3",
76
+ "react-native-gesture-handler": "^2.32.0",
71
77
  "react-native-keyboard-controller": "1.21.9",
78
+ "react-native-reanimated": "^4.5.1",
72
79
  "react-native-safe-area-context": "~5.7.0",
80
+ "react-native-screens": "~4.26.0",
73
81
  "typescript": "^5.9.3"
74
82
  }
75
83
  }
@@ -1,17 +1,46 @@
1
- import { useCallback, useRef, type ReactNode } from "react";
2
- import { Pressable, StyleSheet, Text, View } from "react-native";
3
- import { TrueSheet } from "@lodev09/react-native-true-sheet";
1
+ import {
2
+ useCallback,
3
+ useMemo,
4
+ useRef,
5
+ type ElementRef,
6
+ type ReactNode,
7
+ } from "react";
8
+ import {
9
+ Platform,
10
+ Pressable,
11
+ StyleSheet,
12
+ Text,
13
+ useWindowDimensions,
14
+ View,
15
+ } from "react-native";
16
+ import {
17
+ BottomSheetBackdrop,
18
+ BottomSheetHandle,
19
+ BottomSheetModal,
20
+ BottomSheetScrollView,
21
+ type BottomSheetBackdropProps,
22
+ type BottomSheetHandleProps,
23
+ } from "@gorhom/bottom-sheet";
24
+ import { FullWindowOverlay } from "react-native-screens";
4
25
  import { buttonRadiusValue } from "../Button/buttonRadius";
5
26
  import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
6
- import {
7
- DEFAULT_SELECT_DETENTS,
8
- DEFAULT_SELECT_INITIAL_DETENT,
9
- resolveSelectDetentIndex,
10
- } from "./resolveSelectDetentIndex";
27
+ import { useThemeColor } from "../../theme/useThemeColor";
28
+ import { DEFAULT_SELECT_DETENTS } from "./resolveSelectDetentIndex";
11
29
  import { SelectCloseButton } from "./SelectCloseButton";
12
30
  import { SELECT_THEME_ROLES } from "./selectThemeRoles";
13
31
  import type { SelectOption, SelectProps } from "./types";
14
32
 
33
+ const BACKDROP_OPACITY = 0.72;
34
+ const SHEET_TOP_RADIUS = 20;
35
+
36
+ /** Renders the sheet above React Navigation native modals (iOS). */
37
+ function SheetContainer({ children }: { children?: ReactNode }) {
38
+ if (Platform.OS === "ios") {
39
+ return <FullWindowOverlay>{children}</FullWindowOverlay>;
40
+ }
41
+ return <>{children}</>;
42
+ }
43
+
15
44
  export function Select({
16
45
  value,
17
46
  onChange,
@@ -22,8 +51,9 @@ export function Select({
22
51
  error,
23
52
  sheetTitle,
24
53
  closeButton,
54
+ /** Max sheet height as a fraction of the window (largest value wins). Default `[0.25, 0.5, 0.75]` → 75%. */
25
55
  detents = [...DEFAULT_SELECT_DETENTS],
26
- initialDetent = DEFAULT_SELECT_INITIAL_DETENT,
56
+ initialDetent: _initialDetent,
27
57
  radius = "xl",
28
58
  editable = true,
29
59
  backgroundColor: backgroundColorProp,
@@ -36,7 +66,8 @@ export function Select({
36
66
  selectedColor: selectedColorProp,
37
67
  style,
38
68
  }: SelectProps) {
39
- const sheetRef = useRef<TrueSheet>(null);
69
+ const modalRef = useRef<ElementRef<typeof BottomSheetModal>>(null);
70
+ const { height: windowHeight } = useWindowDimensions();
40
71
 
41
72
  const backgroundColor = useResolvedFieldColor(
42
73
  backgroundColorProp,
@@ -70,6 +101,9 @@ export function Select({
70
101
  selectedColorProp,
71
102
  SELECT_THEME_ROLES.selectedColor,
72
103
  );
104
+ // Resolved on the trigger tree (has ThemeProvider), then passed into the portal.
105
+ const closeDiscColor = useThemeColor("text");
106
+ const closeIconColor = useThemeColor("background");
73
107
 
74
108
  const hasError = Boolean(error);
75
109
  const selected = options.find((option) => option.value === value);
@@ -77,21 +111,83 @@ export function Select({
77
111
  const isPlaceholder = selected == null;
78
112
  const borderRadius = buttonRadiusValue(radius);
79
113
  const title = sheetTitle ?? label ?? "Select";
80
- const presentIndex = resolveSelectDetentIndex(detents, initialDetent);
114
+
115
+ const maxDynamicContentSize = useMemo(() => {
116
+ const maxDetent = Math.max(...detents, 0.75);
117
+ return Math.round(windowHeight * maxDetent);
118
+ }, [detents, windowHeight]);
81
119
 
82
120
  const dismiss = useCallback(() => {
83
- void sheetRef.current?.dismiss();
121
+ modalRef.current?.dismiss();
84
122
  }, []);
85
123
 
86
124
  const present = useCallback(() => {
87
125
  if (!editable) return;
88
- void sheetRef.current?.present(presentIndex);
89
- }, [editable, presentIndex]);
126
+ modalRef.current?.present();
127
+ }, [editable]);
128
+
129
+ const renderBackdrop = useCallback(
130
+ (props: BottomSheetBackdropProps) => (
131
+ <BottomSheetBackdrop
132
+ {...props}
133
+ appearsOnIndex={0}
134
+ disappearsOnIndex={-1}
135
+ opacity={BACKDROP_OPACITY}
136
+ pressBehavior="close"
137
+ />
138
+ ),
139
+ [],
140
+ );
90
141
 
91
142
  const resolvedClose: ReactNode =
92
143
  typeof closeButton === "function"
93
144
  ? closeButton(dismiss)
94
- : (closeButton ?? <SelectCloseButton onPress={dismiss} />);
145
+ : (closeButton ?? (
146
+ <SelectCloseButton
147
+ backgroundColor={closeDiscColor}
148
+ iconColor={closeIconColor}
149
+ onPress={dismiss}
150
+ />
151
+ ));
152
+
153
+ // Header lives in the handle so it stays fixed; only options scroll.
154
+ const renderHandle = useCallback(
155
+ (props: BottomSheetHandleProps) => (
156
+ <View
157
+ style={[
158
+ styles.handleRoot,
159
+ {
160
+ backgroundColor,
161
+ borderTopLeftRadius: SHEET_TOP_RADIUS,
162
+ borderTopRightRadius: SHEET_TOP_RADIUS,
163
+ },
164
+ ]}
165
+ >
166
+ <BottomSheetHandle
167
+ {...props}
168
+ indicatorStyle={{
169
+ backgroundColor: placeholderColor,
170
+ opacity: 0.9,
171
+ }}
172
+ />
173
+ <View style={styles.header}>
174
+ <Text style={[styles.sheetTitle, { color: textColor, flex: 1 }]}>
175
+ {title}
176
+ </Text>
177
+ {resolvedClose}
178
+ </View>
179
+ <View style={[styles.headerDivider, { backgroundColor: borderColor }]} />
180
+ </View>
181
+ ),
182
+ [
183
+ backgroundColor,
184
+ borderColor,
185
+ placeholderColor,
186
+ resolvedClose,
187
+ textColor,
188
+ title,
189
+ ],
190
+ );
95
191
 
96
192
  const onSelect = (option: SelectOption) => {
97
193
  if (option.disabled) return;
@@ -145,68 +241,70 @@ export function Select({
145
241
  </Text>
146
242
  ) : null}
147
243
 
148
- <TrueSheet
149
- ref={sheetRef}
150
- backgroundColor={backgroundColor}
151
- cornerRadius={buttonRadiusValue("xl")}
152
- detents={detents}
153
- grabber
154
- scrollable
244
+ <BottomSheetModal
245
+ ref={modalRef}
246
+ backdropComponent={renderBackdrop}
247
+ backgroundStyle={{
248
+ backgroundColor,
249
+ borderTopLeftRadius: SHEET_TOP_RADIUS,
250
+ borderTopRightRadius: SHEET_TOP_RADIUS,
251
+ }}
252
+ containerComponent={SheetContainer}
253
+ enableDynamicSizing
254
+ enablePanDownToClose
255
+ handleComponent={renderHandle}
256
+ handleStyle={styles.handleShell}
257
+ maxDynamicContentSize={maxDynamicContentSize}
258
+ stackBehavior="push"
155
259
  >
156
- <View style={styles.sheetHeader}>
157
- <Text
158
- accessibilityRole="header"
159
- numberOfLines={1}
160
- style={[styles.sheetTitle, { color: textColor, flex: 1 }]}
161
- >
162
- {title}
163
- </Text>
164
- {resolvedClose}
165
- </View>
166
- {options.length === 0 ? (
167
- <Text style={[styles.empty, { color: placeholderColor }]}>
168
- No options
169
- </Text>
170
- ) : (
171
- options.map((item) => {
172
- const isSelected = item.value === value;
173
- return (
174
- <Pressable
175
- key={item.value}
176
- accessibilityRole="button"
177
- accessibilityState={{
178
- selected: isSelected,
179
- disabled: !!item.disabled,
180
- }}
181
- disabled={item.disabled}
182
- onPress={() => onSelect(item)}
183
- style={[styles.option, item.disabled && styles.blocked]}
184
- >
185
- <Text
260
+ <BottomSheetScrollView
261
+ contentContainerStyle={styles.sheetContent}
262
+ enableFooterMarginAdjustment={false}
263
+ >
264
+ {options.length === 0 ? (
265
+ <Text style={[styles.empty, { color: placeholderColor }]}>
266
+ No options
267
+ </Text>
268
+ ) : (
269
+ options.map((option) => {
270
+ const isSelected = option.value === value;
271
+ return (
272
+ <Pressable
273
+ key={option.value}
274
+ accessibilityRole="button"
275
+ accessibilityState={{
276
+ selected: isSelected,
277
+ disabled: !!option.disabled,
278
+ }}
279
+ disabled={option.disabled}
280
+ onPress={() => onSelect(option)}
186
281
  style={[
187
- styles.optionLabel,
188
- {
189
- color: isSelected ? selectedColor : textColor,
190
- flex: 1,
191
- },
282
+ styles.option,
283
+ option.disabled && styles.optionBlocked,
192
284
  ]}
193
285
  >
194
- {item.label}
195
- </Text>
196
- {isSelected ? (
197
286
  <Text
198
- accessibilityElementsHidden
199
- importantForAccessibility="no"
200
- style={[styles.check, { color: selectedColor }]}
287
+ style={[
288
+ styles.optionLabel,
289
+ {
290
+ color: isSelected ? selectedColor : textColor,
291
+ flex: 1,
292
+ },
293
+ ]}
201
294
  >
202
- ✓
295
+ {option.label}
203
296
  </Text>
204
- ) : null}
205
- </Pressable>
206
- );
207
- })
208
- )}
209
- </TrueSheet>
297
+ {isSelected ? (
298
+ <Text style={[styles.check, { color: selectedColor }]}>
299
+ ✓
300
+ </Text>
301
+ ) : null}
302
+ </Pressable>
303
+ );
304
+ })
305
+ )}
306
+ </BottomSheetScrollView>
307
+ </BottomSheetModal>
210
308
  </View>
211
309
  );
212
310
  }
@@ -225,6 +323,7 @@ const styles = StyleSheet.create({
225
323
  },
226
324
  trigger: {
227
325
  minHeight: 48,
326
+ width: "100%",
228
327
  borderWidth: 1,
229
328
  paddingHorizontal: 14,
230
329
  paddingVertical: 12,
@@ -244,23 +343,34 @@ const styles = StyleSheet.create({
244
343
  helper: {
245
344
  fontSize: 13,
246
345
  },
247
- sheetHeader: {
346
+ handleShell: {
347
+ paddingBottom: 0,
348
+ },
349
+ handleRoot: {
350
+ overflow: "hidden",
351
+ },
352
+ sheetContent: {
353
+ paddingBottom: 24,
354
+ },
355
+ header: {
248
356
  flexDirection: "row",
249
357
  alignItems: "center",
250
358
  gap: 12,
251
359
  paddingHorizontal: 16,
252
- paddingTop: 8,
360
+ paddingTop: 4,
253
361
  paddingBottom: 12,
254
362
  },
363
+ headerDivider: {
364
+ height: StyleSheet.hairlineWidth,
365
+ },
255
366
  sheetTitle: {
256
367
  fontSize: 17,
257
368
  fontWeight: "600",
258
369
  },
259
370
  empty: {
260
- fontSize: 17,
371
+ fontSize: 15,
261
372
  paddingHorizontal: 16,
262
- paddingVertical: 24,
263
- textAlign: "center",
373
+ paddingVertical: 20,
264
374
  },
265
375
  option: {
266
376
  minHeight: 52,
@@ -269,6 +379,9 @@ const styles = StyleSheet.create({
269
379
  alignItems: "center",
270
380
  gap: 12,
271
381
  },
382
+ optionBlocked: {
383
+ opacity: 0.4,
384
+ },
272
385
  optionLabel: {
273
386
  fontSize: 17,
274
387
  },
@@ -1,19 +1,44 @@
1
1
  import { Pressable, StyleSheet, Text } from "react-native";
2
- import { useThemeColor } from "../../theme/useThemeColor";
3
2
 
4
- export function SelectCloseButton({ onPress }: { onPress: () => void }) {
5
- const text = useThemeColor("text");
3
+ type SelectCloseButtonProps = {
4
+ onPress: () => void;
5
+ /** Disc fill. Select passes theme colours so this stays portal-safe. */
6
+ backgroundColor?: string;
7
+ /** Cross colour. */
8
+ iconColor?: string;
9
+ };
6
10
 
11
+ /** Default Select sheet close: filled disc + contrasting cross. */
12
+ export function SelectCloseButton({
13
+ onPress,
14
+ backgroundColor = "#111111",
15
+ iconColor = "#FFFFFF",
16
+ }: SelectCloseButtonProps) {
7
17
  return (
8
- <Pressable accessibilityRole="button" hitSlop={8} onPress={onPress}>
9
- <Text style={[styles.label, { color: text }]}>Cancel</Text>
18
+ <Pressable
19
+ accessibilityLabel="Close"
20
+ accessibilityRole="button"
21
+ hitSlop={8}
22
+ onPress={onPress}
23
+ style={[styles.disc, { backgroundColor }]}
24
+ >
25
+ <Text style={[styles.discX, { color: iconColor }]}>×</Text>
10
26
  </Pressable>
11
27
  );
12
28
  }
13
29
 
14
30
  const styles = StyleSheet.create({
15
- label: {
16
- fontSize: 17,
31
+ disc: {
32
+ width: 28,
33
+ height: 28,
34
+ borderRadius: 14,
35
+ alignItems: "center",
36
+ justifyContent: "center",
37
+ },
38
+ discX: {
39
+ fontSize: 20,
17
40
  fontWeight: "600",
41
+ lineHeight: 22,
42
+ marginTop: -1,
18
43
  },
19
44
  });
@@ -3,11 +3,15 @@ export { SelectCloseButton } from "./SelectCloseButton";
3
3
  export {
4
4
  DEFAULT_SELECT_DETENTS,
5
5
  DEFAULT_SELECT_INITIAL_DETENT,
6
+ detentsToSnapPoints,
6
7
  resolveSelectDetentIndex,
7
8
  } from "./resolveSelectDetentIndex";
8
- export { SELECT_THEME_ROLES, type SelectThemeColorProp } from "./selectThemeRoles";
9
+ export {
10
+ SELECT_THEME_ROLES,
11
+ type SelectThemeColorProp,
12
+ } from "./selectThemeRoles";
9
13
  export type {
10
- SelectCloseButton as SelectCloseButtonProp,
14
+ SelectCloseButton as SelectCloseButtonSlot,
11
15
  SelectOption,
12
16
  SelectProps,
13
17
  } from "./types";
@@ -3,13 +3,17 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  DEFAULT_SELECT_DETENTS,
5
5
  DEFAULT_SELECT_INITIAL_DETENT,
6
+ detentsToSnapPoints,
6
7
  resolveSelectDetentIndex,
7
8
  } from "./resolveSelectDetentIndex.ts";
8
9
 
9
10
  describe("resolveSelectDetentIndex", () => {
10
11
  it("returns the index of the initial detent", () => {
11
12
  assert.equal(
12
- resolveSelectDetentIndex(DEFAULT_SELECT_DETENTS, DEFAULT_SELECT_INITIAL_DETENT),
13
+ resolveSelectDetentIndex(
14
+ DEFAULT_SELECT_DETENTS,
15
+ DEFAULT_SELECT_INITIAL_DETENT,
16
+ ),
13
17
  1,
14
18
  );
15
19
  });
@@ -18,3 +22,13 @@ describe("resolveSelectDetentIndex", () => {
18
22
  assert.equal(resolveSelectDetentIndex([0.25, 0.5, 0.75], 0.9), 0);
19
23
  });
20
24
  });
25
+
26
+ describe("detentsToSnapPoints", () => {
27
+ it("maps fractions to percentage snap points", () => {
28
+ assert.deepEqual(detentsToSnapPoints(DEFAULT_SELECT_DETENTS), [
29
+ "25%",
30
+ "50%",
31
+ "75%",
32
+ ]);
33
+ });
34
+ });
@@ -14,3 +14,8 @@ export function resolveSelectDetentIndex(
14
14
  const index = detents.indexOf(initial);
15
15
  return index >= 0 ? index : 0;
16
16
  }
17
+
18
+ /** Convert detent fractions (0–1) to Gorhom snap-point percentage strings. */
19
+ export function detentsToSnapPoints(detents: readonly number[]): string[] {
20
+ return detents.map((detent) => `${Math.round(detent * 100)}%`);
21
+ }
@@ -0,0 +1,16 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { SELECT_THEME_ROLES } from "./selectThemeRoles.ts";
4
+
5
+ describe("SELECT_THEME_ROLES", () => {
6
+ it("maps colour props to Field roles plus fieldSelectSelected", () => {
7
+ assert.equal(SELECT_THEME_ROLES.backgroundColor, "fieldBackground");
8
+ assert.equal(SELECT_THEME_ROLES.textColor, "fieldText");
9
+ assert.equal(SELECT_THEME_ROLES.placeholderColor, "fieldPlaceholder");
10
+ assert.equal(SELECT_THEME_ROLES.labelColor, "fieldLabel");
11
+ assert.equal(SELECT_THEME_ROLES.descriptionColor, "fieldDescription");
12
+ assert.equal(SELECT_THEME_ROLES.borderColor, "fieldBorder");
13
+ assert.equal(SELECT_THEME_ROLES.errorColor, "fieldError");
14
+ assert.equal(SELECT_THEME_ROLES.selectedColor, "fieldSelectSelected");
15
+ });
16
+ });
@@ -6,7 +6,7 @@ export const SELECT_THEME_ROLES = {
6
6
  descriptionColor: "fieldDescription",
7
7
  borderColor: "fieldBorder",
8
8
  errorColor: "fieldError",
9
- selectedColor: "fieldRadioSelected",
9
+ selectedColor: "fieldSelectSelected",
10
10
  } as const;
11
11
 
12
12
  export type SelectThemeColorProp = keyof typeof SELECT_THEME_ROLES;
@@ -21,9 +21,11 @@ export type SelectProps = {
21
21
  description?: string;
22
22
  error?: string;
23
23
  sheetTitle?: string;
24
- /** Plain Cancel by default. Pass `(dismiss) => <Glass onPress={dismiss} />` for app chrome. */
24
+ /** DiscClose by default. Pass `(dismiss) => <YourClose onPress={dismiss} />` to override. */
25
25
  closeButton?: SelectCloseButton;
26
+ /** Detent fractions 0–1. Largest value caps sheet height (content-sized up to that max). Default `[0.25, 0.5, 0.75]`. */
26
27
  detents?: number[];
28
+ /** @deprecated Ignored — sheet opens at content height (capped by `detents`). */
27
29
  initialDetent?: number;
28
30
  radius?: ButtonRadius;
29
31
  editable?: boolean;