@studio-piot/native-ui 0.2.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 (60) 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 +72 -28
  5. package/dist/components/Select/Select.js.map +1 -1
  6. package/dist/components/Select/SelectCloseButton.d.ts +9 -3
  7. package/dist/components/Select/SelectCloseButton.d.ts.map +1 -1
  8. package/dist/components/Select/SelectCloseButton.js +2 -5
  9. package/dist/components/Select/SelectCloseButton.js.map +1 -1
  10. package/dist/components/Select/presentSelectSheet.d.ts +7 -0
  11. package/dist/components/Select/presentSelectSheet.d.ts.map +1 -0
  12. package/dist/components/Select/presentSelectSheet.js +14 -0
  13. package/dist/components/Select/presentSelectSheet.js.map +1 -0
  14. package/dist/components/Select/selectThemeRoles.d.ts +1 -1
  15. package/dist/components/Select/selectThemeRoles.js +1 -1
  16. package/dist/components/Select/selectThemeRoles.js.map +1 -1
  17. package/dist/components/Select/types.d.ts +2 -2
  18. package/dist/components/Select/types.d.ts.map +1 -1
  19. package/dist/components/Slider/Slider.d.ts +3 -0
  20. package/dist/components/Slider/Slider.d.ts.map +1 -0
  21. package/dist/components/Slider/Slider.js +46 -0
  22. package/dist/components/Slider/Slider.js.map +1 -0
  23. package/dist/components/Slider/index.d.ts +4 -0
  24. package/dist/components/Slider/index.d.ts.map +1 -0
  25. package/dist/components/Slider/index.js +8 -0
  26. package/dist/components/Slider/index.js.map +1 -0
  27. package/dist/components/Slider/sliderThemeRoles.d.ts +8 -0
  28. package/dist/components/Slider/sliderThemeRoles.d.ts.map +1 -0
  29. package/dist/components/Slider/sliderThemeRoles.js +10 -0
  30. package/dist/components/Slider/sliderThemeRoles.js.map +1 -0
  31. package/dist/components/Slider/types.d.ts +18 -0
  32. package/dist/components/Slider/types.d.ts.map +1 -0
  33. package/dist/components/Slider/types.js +3 -0
  34. package/dist/components/Slider/types.js.map +1 -0
  35. package/dist/components/index.d.ts +1 -0
  36. package/dist/components/index.d.ts.map +1 -1
  37. package/dist/components/index.js +4 -1
  38. package/dist/components/index.js.map +1 -1
  39. package/dist/index.d.ts +2 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +5 -2
  42. package/dist/index.js.map +1 -1
  43. package/docs/components/select.md +11 -6
  44. package/docs/components/slider.md +65 -0
  45. package/docs/superpowers/plans/2026-09-25-slider.md +35 -0
  46. package/docs/superpowers/specs/2026-09-25-select-design.md +3 -3
  47. package/docs/superpowers/specs/2026-09-25-slider-design.md +117 -0
  48. package/package.json +6 -2
  49. package/src/components/Select/Select.tsx +133 -64
  50. package/src/components/Select/SelectCloseButton.tsx +15 -7
  51. package/src/components/Select/selectThemeRoles.test.ts +2 -2
  52. package/src/components/Select/selectThemeRoles.ts +1 -1
  53. package/src/components/Select/types.ts +2 -2
  54. package/src/components/Slider/Slider.tsx +95 -0
  55. package/src/components/Slider/index.ts +6 -0
  56. package/src/components/Slider/sliderThemeRoles.test.ts +12 -0
  57. package/src/components/Slider/sliderThemeRoles.ts +8 -0
  58. package/src/components/Slider/types.ts +18 -0
  59. package/src/components/index.ts +6 -0
  60. package/src/index.ts +7 -1
package/dist/index.js CHANGED
@@ -36,8 +36,8 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.components = exports.resolveSelectDetentIndex = exports.detentsToSnapPoints = exports.DEFAULT_SELECT_INITIAL_DETENT = exports.DEFAULT_SELECT_DETENTS = exports.SELECT_THEME_ROLES = exports.SelectCloseButton = exports.Select = exports.RADIO_THEME_ROLES = exports.RadioField = exports.FormCloseButton = exports.Form = exports.FIELD_THEME_ROLES = exports.FieldTextarea = exports.FieldText = exports.filterLinkBlockGroups = exports.LinkBlocks = exports.buttonRadiusValue = exports.Button = exports.version = void 0;
40
- exports.version = "0.2.0";
39
+ exports.components = exports.SLIDER_THEME_ROLES = exports.Slider = exports.resolveSelectDetentIndex = exports.detentsToSnapPoints = exports.DEFAULT_SELECT_INITIAL_DETENT = exports.DEFAULT_SELECT_DETENTS = exports.SELECT_THEME_ROLES = exports.SelectCloseButton = exports.Select = exports.RADIO_THEME_ROLES = exports.RadioField = exports.FormCloseButton = exports.Form = exports.FIELD_THEME_ROLES = exports.FieldTextarea = exports.FieldText = exports.filterLinkBlockGroups = exports.LinkBlocks = exports.buttonRadiusValue = exports.Button = exports.version = void 0;
40
+ exports.version = "0.2.2";
41
41
  __exportStar(require("./theme"), exports);
42
42
  var Button_1 = require("./components/Button");
43
43
  Object.defineProperty(exports, "Button", { enumerable: true, get: function () { return Button_1.Button; } });
@@ -63,5 +63,8 @@ Object.defineProperty(exports, "DEFAULT_SELECT_DETENTS", { enumerable: true, get
63
63
  Object.defineProperty(exports, "DEFAULT_SELECT_INITIAL_DETENT", { enumerable: true, get: function () { return Select_1.DEFAULT_SELECT_INITIAL_DETENT; } });
64
64
  Object.defineProperty(exports, "detentsToSnapPoints", { enumerable: true, get: function () { return Select_1.detentsToSnapPoints; } });
65
65
  Object.defineProperty(exports, "resolveSelectDetentIndex", { enumerable: true, get: function () { return Select_1.resolveSelectDetentIndex; } });
66
+ var Slider_1 = require("./components/Slider");
67
+ Object.defineProperty(exports, "Slider", { enumerable: true, get: function () { return Slider_1.Slider; } });
68
+ Object.defineProperty(exports, "SLIDER_THEME_ROLES", { enumerable: true, get: function () { return Slider_1.SLIDER_THEME_ROLES; } });
66
69
  exports.components = __importStar(require("./components"));
67
70
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAa,QAAA,OAAO,GAAG,OAAO,CAAC;AAE/B,0CAAwB;AACxB,8CAK6B;AAJ3B,gGAAA,MAAM,OAAA;AAEN,2GAAA,iBAAiB,OAAA;AAGnB,sDAMiC;AAL/B,wGAAA,UAAU,OAAA;AACV,mHAAA,qBAAqB,OAAA;AAKvB,4CAQ4B;AAP1B,kGAAA,SAAS,OAAA;AACT,sGAAA,aAAa,OAAA;AACb,0GAAA,iBAAiB,OAAA;AAMnB,0CAI2B;AAHzB,4FAAA,IAAI,OAAA;AACJ,uGAAA,eAAe,OAAA;AAGjB,sDAMiC;AAL/B,wGAAA,UAAU,OAAA;AACV,+GAAA,iBAAiB,OAAA;AAKnB,8CAY6B;AAX3B,gGAAA,MAAM,OAAA;AACN,2GAAA,iBAAiB,OAAA;AACjB,4GAAA,kBAAkB,OAAA;AAClB,gHAAA,sBAAsB,OAAA;AACtB,uHAAA,6BAA6B,OAAA;AAC7B,6GAAA,mBAAmB,OAAA;AACnB,kHAAA,wBAAwB,OAAA;AAM1B,2DAA2C"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAa,QAAA,OAAO,GAAG,OAAO,CAAC;AAE/B,0CAAwB;AACxB,8CAK6B;AAJ3B,gGAAA,MAAM,OAAA;AAEN,2GAAA,iBAAiB,OAAA;AAGnB,sDAMiC;AAL/B,wGAAA,UAAU,OAAA;AACV,mHAAA,qBAAqB,OAAA;AAKvB,4CAQ4B;AAP1B,kGAAA,SAAS,OAAA;AACT,sGAAA,aAAa,OAAA;AACb,0GAAA,iBAAiB,OAAA;AAMnB,0CAI2B;AAHzB,4FAAA,IAAI,OAAA;AACJ,uGAAA,eAAe,OAAA;AAGjB,sDAMiC;AAL/B,wGAAA,UAAU,OAAA;AACV,+GAAA,iBAAiB,OAAA;AAKnB,8CAY6B;AAX3B,gGAAA,MAAM,OAAA;AACN,2GAAA,iBAAiB,OAAA;AACjB,4GAAA,kBAAkB,OAAA;AAClB,gHAAA,sBAAsB,OAAA;AACtB,uHAAA,6BAA6B,OAAA;AAC7B,6GAAA,mBAAmB,OAAA;AACnB,kHAAA,wBAAwB,OAAA;AAM1B,8CAK6B;AAJ3B,gGAAA,MAAM,OAAA;AACN,4GAAA,kBAAkB,OAAA;AAIpB,2DAA2C"}
@@ -9,19 +9,24 @@ For `ThemeProvider`, see [theme provider and Button usage](./button.md). Design:
9
9
  Install in the app (Expo example):
10
10
 
11
11
  ```bash
12
- npx expo install @gorhom/bottom-sheet react-native-reanimated react-native-gesture-handler
12
+ npx expo install @gorhom/bottom-sheet react-native-reanimated react-native-gesture-handler react-native-screens
13
13
  ```
14
14
 
15
- Wrap the app root with `GestureHandlerRootView` and `BottomSheetModalProvider`:
15
+ On iOS, Select uses `FullWindowOverlay` so the sheet appears above React Navigation `fullScreenModal` screens.
16
+
17
+ Wrap the app root with `GestureHandlerRootView` and `BottomSheetModalProvider`. Put **`BottomSheetModalProvider` inside `ThemeProvider`** so sheet content (portaled) still resolves theme colours:
16
18
 
17
19
  ```tsx
18
20
  import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
21
+ import { ThemeProvider } from "@studio-piot/native-ui";
19
22
  import { GestureHandlerRootView } from "react-native-gesture-handler";
20
23
 
21
24
  export default function RootLayout() {
22
25
  return (
23
26
  <GestureHandlerRootView style={{ flex: 1 }}>
24
- <BottomSheetModalProvider>{/* … */}</BottomSheetModalProvider>
27
+ <ThemeProvider colors={Colors}>
28
+ <BottomSheetModalProvider>{/* … */}</BottomSheetModalProvider>
29
+ </ThemeProvider>
25
30
  </GestureHandlerRootView>
26
31
  );
27
32
  }
@@ -31,7 +36,7 @@ Import `react-native-gesture-handler` at the top of the entry layout (before oth
31
36
 
32
37
  ## Theme roles
33
38
 
34
- Uses Field roles: `fieldBackground`, `fieldText`, `fieldPlaceholder`, `fieldLabel`, `fieldDescription`, `fieldBorder`, `fieldError`, plus `fieldRadioSelected` for the selected check / emphasis.
39
+ Uses Field roles: `fieldBackground`, `fieldText`, `fieldPlaceholder`, `fieldLabel`, `fieldDescription`, `fieldBorder`, `fieldError`, plus **`fieldSelectSelected`** for the selected option check / label tint.
35
40
 
36
41
  ## Basic usage
37
42
 
@@ -59,9 +64,9 @@ Default is a dark disc with a white × (`SelectCloseButton`). Override:
59
64
  closeButton={(dismiss) => <YourClose onPress={dismiss} />}
60
65
  ```
61
66
 
62
- ## Detents / snap points
67
+ ## Detents / height
63
68
 
64
- Default `detents={[0.25, 0.5, 0.75]}` (Gorhom snap `25%` / `50%` / `75%`), opens at `initialDetent={0.5}`.
69
+ Sheet height follows content (`enableDynamicSizing`), capped by the largest `detents` fraction (default `0.75` of the window). Dragging cannot expand past content (or that cap when the list is long).
65
70
 
66
71
  ## Imports
67
72
 
@@ -0,0 +1,65 @@
1
+ # Slider — usage
2
+
3
+ Controlled slider with label on the left and a live formatted value on the top right. Track uses `@react-native-community/slider`. Your app owns form state.
4
+
5
+ Design: [Slider design](../superpowers/specs/2026-09-25-slider-design.md).
6
+
7
+ ## Peer dependency
8
+
9
+ ```bash
10
+ npx expo install @react-native-community/slider
11
+ ```
12
+
13
+ ## Theme roles
14
+
15
+ | Usage | Role |
16
+ |---|---|
17
+ | Label | `fieldLabel` |
18
+ | Value | `fieldText` |
19
+ | Filled track + thumb | **`fieldSliderHighlight`** (apps must add) |
20
+ | Unfilled track | `fieldBorder` |
21
+
22
+ ## Basic usage
23
+
24
+ ```tsx
25
+ import { Slider } from "@studio-piot/native-ui";
26
+ import { useState } from "react";
27
+
28
+ export function EffortSlider() {
29
+ const [effort, setEffort] = useState(5);
30
+
31
+ return (
32
+ <Slider
33
+ label="Effort"
34
+ value={effort}
35
+ onChange={setEffort}
36
+ minimumValue={1}
37
+ maximumValue={10}
38
+ step={1}
39
+ />
40
+ );
41
+ }
42
+ ```
43
+
44
+ ### Optional colour overrides
45
+
46
+ | Prop | Default theme role |
47
+ |---|---|
48
+ | `labelColor` | `fieldLabel` |
49
+ | `valueColor` | `fieldText` |
50
+ | `highlightColor` | `fieldSliderHighlight` |
51
+ | `trackColor` | `fieldBorder` |
52
+
53
+ ### Props
54
+
55
+ | Prop | Type | Default | Notes |
56
+ |---|---|---|---|
57
+ | `value` | `number` | — | Required |
58
+ | `onChange` | `(value: number) => void` | — | Required |
59
+ | `minimumValue` | `number` | — | Required |
60
+ | `maximumValue` | `number` | — | Required |
61
+ | `step` | `number` | — | Optional; omit for continuous |
62
+ | `label` | `string` | — | Optional |
63
+ | `formatValue` | `(value: number) => string` | `String` | Value display |
64
+ | `disabled` | `boolean` | `false` | |
65
+ | `style` | view style | — | Outer column |
@@ -0,0 +1,35 @@
1
+ # Slider Implementation Plan
2
+
3
+ > **For agentic workers:** Inline execution in session; checkboxes track progress.
4
+
5
+ **Goal:** Ship controlled `Slider` (label left, formatted value top-right, community track) with `fieldSliderHighlight`, and wire Ribe create-activity Effort (1–10).
6
+
7
+ **Architecture:** Presentational wrapper around `@react-native-community/slider`. Theme colours resolve like Field via `useResolvedFieldColor`. New role `fieldSliderHighlight` for filled track + thumb; unfilled track uses `fieldBorder`.
8
+
9
+ **Tech Stack:** TypeScript, RN, `@react-native-community/slider` peer, Expo Ribe.
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-09-25-slider-design.md`
14
+ - Peer `@react-native-community/slider`
15
+ - No brand in package; no custom-drawn track; no Optimum migration
16
+ - Commit only when the user asks
17
+
18
+ ## File map
19
+
20
+ | File | Responsibility |
21
+ |---|---|
22
+ | `src/components/Slider/sliderThemeRoles.ts` | Role map |
23
+ | `src/components/Slider/sliderThemeRoles.test.ts` | Role assertions |
24
+ | `src/components/Slider/types.ts` | `SliderProps` |
25
+ | `src/components/Slider/Slider.tsx` | Header + community Slider |
26
+ | `src/components/Slider/index.ts` | Barrel |
27
+ | Exports / README / `docs/components/slider.md` | Public surface |
28
+ | Ribe `colors.ts` (+ test) | `fieldSliderHighlight` |
29
+ | Ribe create-activity | Effort Slider |
30
+ | Ribe `package.json` | Install community slider |
31
+
32
+ ## Tasks
33
+
34
+ - [x] Package Slider + peer + docs + exports + unit test
35
+ - [x] Ribe: theme role + Effort on create-activity + install peer
@@ -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.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": {
@@ -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
  }
@@ -5,26 +5,41 @@ import {
5
5
  type ElementRef,
6
6
  type ReactNode,
7
7
  } from "react";
8
- import { Pressable, StyleSheet, Text, View } from "react-native";
8
+ import {
9
+ Platform,
10
+ Pressable,
11
+ StyleSheet,
12
+ Text,
13
+ useWindowDimensions,
14
+ View,
15
+ } from "react-native";
9
16
  import {
10
17
  BottomSheetBackdrop,
18
+ BottomSheetHandle,
11
19
  BottomSheetModal,
12
20
  BottomSheetScrollView,
13
21
  type BottomSheetBackdropProps,
22
+ type BottomSheetHandleProps,
14
23
  } from "@gorhom/bottom-sheet";
24
+ import { FullWindowOverlay } from "react-native-screens";
15
25
  import { buttonRadiusValue } from "../Button/buttonRadius";
16
26
  import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
17
- import {
18
- DEFAULT_SELECT_DETENTS,
19
- DEFAULT_SELECT_INITIAL_DETENT,
20
- detentsToSnapPoints,
21
- resolveSelectDetentIndex,
22
- } from "./resolveSelectDetentIndex";
27
+ import { useThemeColor } from "../../theme/useThemeColor";
28
+ import { DEFAULT_SELECT_DETENTS } from "./resolveSelectDetentIndex";
23
29
  import { SelectCloseButton } from "./SelectCloseButton";
24
30
  import { SELECT_THEME_ROLES } from "./selectThemeRoles";
25
31
  import type { SelectOption, SelectProps } from "./types";
26
32
 
27
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
+ }
28
43
 
29
44
  export function Select({
30
45
  value,
@@ -36,8 +51,9 @@ export function Select({
36
51
  error,
37
52
  sheetTitle,
38
53
  closeButton,
54
+ /** Max sheet height as a fraction of the window (largest value wins). Default `[0.25, 0.5, 0.75]` → 75%. */
39
55
  detents = [...DEFAULT_SELECT_DETENTS],
40
- initialDetent = DEFAULT_SELECT_INITIAL_DETENT,
56
+ initialDetent: _initialDetent,
41
57
  radius = "xl",
42
58
  editable = true,
43
59
  backgroundColor: backgroundColorProp,
@@ -51,6 +67,7 @@ export function Select({
51
67
  style,
52
68
  }: SelectProps) {
53
69
  const modalRef = useRef<ElementRef<typeof BottomSheetModal>>(null);
70
+ const { height: windowHeight } = useWindowDimensions();
54
71
 
55
72
  const backgroundColor = useResolvedFieldColor(
56
73
  backgroundColorProp,
@@ -84,6 +101,9 @@ export function Select({
84
101
  selectedColorProp,
85
102
  SELECT_THEME_ROLES.selectedColor,
86
103
  );
104
+ // Resolved on the trigger tree (has ThemeProvider), then passed into the portal.
105
+ const closeDiscColor = useThemeColor("text");
106
+ const closeIconColor = useThemeColor("background");
87
107
 
88
108
  const hasError = Boolean(error);
89
109
  const selected = options.find((option) => option.value === value);
@@ -91,8 +111,11 @@ export function Select({
91
111
  const isPlaceholder = selected == null;
92
112
  const borderRadius = buttonRadiusValue(radius);
93
113
  const title = sheetTitle ?? label ?? "Select";
94
- const presentIndex = resolveSelectDetentIndex(detents, initialDetent);
95
- const snapPoints = useMemo(() => detentsToSnapPoints(detents), [detents]);
114
+
115
+ const maxDynamicContentSize = useMemo(() => {
116
+ const maxDetent = Math.max(...detents, 0.75);
117
+ return Math.round(windowHeight * maxDetent);
118
+ }, [detents, windowHeight]);
96
119
 
97
120
  const dismiss = useCallback(() => {
98
121
  modalRef.current?.dismiss();
@@ -119,7 +142,52 @@ export function Select({
119
142
  const resolvedClose: ReactNode =
120
143
  typeof closeButton === "function"
121
144
  ? closeButton(dismiss)
122
- : (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
+ );
123
191
 
124
192
  const onSelect = (option: SelectOption) => {
125
193
  if (option.disabled) return;
@@ -176,67 +244,62 @@ export function Select({
176
244
  <BottomSheetModal
177
245
  ref={modalRef}
178
246
  backdropComponent={renderBackdrop}
179
- backgroundStyle={{ backgroundColor }}
180
- enablePanDownToClose
181
- handleIndicatorStyle={{
182
- backgroundColor: placeholderColor,
183
- opacity: 0.9,
247
+ backgroundStyle={{
248
+ backgroundColor,
249
+ borderTopLeftRadius: SHEET_TOP_RADIUS,
250
+ borderTopRightRadius: SHEET_TOP_RADIUS,
184
251
  }}
185
- index={presentIndex}
186
- snapPoints={snapPoints}
252
+ containerComponent={SheetContainer}
253
+ enableDynamicSizing
254
+ enablePanDownToClose
255
+ handleComponent={renderHandle}
256
+ handleStyle={styles.handleShell}
257
+ maxDynamicContentSize={maxDynamicContentSize}
258
+ stackBehavior="push"
187
259
  >
188
- <BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
189
- <View style={styles.header}>
190
- <Text style={[styles.sheetTitle, { color: textColor, flex: 1 }]}>
191
- {title}
192
- </Text>
193
- {resolvedClose}
194
- </View>
260
+ <BottomSheetScrollView
261
+ contentContainerStyle={styles.sheetContent}
262
+ enableFooterMarginAdjustment={false}
263
+ >
195
264
  {options.length === 0 ? (
196
265
  <Text style={[styles.empty, { color: placeholderColor }]}>
197
266
  No options
198
267
  </Text>
199
268
  ) : (
200
- options.map((option, index) => {
269
+ options.map((option) => {
201
270
  const isSelected = option.value === value;
202
271
  return (
203
- <View key={option.value}>
204
- {index > 0 ? (
205
- <View
206
- style={[styles.divider, { backgroundColor: borderColor }]}
207
- />
208
- ) : null}
209
- <Pressable
210
- accessibilityRole="button"
211
- accessibilityState={{
212
- selected: isSelected,
213
- disabled: !!option.disabled,
214
- }}
215
- disabled={option.disabled}
216
- onPress={() => onSelect(option)}
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)}
281
+ style={[
282
+ styles.option,
283
+ option.disabled && styles.optionBlocked,
284
+ ]}
285
+ >
286
+ <Text
217
287
  style={[
218
- styles.option,
219
- option.disabled && styles.optionBlocked,
288
+ styles.optionLabel,
289
+ {
290
+ color: isSelected ? selectedColor : textColor,
291
+ flex: 1,
292
+ },
220
293
  ]}
221
294
  >
222
- <Text
223
- style={[
224
- styles.optionLabel,
225
- {
226
- color: isSelected ? selectedColor : textColor,
227
- flex: 1,
228
- },
229
- ]}
230
- >
231
- {option.label}
295
+ {option.label}
296
+ </Text>
297
+ {isSelected ? (
298
+ <Text style={[styles.check, { color: selectedColor }]}>
299
+ ✓
232
300
  </Text>
233
- {isSelected ? (
234
- <Text style={[styles.check, { color: selectedColor }]}>
235
- ✓
236
- </Text>
237
- ) : null}
238
- </Pressable>
239
- </View>
301
+ ) : null}
302
+ </Pressable>
240
303
  );
241
304
  })
242
305
  )}
@@ -275,10 +338,17 @@ const styles = StyleSheet.create({
275
338
  fontSize: 22,
276
339
  fontWeight: "300",
277
340
  lineHeight: 24,
341
+ transform: [{ rotate: "90deg" }],
278
342
  },
279
343
  helper: {
280
344
  fontSize: 13,
281
345
  },
346
+ handleShell: {
347
+ paddingBottom: 0,
348
+ },
349
+ handleRoot: {
350
+ overflow: "hidden",
351
+ },
282
352
  sheetContent: {
283
353
  paddingBottom: 24,
284
354
  },
@@ -287,9 +357,12 @@ const styles = StyleSheet.create({
287
357
  alignItems: "center",
288
358
  gap: 12,
289
359
  paddingHorizontal: 16,
290
- paddingTop: 8,
360
+ paddingTop: 4,
291
361
  paddingBottom: 12,
292
362
  },
363
+ headerDivider: {
364
+ height: StyleSheet.hairlineWidth,
365
+ },
293
366
  sheetTitle: {
294
367
  fontSize: 17,
295
368
  fontWeight: "600",
@@ -316,8 +389,4 @@ const styles = StyleSheet.create({
316
389
  fontSize: 17,
317
390
  fontWeight: "600",
318
391
  },
319
- divider: {
320
- height: StyleSheet.hairlineWidth,
321
- marginHorizontal: 16,
322
- },
323
392
  });