@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.
- package/README.md +3 -1
- package/dist/components/Select/Select.d.ts +3 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.js +81 -19
- package/dist/components/Select/Select.js.map +1 -1
- package/dist/components/Select/SelectCloseButton.d.ts +9 -2
- package/dist/components/Select/SelectCloseButton.d.ts.map +1 -1
- package/dist/components/Select/SelectCloseButton.js +14 -6
- package/dist/components/Select/SelectCloseButton.js.map +1 -1
- package/dist/components/Select/index.d.ts +3 -3
- package/dist/components/Select/index.d.ts.map +1 -1
- package/dist/components/Select/index.js +2 -1
- package/dist/components/Select/index.js.map +1 -1
- package/dist/components/Select/presentSelectSheet.d.ts +7 -0
- package/dist/components/Select/presentSelectSheet.d.ts.map +1 -0
- package/dist/components/Select/presentSelectSheet.js +14 -0
- package/dist/components/Select/presentSelectSheet.js.map +1 -0
- package/dist/components/Select/resolveSelectDetentIndex.d.ts +2 -0
- package/dist/components/Select/resolveSelectDetentIndex.d.ts.map +1 -1
- package/dist/components/Select/resolveSelectDetentIndex.js +5 -0
- package/dist/components/Select/resolveSelectDetentIndex.js.map +1 -1
- package/dist/components/Select/selectThemeRoles.d.ts +1 -1
- package/dist/components/Select/selectThemeRoles.js +1 -1
- package/dist/components/Select/selectThemeRoles.js.map +1 -1
- package/dist/components/Select/trueSheetReanimated.d.ts +13 -0
- package/dist/components/Select/trueSheetReanimated.d.ts.map +1 -0
- package/dist/components/Select/trueSheetReanimated.js +14 -0
- package/dist/components/Select/trueSheetReanimated.js.map +1 -0
- package/dist/components/Select/types.d.ts +3 -1
- package/dist/components/Select/types.d.ts.map +1 -1
- package/dist/components/Slider/Slider.d.ts +3 -0
- package/dist/components/Slider/Slider.d.ts.map +1 -0
- package/dist/components/Slider/Slider.js +46 -0
- package/dist/components/Slider/Slider.js.map +1 -0
- package/dist/components/Slider/index.d.ts +4 -0
- package/dist/components/Slider/index.d.ts.map +1 -0
- package/dist/components/Slider/index.js +8 -0
- package/dist/components/Slider/index.js.map +1 -0
- package/dist/components/Slider/sliderThemeRoles.d.ts +8 -0
- package/dist/components/Slider/sliderThemeRoles.d.ts.map +1 -0
- package/dist/components/Slider/sliderThemeRoles.js +10 -0
- package/dist/components/Slider/sliderThemeRoles.js.map +1 -0
- package/dist/components/Slider/types.d.ts +18 -0
- package/dist/components/Slider/types.d.ts.map +1 -0
- package/dist/components/Slider/types.js +3 -0
- package/dist/components/Slider/types.js.map +1 -0
- package/dist/components/index.d.ts +2 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +5 -1
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/docs/components/select.md +32 -10
- package/docs/components/slider.md +65 -0
- package/docs/superpowers/plans/2026-09-25-select.md +26 -8
- package/docs/superpowers/plans/2026-09-25-slider.md +35 -0
- package/docs/superpowers/specs/2026-09-25-select-design.md +39 -67
- package/docs/superpowers/specs/2026-09-25-slider-design.md +117 -0
- package/package.json +12 -4
- package/src/components/Select/Select.tsx +188 -75
- package/src/components/Select/SelectCloseButton.tsx +32 -7
- package/src/components/Select/index.ts +6 -2
- package/src/components/Select/resolveSelectDetentIndex.test.ts +15 -1
- package/src/components/Select/resolveSelectDetentIndex.ts +5 -0
- package/src/components/Select/selectThemeRoles.test.ts +16 -0
- package/src/components/Select/selectThemeRoles.ts +1 -1
- package/src/components/Select/types.ts +3 -1
- package/src/components/Slider/Slider.tsx +95 -0
- package/src/components/Slider/index.ts +6 -0
- package/src/components/Slider/sliderThemeRoles.test.ts +12 -0
- package/src/components/Slider/sliderThemeRoles.ts +8 -0
- package/src/components/Slider/types.ts +18 -0
- package/src/components/index.ts +8 -1
- 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.
|
|
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
|
-
"@
|
|
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-
|
|
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
|
-
"@
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
121
|
+
modalRef.current?.dismiss();
|
|
84
122
|
}, []);
|
|
85
123
|
|
|
86
124
|
const present = useCallback(() => {
|
|
87
125
|
if (!editable) return;
|
|
88
|
-
|
|
89
|
-
}, [editable
|
|
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 ??
|
|
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
|
-
<
|
|
149
|
-
ref={
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
371
|
+
fontSize: 15,
|
|
261
372
|
paddingHorizontal: 16,
|
|
262
|
-
paddingVertical:
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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 {
|
|
9
|
+
export {
|
|
10
|
+
SELECT_THEME_ROLES,
|
|
11
|
+
type SelectThemeColorProp,
|
|
12
|
+
} from "./selectThemeRoles";
|
|
9
13
|
export type {
|
|
10
|
-
SelectCloseButton as
|
|
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(
|
|
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: "
|
|
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
|
-
/**
|
|
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;
|