@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.
- package/README.md +3 -1
- package/dist/components/ErrorBanner/ErrorBanner.d.ts +8 -0
- package/dist/components/ErrorBanner/ErrorBanner.d.ts.map +1 -0
- package/dist/components/ErrorBanner/ErrorBanner.js +136 -0
- package/dist/components/ErrorBanner/ErrorBanner.js.map +1 -0
- package/dist/components/ErrorBanner/errorBannerContext.d.ts +36 -0
- package/dist/components/ErrorBanner/errorBannerContext.d.ts.map +1 -0
- package/dist/components/ErrorBanner/errorBannerContext.js +78 -0
- package/dist/components/ErrorBanner/errorBannerContext.js.map +1 -0
- package/dist/components/ErrorBanner/errorBannerCopy.d.ts +7 -0
- package/dist/components/ErrorBanner/errorBannerCopy.d.ts.map +1 -0
- package/dist/components/ErrorBanner/errorBannerCopy.js +22 -0
- package/dist/components/ErrorBanner/errorBannerCopy.js.map +1 -0
- package/dist/components/ErrorBanner/errorBannerThemeRoles.d.ts +9 -0
- package/dist/components/ErrorBanner/errorBannerThemeRoles.d.ts.map +1 -0
- package/dist/components/ErrorBanner/errorBannerThemeRoles.js +11 -0
- package/dist/components/ErrorBanner/errorBannerThemeRoles.js.map +1 -0
- package/dist/components/ErrorBanner/icons.d.ts +7 -0
- package/dist/components/ErrorBanner/icons.d.ts.map +1 -0
- package/dist/components/ErrorBanner/icons.js +46 -0
- package/dist/components/ErrorBanner/icons.js.map +1 -0
- package/dist/components/ErrorBanner/index.d.ts +6 -0
- package/dist/components/ErrorBanner/index.d.ts.map +1 -0
- package/dist/components/ErrorBanner/index.js +16 -0
- package/dist/components/ErrorBanner/index.js.map +1 -0
- package/dist/components/ErrorBanner/types.d.ts +31 -0
- package/dist/components/ErrorBanner/types.d.ts.map +1 -0
- package/dist/components/ErrorBanner/types.js +3 -0
- package/dist/components/ErrorBanner/types.js.map +1 -0
- package/dist/components/ErrorBanner/useErrorBanner.d.ts +13 -0
- package/dist/components/ErrorBanner/useErrorBanner.d.ts.map +1 -0
- package/dist/components/ErrorBanner/useErrorBanner.js +31 -0
- package/dist/components/ErrorBanner/useErrorBanner.js.map +1 -0
- package/dist/components/Field/fieldChrome.d.ts +1 -1
- package/dist/components/Field/fieldChrome.d.ts.map +1 -1
- package/dist/components/Field/fieldChrome.js +20 -4
- package/dist/components/Field/fieldChrome.js.map +1 -1
- package/dist/components/Field/types.d.ts +2 -0
- package/dist/components/Field/types.d.ts.map +1 -1
- package/dist/components/Form/Form.d.ts +1 -1
- package/dist/components/Form/Form.d.ts.map +1 -1
- package/dist/components/Form/Form.js +36 -23
- package/dist/components/Form/Form.js.map +1 -1
- package/dist/components/Form/types.d.ts +7 -0
- package/dist/components/Form/types.d.ts.map +1 -1
- package/dist/components/RadioField/RadioField.d.ts.map +1 -1
- package/dist/components/RadioField/RadioField.js +6 -1
- package/dist/components/RadioField/RadioField.js.map +1 -1
- package/dist/components/SegmentedControl/SegmentedControl.d.ts +3 -0
- package/dist/components/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/dist/components/SegmentedControl/SegmentedControl.js +181 -0
- package/dist/components/SegmentedControl/SegmentedControl.js.map +1 -0
- package/dist/components/SegmentedControl/index.d.ts +4 -0
- package/dist/components/SegmentedControl/index.d.ts.map +1 -0
- package/dist/components/SegmentedControl/index.js +8 -0
- package/dist/components/SegmentedControl/index.js.map +1 -0
- package/dist/components/SegmentedControl/segmentIndicatorFrame.d.ts +16 -0
- package/dist/components/SegmentedControl/segmentIndicatorFrame.d.ts.map +1 -0
- package/dist/components/SegmentedControl/segmentIndicatorFrame.js +27 -0
- package/dist/components/SegmentedControl/segmentIndicatorFrame.js.map +1 -0
- package/dist/components/SegmentedControl/segmentThemeRoles.d.ts +9 -0
- package/dist/components/SegmentedControl/segmentThemeRoles.d.ts.map +1 -0
- package/dist/components/SegmentedControl/segmentThemeRoles.js +11 -0
- package/dist/components/SegmentedControl/segmentThemeRoles.js.map +1 -0
- package/dist/components/SegmentedControl/types.d.ts +23 -0
- package/dist/components/SegmentedControl/types.d.ts.map +1 -0
- package/dist/components/SegmentedControl/types.js +3 -0
- package/dist/components/SegmentedControl/types.js.map +1 -0
- package/dist/components/Select/Select.d.ts +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.js +11 -4
- package/dist/components/Select/Select.js.map +1 -1
- package/dist/components/Select/types.d.ts +2 -0
- package/dist/components/Select/types.d.ts.map +1 -1
- package/dist/components/Slider/Slider.d.ts.map +1 -1
- package/dist/components/Slider/Slider.js +6 -1
- package/dist/components/Slider/Slider.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +13 -1
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -2
- package/dist/index.js.map +1 -1
- package/docs/components/error-banner.md +85 -0
- package/docs/components/form.md +2 -1
- package/docs/components/segmented-control.md +105 -0
- package/package.json +4 -2
- package/src/components/ErrorBanner/ErrorBanner.tsx +232 -0
- package/src/components/ErrorBanner/errorBannerContext.tsx +140 -0
- package/src/components/ErrorBanner/errorBannerCopy.test.ts +41 -0
- package/src/components/ErrorBanner/errorBannerCopy.ts +22 -0
- package/src/components/ErrorBanner/errorBannerThemeRoles.test.ts +19 -0
- package/src/components/ErrorBanner/errorBannerThemeRoles.ts +9 -0
- package/src/components/ErrorBanner/icons.tsx +38 -0
- package/src/components/ErrorBanner/index.ts +14 -0
- package/src/components/ErrorBanner/types.ts +31 -0
- package/src/components/ErrorBanner/useErrorBanner.ts +33 -0
- package/src/components/Field/fieldChrome.tsx +33 -7
- package/src/components/Field/types.ts +2 -0
- package/src/components/Form/Form.tsx +56 -2
- package/src/components/Form/types.ts +7 -0
- package/src/components/RadioField/RadioField.tsx +6 -1
- package/src/components/SegmentedControl/SegmentedControl.tsx +230 -0
- package/src/components/SegmentedControl/index.ts +6 -0
- package/src/components/SegmentedControl/segmentIndicatorFrame.test.ts +39 -0
- package/src/components/SegmentedControl/segmentIndicatorFrame.ts +31 -0
- package/src/components/SegmentedControl/segmentThemeRoles.test.ts +19 -0
- package/src/components/SegmentedControl/segmentThemeRoles.ts +9 -0
- package/src/components/SegmentedControl/types.ts +24 -0
- package/src/components/Select/Select.tsx +20 -5
- package/src/components/Select/types.ts +2 -0
- package/src/components/Slider/Slider.tsx +6 -1
- package/src/components/index.ts +20 -0
- 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
|
|
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={
|
|
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={
|
|
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
|
-
{
|
|
171
|
-
<Text style={[styles.helper, { color: errorColor }]}>{
|
|
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={
|
|
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={() =>
|
|
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,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
|
|
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
|
|
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
|
-
{
|
|
237
|
-
<Text style={[styles.helper, { color: errorColor }]}>{
|
|
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}
|