@studio-piot/native-ui 0.2.2 → 0.2.4

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 (116) hide show
  1. package/README.md +3 -1
  2. package/dist/components/ErrorBanner/ErrorBanner.d.ts +8 -0
  3. package/dist/components/ErrorBanner/ErrorBanner.d.ts.map +1 -0
  4. package/dist/components/ErrorBanner/ErrorBanner.js +136 -0
  5. package/dist/components/ErrorBanner/ErrorBanner.js.map +1 -0
  6. package/dist/components/ErrorBanner/errorBannerContext.d.ts +36 -0
  7. package/dist/components/ErrorBanner/errorBannerContext.d.ts.map +1 -0
  8. package/dist/components/ErrorBanner/errorBannerContext.js +78 -0
  9. package/dist/components/ErrorBanner/errorBannerContext.js.map +1 -0
  10. package/dist/components/ErrorBanner/errorBannerCopy.d.ts +7 -0
  11. package/dist/components/ErrorBanner/errorBannerCopy.d.ts.map +1 -0
  12. package/dist/components/ErrorBanner/errorBannerCopy.js +22 -0
  13. package/dist/components/ErrorBanner/errorBannerCopy.js.map +1 -0
  14. package/dist/components/ErrorBanner/errorBannerThemeRoles.d.ts +9 -0
  15. package/dist/components/ErrorBanner/errorBannerThemeRoles.d.ts.map +1 -0
  16. package/dist/components/ErrorBanner/errorBannerThemeRoles.js +11 -0
  17. package/dist/components/ErrorBanner/errorBannerThemeRoles.js.map +1 -0
  18. package/dist/components/ErrorBanner/icons.d.ts +7 -0
  19. package/dist/components/ErrorBanner/icons.d.ts.map +1 -0
  20. package/dist/components/ErrorBanner/icons.js +46 -0
  21. package/dist/components/ErrorBanner/icons.js.map +1 -0
  22. package/dist/components/ErrorBanner/index.d.ts +6 -0
  23. package/dist/components/ErrorBanner/index.d.ts.map +1 -0
  24. package/dist/components/ErrorBanner/index.js +16 -0
  25. package/dist/components/ErrorBanner/index.js.map +1 -0
  26. package/dist/components/ErrorBanner/types.d.ts +31 -0
  27. package/dist/components/ErrorBanner/types.d.ts.map +1 -0
  28. package/dist/components/ErrorBanner/types.js +3 -0
  29. package/dist/components/ErrorBanner/types.js.map +1 -0
  30. package/dist/components/ErrorBanner/useErrorBanner.d.ts +13 -0
  31. package/dist/components/ErrorBanner/useErrorBanner.d.ts.map +1 -0
  32. package/dist/components/ErrorBanner/useErrorBanner.js +31 -0
  33. package/dist/components/ErrorBanner/useErrorBanner.js.map +1 -0
  34. package/dist/components/Field/fieldChrome.d.ts +1 -1
  35. package/dist/components/Field/fieldChrome.d.ts.map +1 -1
  36. package/dist/components/Field/fieldChrome.js +20 -4
  37. package/dist/components/Field/fieldChrome.js.map +1 -1
  38. package/dist/components/Field/types.d.ts +2 -0
  39. package/dist/components/Field/types.d.ts.map +1 -1
  40. package/dist/components/Form/Form.d.ts +1 -1
  41. package/dist/components/Form/Form.d.ts.map +1 -1
  42. package/dist/components/Form/Form.js +36 -23
  43. package/dist/components/Form/Form.js.map +1 -1
  44. package/dist/components/Form/types.d.ts +7 -0
  45. package/dist/components/Form/types.d.ts.map +1 -1
  46. package/dist/components/RadioField/RadioField.d.ts.map +1 -1
  47. package/dist/components/RadioField/RadioField.js +6 -1
  48. package/dist/components/RadioField/RadioField.js.map +1 -1
  49. package/dist/components/SegmentedControl/SegmentedControl.d.ts +3 -0
  50. package/dist/components/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  51. package/dist/components/SegmentedControl/SegmentedControl.js +181 -0
  52. package/dist/components/SegmentedControl/SegmentedControl.js.map +1 -0
  53. package/dist/components/SegmentedControl/index.d.ts +4 -0
  54. package/dist/components/SegmentedControl/index.d.ts.map +1 -0
  55. package/dist/components/SegmentedControl/index.js +8 -0
  56. package/dist/components/SegmentedControl/index.js.map +1 -0
  57. package/dist/components/SegmentedControl/segmentIndicatorFrame.d.ts +16 -0
  58. package/dist/components/SegmentedControl/segmentIndicatorFrame.d.ts.map +1 -0
  59. package/dist/components/SegmentedControl/segmentIndicatorFrame.js +27 -0
  60. package/dist/components/SegmentedControl/segmentIndicatorFrame.js.map +1 -0
  61. package/dist/components/SegmentedControl/segmentThemeRoles.d.ts +9 -0
  62. package/dist/components/SegmentedControl/segmentThemeRoles.d.ts.map +1 -0
  63. package/dist/components/SegmentedControl/segmentThemeRoles.js +11 -0
  64. package/dist/components/SegmentedControl/segmentThemeRoles.js.map +1 -0
  65. package/dist/components/SegmentedControl/types.d.ts +23 -0
  66. package/dist/components/SegmentedControl/types.d.ts.map +1 -0
  67. package/dist/components/SegmentedControl/types.js +3 -0
  68. package/dist/components/SegmentedControl/types.js.map +1 -0
  69. package/dist/components/Select/Select.d.ts +1 -1
  70. package/dist/components/Select/Select.d.ts.map +1 -1
  71. package/dist/components/Select/Select.js +11 -4
  72. package/dist/components/Select/Select.js.map +1 -1
  73. package/dist/components/Select/types.d.ts +2 -0
  74. package/dist/components/Select/types.d.ts.map +1 -1
  75. package/dist/components/Slider/Slider.d.ts.map +1 -1
  76. package/dist/components/Slider/Slider.js +6 -1
  77. package/dist/components/Slider/Slider.js.map +1 -1
  78. package/dist/components/index.d.ts +2 -0
  79. package/dist/components/index.d.ts.map +1 -1
  80. package/dist/components/index.js +13 -1
  81. package/dist/components/index.js.map +1 -1
  82. package/dist/index.d.ts +3 -1
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +14 -2
  85. package/dist/index.js.map +1 -1
  86. package/docs/components/error-banner.md +85 -0
  87. package/docs/components/form.md +2 -1
  88. package/docs/components/segmented-control.md +105 -0
  89. package/package.json +4 -2
  90. package/src/components/ErrorBanner/ErrorBanner.tsx +232 -0
  91. package/src/components/ErrorBanner/errorBannerContext.tsx +140 -0
  92. package/src/components/ErrorBanner/errorBannerCopy.test.ts +41 -0
  93. package/src/components/ErrorBanner/errorBannerCopy.ts +22 -0
  94. package/src/components/ErrorBanner/errorBannerThemeRoles.test.ts +19 -0
  95. package/src/components/ErrorBanner/errorBannerThemeRoles.ts +9 -0
  96. package/src/components/ErrorBanner/icons.tsx +38 -0
  97. package/src/components/ErrorBanner/index.ts +14 -0
  98. package/src/components/ErrorBanner/types.ts +31 -0
  99. package/src/components/ErrorBanner/useErrorBanner.ts +33 -0
  100. package/src/components/Field/fieldChrome.tsx +33 -7
  101. package/src/components/Field/types.ts +2 -0
  102. package/src/components/Form/Form.tsx +56 -2
  103. package/src/components/Form/types.ts +7 -0
  104. package/src/components/RadioField/RadioField.tsx +6 -1
  105. package/src/components/SegmentedControl/SegmentedControl.tsx +230 -0
  106. package/src/components/SegmentedControl/index.ts +6 -0
  107. package/src/components/SegmentedControl/segmentIndicatorFrame.test.ts +39 -0
  108. package/src/components/SegmentedControl/segmentIndicatorFrame.ts +31 -0
  109. package/src/components/SegmentedControl/segmentThemeRoles.test.ts +19 -0
  110. package/src/components/SegmentedControl/segmentThemeRoles.ts +9 -0
  111. package/src/components/SegmentedControl/types.ts +24 -0
  112. package/src/components/Select/Select.tsx +20 -5
  113. package/src/components/Select/types.ts +2 -0
  114. package/src/components/Slider/Slider.tsx +6 -1
  115. package/src/components/index.ts +20 -0
  116. package/src/index.ts +21 -1
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useRef, useState } from "react";
2
2
  import {
3
3
  StyleSheet,
4
4
  Text,
@@ -8,6 +8,10 @@ import {
8
8
  type TextInputProps,
9
9
  } from "react-native";
10
10
  import { buttonRadiusValue } from "../Button/buttonRadius";
11
+ import {
12
+ useErrorBannerDismiss,
13
+ useErrorBannerField,
14
+ } from "../ErrorBanner";
11
15
  import { FIELD_THEME_ROLES } from "./fieldThemeRoles";
12
16
  import {
13
17
  resolveFieldBorder,
@@ -24,6 +28,7 @@ export type FieldChromeProps = FieldSharedProps & {
24
28
  };
25
29
 
26
30
  export function FieldChrome({
31
+ name,
27
32
  value,
28
33
  onChangeText,
29
34
  borderColor: borderColorProp,
@@ -89,8 +94,14 @@ export function FieldChrome({
89
94
  FIELD_THEME_ROLES.descriptionColor,
90
95
  );
91
96
 
97
+ const inputRef = useRef<TextInput>(null);
98
+ const dismissBanner = useErrorBannerDismiss();
99
+ const { viewRef, listed } = useErrorBannerField(name, () =>
100
+ inputRef.current?.focus(),
101
+ );
92
102
  const [isFocused, setIsFocused] = useState(false);
93
- const hasError = Boolean(error);
103
+ const hasError = Boolean(error) || Boolean(listed);
104
+ const inlineError = listed ? undefined : error;
94
105
  const showClear = shouldShowFieldClear(value, clearIcon != null, editable);
95
106
  const border = resolveFieldBorder({
96
107
  isFocused,
@@ -107,15 +118,29 @@ export function FieldChrome({
107
118
  };
108
119
 
109
120
  return (
110
- <View style={[styles.column, !editable && styles.blocked, style]}>
121
+ <View
122
+ ref={viewRef}
123
+ collapsable={false}
124
+ style={[styles.column, !editable && styles.blocked, style]}
125
+ >
111
126
  {label ? (
112
127
  <Text style={[styles.label, { color: labelColor }]}>{label}</Text>
113
128
  ) : null}
114
129
  <View style={styles.inputWrapper}>
115
130
  <TextInput
116
- ref={ref}
131
+ ref={(node) => {
132
+ inputRef.current = node;
133
+ if (typeof ref === "function") {
134
+ ref(node);
135
+ } else if (ref) {
136
+ ref.current = node;
137
+ }
138
+ }}
117
139
  value={value}
118
- onChangeText={onChangeText}
140
+ onChangeText={(text) => {
141
+ onChangeText(text);
142
+ dismissBanner();
143
+ }}
119
144
  placeholder={placeholder}
120
145
  placeholderTextColor={placeholderColor}
121
146
  editable={editable}
@@ -127,6 +152,7 @@ export function FieldChrome({
127
152
  autoCapitalize={autoCapitalize}
128
153
  autoComplete={autoComplete}
129
154
  textContentType={textContentType}
155
+ accessibilityHint={listed?.message}
130
156
  accessibilityLabel={label}
131
157
  accessibilityState={{ disabled: !editable }}
132
158
  onFocus={(e) => {
@@ -167,8 +193,8 @@ export function FieldChrome({
167
193
  </TouchableOpacity>
168
194
  ) : null}
169
195
  </View>
170
- {error ? (
171
- <Text style={[styles.helper, { color: errorColor }]}>{error}</Text>
196
+ {inlineError ? (
197
+ <Text style={[styles.helper, { color: errorColor }]}>{inlineError}</Text>
172
198
  ) : description ? (
173
199
  <Text style={[styles.helper, { color: descriptionColor }]}>
174
200
  {description}
@@ -8,6 +8,8 @@ import type {
8
8
  import type { ButtonRadius } from "../Button/buttonRadius";
9
9
 
10
10
  export type FieldSharedProps = {
11
+ /** Registers the field with `Form` / `ErrorBannerProvider` for banner rows. */
12
+ name?: string;
11
13
  value: string;
12
14
  onChangeText: (text: string) => void;
13
15
  /** Defaults to theme role `fieldBorder`. */
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from "react";
1
+ import { useRef, useState, type ReactNode } from "react";
2
2
  import {
3
3
  StyleSheet,
4
4
  Text,
@@ -8,7 +8,15 @@ import {
8
8
  import {
9
9
  KeyboardAwareScrollView,
10
10
  KeyboardStickyView,
11
+ type KeyboardAwareScrollViewRef,
11
12
  } from "react-native-keyboard-controller";
13
+ import {
14
+ ErrorBanner,
15
+ ErrorBannerProvider,
16
+ useErrorBanner,
17
+ useErrorBannerContext,
18
+ type FieldError,
19
+ } from "../ErrorBanner";
12
20
  import { useSafeAreaInsets } from "react-native-safe-area-context";
13
21
  import { screenPaddingHorizontal } from "../../layout/screenPadding";
14
22
  import { Button } from "../Button/Button";
@@ -20,6 +28,29 @@ import { withAlpha } from "./withAlpha";
20
28
  /** How far the header wash continues below the buttons. */
21
29
  const FADE_TAIL = 32;
22
30
 
31
+ function FormErrorBanner({
32
+ open,
33
+ errors,
34
+ onDismiss,
35
+ onHeightChange,
36
+ }: {
37
+ open: boolean;
38
+ errors: readonly FieldError[];
39
+ onDismiss: () => void;
40
+ onHeightChange: (height: number) => void;
41
+ }) {
42
+ const ctx = useErrorBannerContext();
43
+ return (
44
+ <ErrorBanner
45
+ errors={errors}
46
+ onDismiss={onDismiss}
47
+ onHeightChange={onHeightChange}
48
+ onJump={(field) => ctx?.jump(field)}
49
+ open={open}
50
+ />
51
+ );
52
+ }
53
+
23
54
  /** Space kept between the focused field and the top of the footer. */
24
55
  const FIELD_GAP = 12;
25
56
 
@@ -34,6 +65,7 @@ export function Form({
34
65
  children,
35
66
  onClose,
36
67
  onSubmit,
68
+ errors = [],
37
69
  submitLabel = "Save",
38
70
  submitting = false,
39
71
  submitDisabled = false,
@@ -61,6 +93,9 @@ export function Form({
61
93
  buttonLabelColorProp,
62
94
  "buttonLabel",
63
95
  );
96
+ const scrollRef = useRef<KeyboardAwareScrollViewRef>(null);
97
+ const bannerHeightRef = useRef(0);
98
+ const banner = useErrorBanner(errors);
64
99
  const [footerHeight, setFooterHeight] = useState(0);
65
100
  const [cancelWidth, setCancelWidth] = useState(0);
66
101
  const [barHeight, setBarHeight] = useState(0);
@@ -76,8 +111,15 @@ export function Form({
76
111
  );
77
112
 
78
113
  return (
114
+ <ErrorBannerProvider
115
+ bannerHeightRef={bannerHeightRef}
116
+ errors={errors}
117
+ onDismiss={banner.dismiss}
118
+ scrollRef={scrollRef}
119
+ >
79
120
  <View style={[styles.screen, { backgroundColor: background }]}>
80
121
  <KeyboardAwareScrollView
122
+ ref={scrollRef}
81
123
  automaticallyAdjustKeyboardInsets={false}
82
124
  bottomOffset={footerHeight + FIELD_GAP + FIELD_BELOW_CARET}
83
125
  contentContainerStyle={[
@@ -154,14 +196,26 @@ export function Form({
154
196
  label={submitLabel}
155
197
  labelColor={buttonLabel}
156
198
  loading={submitting}
157
- onPress={onSubmit}
199
+ onPress={() => {
200
+ banner.present();
201
+ onSubmit();
202
+ }}
158
203
  radius={submitRadius}
159
204
  />
160
205
  </View>
161
206
  <View style={{ height: insets.bottom }} />
162
207
  </View>
163
208
  </KeyboardStickyView>
209
+ <FormErrorBanner
210
+ errors={errors}
211
+ onDismiss={banner.dismiss}
212
+ onHeightChange={(height) => {
213
+ bannerHeightRef.current = height;
214
+ }}
215
+ open={banner.open}
216
+ />
164
217
  </View>
218
+ </ErrorBannerProvider>
165
219
  );
166
220
  }
167
221
 
@@ -1,11 +1,18 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { ButtonRadius } from "../Button/buttonRadius";
3
+ import type { FieldError } from "../ErrorBanner/types";
3
4
 
4
5
  export type FormProps = {
5
6
  title: string;
6
7
  children: ReactNode;
7
8
  onClose: () => void;
8
9
  onSubmit: () => void;
10
+ /**
11
+ * Field errors for the screen banner. Empty hides it.
12
+ * Set these when submit fails. Editing a field dismisses the banner;
13
+ * the next submit shows it again.
14
+ */
15
+ errors?: readonly FieldError[];
9
16
  submitLabel?: string;
10
17
  submitting?: boolean;
11
18
  submitDisabled?: boolean;
@@ -5,6 +5,7 @@ import {
5
5
  View,
6
6
  } from "react-native";
7
7
  import { buttonRadiusValue } from "../Button/buttonRadius";
8
+ import { useErrorBannerDismiss } from "../ErrorBanner";
8
9
  import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
9
10
  import { RadioMark } from "./RadioMark";
10
11
  import { RADIO_THEME_ROLES } from "./radioThemeRoles";
@@ -55,6 +56,7 @@ export function RadioField<T extends string = string>({
55
56
  titleColorProp,
56
57
  RADIO_THEME_ROLES.titleColor,
57
58
  );
59
+ const dismissBanner = useErrorBannerDismiss();
58
60
  const borderRadius = buttonRadiusValue(radius);
59
61
 
60
62
  if (options.length === 0) {
@@ -97,7 +99,10 @@ export function RadioField<T extends string = string>({
97
99
  }}
98
100
  activeOpacity={0.75}
99
101
  disabled={option.disabled}
100
- onPress={() => onChange(option.value)}
102
+ onPress={() => {
103
+ onChange(option.value);
104
+ dismissBanner();
105
+ }}
101
106
  style={[styles.row, option.disabled && styles.blocked]}
102
107
  >
103
108
  <View style={styles.textCol}>
@@ -0,0 +1,230 @@
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 { useErrorBannerDismiss } from "../ErrorBanner";
11
+ import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
12
+ import {
13
+ resolveSegmentIndex,
14
+ SEGMENT_INSET,
15
+ segmentIndicatorFrame,
16
+ } from "./segmentIndicatorFrame";
17
+ import { SEGMENTED_THEME_ROLES } from "./segmentThemeRoles";
18
+ import type { SegmentedControlProps } from "./types";
19
+
20
+ const SLIDE_MS = 220;
21
+
22
+ export function SegmentedControl<T extends string = string>({
23
+ value,
24
+ onChange,
25
+ options,
26
+ label,
27
+ radius = "lg",
28
+ disabled = false,
29
+ backgroundColor: backgroundColorProp,
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 selectedBackgroundColor = useResolvedFieldColor(
41
+ selectedBackgroundColorProp,
42
+ SEGMENTED_THEME_ROLES.selectedBackgroundColor,
43
+ );
44
+ const labelColor = useResolvedFieldColor(
45
+ labelColorProp,
46
+ SEGMENTED_THEME_ROLES.labelColor,
47
+ );
48
+ const selectedLabelColor = useResolvedFieldColor(
49
+ selectedLabelColorProp,
50
+ SEGMENTED_THEME_ROLES.selectedLabelColor,
51
+ );
52
+ const titleColor = useResolvedFieldColor(
53
+ titleColorProp,
54
+ SEGMENTED_THEME_ROLES.titleColor,
55
+ );
56
+
57
+ const index = resolveSegmentIndex(value, options);
58
+ const trackWidth = useRef(0);
59
+ const laidOut = useRef(false);
60
+ const indexSV = useSharedValue(0);
61
+ const widthSV = useSharedValue(0);
62
+ const opacitySV = useSharedValue(0);
63
+
64
+ const dismissBanner = useErrorBannerDismiss();
65
+ const borderRadius = buttonRadiusValue(radius);
66
+ const pillRadius = Math.max(0, borderRadius - SEGMENT_INSET);
67
+
68
+ const applyTrackWidth = (nextWidth: number, count: number) => {
69
+ trackWidth.current = nextWidth;
70
+ const frame = segmentIndicatorFrame(0, count, nextWidth, SEGMENT_INSET);
71
+ widthSV.value = frame?.width ?? 0;
72
+ };
73
+
74
+ useEffect(() => {
75
+ if (trackWidth.current > 0) {
76
+ applyTrackWidth(trackWidth.current, options.length);
77
+ }
78
+ }, [options.length, widthSV]);
79
+
80
+ useEffect(() => {
81
+ if (!laidOut.current) {
82
+ return;
83
+ }
84
+ if (index < 0) {
85
+ opacitySV.value = withTiming(0, { duration: 160 });
86
+ return;
87
+ }
88
+ opacitySV.value = withTiming(1, { duration: 160 });
89
+ indexSV.value = withTiming(index, {
90
+ duration: SLIDE_MS,
91
+ easing: Easing.out(Easing.cubic),
92
+ });
93
+ }, [index, indexSV, opacitySV]);
94
+
95
+ const indicatorStyle = useAnimatedStyle(() => ({
96
+ width: widthSV.value,
97
+ opacity: opacitySV.value,
98
+ transform: [{ translateX: SEGMENT_INSET + indexSV.value * widthSV.value }],
99
+ }));
100
+
101
+ if (options.length === 0) {
102
+ return null;
103
+ }
104
+
105
+ return (
106
+ <View style={[styles.group, disabled && styles.blocked, style]}>
107
+ {label ? (
108
+ <Text style={[styles.title, { color: titleColor }]}>{label}</Text>
109
+ ) : null}
110
+ <View
111
+ accessibilityLabel={label}
112
+ accessibilityRole="radiogroup"
113
+ style={[styles.track, { backgroundColor, borderRadius }]}
114
+ >
115
+ <Animated.View
116
+ pointerEvents="none"
117
+ style={[
118
+ styles.indicator,
119
+ indicatorStyle,
120
+ {
121
+ backgroundColor: selectedBackgroundColor,
122
+ borderRadius: pillRadius,
123
+ },
124
+ ]}
125
+ />
126
+ <View
127
+ onLayout={(event) => {
128
+ const nextWidth = event.nativeEvent.layout.width;
129
+ if (nextWidth <= 0) {
130
+ return;
131
+ }
132
+ applyTrackWidth(nextWidth, options.length);
133
+ if (!laidOut.current) {
134
+ indexSV.value = index < 0 ? 0 : index;
135
+ opacitySV.value = index < 0 ? 0 : 1;
136
+ laidOut.current = true;
137
+ }
138
+ }}
139
+ style={styles.row}
140
+ >
141
+ {options.map((option) => {
142
+ const selected = value === option.value;
143
+ const blocked = disabled || !!option.disabled;
144
+ return (
145
+ <Pressable
146
+ key={option.value}
147
+ accessibilityLabel={option.label}
148
+ accessibilityRole="radio"
149
+ accessibilityState={{
150
+ selected,
151
+ checked: selected,
152
+ disabled: blocked,
153
+ }}
154
+ disabled={blocked}
155
+ onPress={() => {
156
+ onChange(option.value);
157
+ dismissBanner();
158
+ }}
159
+ style={({ pressed }) => [
160
+ styles.segment,
161
+ pressed && !blocked && styles.pressed,
162
+ option.disabled && styles.segmentBlocked,
163
+ ]}
164
+ >
165
+ <Text
166
+ style={[
167
+ styles.label,
168
+ {
169
+ color: selected ? selectedLabelColor : labelColor,
170
+ fontWeight: selected ? "600" : "500",
171
+ },
172
+ ]}
173
+ >
174
+ {option.label}
175
+ </Text>
176
+ </Pressable>
177
+ );
178
+ })}
179
+ </View>
180
+ </View>
181
+ </View>
182
+ );
183
+ }
184
+
185
+ const styles = StyleSheet.create({
186
+ group: {
187
+ width: "100%",
188
+ gap: 8,
189
+ },
190
+ blocked: {
191
+ opacity: 0.4,
192
+ },
193
+ title: {
194
+ fontSize: 13,
195
+ fontWeight: "600",
196
+ paddingHorizontal: 4,
197
+ },
198
+ track: {
199
+ width: "100%",
200
+ padding: SEGMENT_INSET,
201
+ overflow: "hidden",
202
+ },
203
+ indicator: {
204
+ position: "absolute",
205
+ top: SEGMENT_INSET,
206
+ bottom: SEGMENT_INSET,
207
+ left: 0,
208
+ },
209
+ row: {
210
+ flexDirection: "row",
211
+ },
212
+ segment: {
213
+ flex: 1,
214
+ minHeight: 36,
215
+ paddingVertical: 8,
216
+ paddingHorizontal: 8,
217
+ alignItems: "center",
218
+ justifyContent: "center",
219
+ },
220
+ pressed: {
221
+ opacity: 0.65,
222
+ },
223
+ segmentBlocked: {
224
+ opacity: 0.4,
225
+ },
226
+ label: {
227
+ fontSize: 15,
228
+ textAlign: "center",
229
+ },
230
+ });
@@ -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,19 @@
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 to dedicated segment roles", () => {
7
+ assert.equal(SEGMENTED_THEME_ROLES.backgroundColor, "segmentBackground");
8
+ assert.equal(
9
+ SEGMENTED_THEME_ROLES.selectedBackgroundColor,
10
+ "segmentSelectedBackground",
11
+ );
12
+ assert.equal(SEGMENTED_THEME_ROLES.labelColor, "segmentLabel");
13
+ assert.equal(
14
+ SEGMENTED_THEME_ROLES.selectedLabelColor,
15
+ "segmentSelectedLabel",
16
+ );
17
+ assert.equal(SEGMENTED_THEME_ROLES.titleColor, "segmentTitle");
18
+ });
19
+ });
@@ -0,0 +1,9 @@
1
+ export const SEGMENTED_THEME_ROLES = {
2
+ backgroundColor: "segmentBackground",
3
+ selectedBackgroundColor: "segmentSelectedBackground",
4
+ labelColor: "segmentLabel",
5
+ selectedLabelColor: "segmentSelectedLabel",
6
+ titleColor: "segmentTitle",
7
+ } as const;
8
+
9
+ 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
+ };
@@ -23,6 +23,10 @@ import {
23
23
  } from "@gorhom/bottom-sheet";
24
24
  import { FullWindowOverlay } from "react-native-screens";
25
25
  import { buttonRadiusValue } from "../Button/buttonRadius";
26
+ import {
27
+ useErrorBannerDismiss,
28
+ useErrorBannerField,
29
+ } from "../ErrorBanner";
26
30
  import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
27
31
  import { useThemeColor } from "../../theme/useThemeColor";
28
32
  import { DEFAULT_SELECT_DETENTS } from "./resolveSelectDetentIndex";
@@ -42,6 +46,7 @@ function SheetContainer({ children }: { children?: ReactNode }) {
42
46
  }
43
47
 
44
48
  export function Select({
49
+ name,
45
50
  value,
46
51
  onChange,
47
52
  options,
@@ -105,7 +110,12 @@ export function Select({
105
110
  const closeDiscColor = useThemeColor("text");
106
111
  const closeIconColor = useThemeColor("background");
107
112
 
108
- const hasError = Boolean(error);
113
+ const dismissBanner = useErrorBannerDismiss();
114
+ const { viewRef, listed } = useErrorBannerField(name, () => {
115
+ modalRef.current?.present();
116
+ });
117
+ const hasError = Boolean(error) || Boolean(listed);
118
+ const inlineError = listed ? undefined : error;
109
119
  const selected = options.find((option) => option.value === value);
110
120
  const display = selected?.label ?? placeholder;
111
121
  const isPlaceholder = selected == null;
@@ -192,16 +202,21 @@ export function Select({
192
202
  const onSelect = (option: SelectOption) => {
193
203
  if (option.disabled) return;
194
204
  onChange(option.value);
205
+ dismissBanner();
195
206
  dismiss();
196
207
  };
197
208
 
198
209
  return (
199
- <View style={[styles.column, !editable && styles.blocked, style]}>
210
+ <View
211
+ ref={viewRef}
212
+ collapsable={false}
213
+ style={[styles.column, !editable && styles.blocked, style]}
214
+ >
200
215
  {label ? (
201
216
  <Text style={[styles.label, { color: labelColor }]}>{label}</Text>
202
217
  ) : null}
203
218
  <Pressable
204
- accessibilityHint="Opens a list of options"
219
+ accessibilityHint={listed?.message ?? "Opens a list of options"}
205
220
  accessibilityLabel={label ? `${label}. ${display}` : display}
206
221
  accessibilityRole="button"
207
222
  accessibilityState={{ disabled: !editable }}
@@ -233,8 +248,8 @@ export function Select({
233
248
  ›
234
249
  </Text>
235
250
  </Pressable>
236
- {error ? (
237
- <Text style={[styles.helper, { color: errorColor }]}>{error}</Text>
251
+ {inlineError ? (
252
+ <Text style={[styles.helper, { color: errorColor }]}>{inlineError}</Text>
238
253
  ) : description ? (
239
254
  <Text style={[styles.helper, { color: descriptionColor }]}>
240
255
  {description}