@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studio-piot/native-ui",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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": {
@@ -58,7 +58,8 @@
58
58
  "react-native-keyboard-controller": ">=1.14.0",
59
59
  "react-native-reanimated": "^4.5.1",
60
60
  "react-native-safe-area-context": ">=4.0.0",
61
- "react-native-screens": ">=4.0.0"
61
+ "react-native-screens": ">=4.0.0",
62
+ "react-native-svg": ">=15.0.0"
62
63
  },
63
64
  "scripts": {
64
65
  "test": "node --experimental-strip-types --test 'src/**/*.test.ts'",
@@ -78,6 +79,7 @@
78
79
  "react-native-reanimated": "^4.5.1",
79
80
  "react-native-safe-area-context": "~5.7.0",
80
81
  "react-native-screens": "~4.26.0",
82
+ "react-native-svg": "^15.15.4",
81
83
  "typescript": "^5.9.3"
82
84
  }
83
85
  }
@@ -0,0 +1,232 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ AccessibilityInfo,
4
+ Animated,
5
+ Easing,
6
+ Pressable,
7
+ StatusBar,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ type LayoutChangeEvent,
12
+ } from "react-native";
13
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
14
+ import { buttonRadiusValue } from "../Button/buttonRadius";
15
+ import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
16
+ import { screenPaddingHorizontal } from "../../layout/screenPadding";
17
+ import { errorBannerTitle } from "./errorBannerCopy";
18
+ import { ERROR_BANNER_THEME_ROLES } from "./errorBannerThemeRoles";
19
+ import { ArrowRight, Megaphone } from "./icons";
20
+ import type { ErrorBannerProps } from "./types";
21
+
22
+ const BOTTOM_RADIUS = buttonRadiusValue("xl");
23
+
24
+ /**
25
+ * Screen-wide field-error banner. Mount it once and drive `open` so the
26
+ * exit animation can play. It is not tied to a form shell: pass `errors`,
27
+ * `onJump`, and `onDismiss`. `Form` does that by default.
28
+ */
29
+ export function ErrorBanner({
30
+ open,
31
+ errors,
32
+ onJump,
33
+ onDismiss,
34
+ onHeightChange,
35
+ safeAreaTop,
36
+ paddingHorizontal = screenPaddingHorizontal,
37
+ backgroundColor: backgroundColorProp,
38
+ textColor: textColorProp,
39
+ secondaryColor: secondaryColorProp,
40
+ dividerColor: dividerColorProp,
41
+ accentColor: accentColorProp,
42
+ }: ErrorBannerProps) {
43
+ const insets = useSafeAreaInsets();
44
+ const topInset = safeAreaTop ?? insets.top;
45
+ const backgroundColor = useResolvedFieldColor(
46
+ backgroundColorProp,
47
+ ERROR_BANNER_THEME_ROLES.backgroundColor,
48
+ );
49
+ const textColor = useResolvedFieldColor(
50
+ textColorProp,
51
+ ERROR_BANNER_THEME_ROLES.textColor,
52
+ );
53
+ const secondaryColor = useResolvedFieldColor(
54
+ secondaryColorProp,
55
+ ERROR_BANNER_THEME_ROLES.secondaryColor,
56
+ );
57
+ const dividerColor = useResolvedFieldColor(
58
+ dividerColorProp,
59
+ ERROR_BANNER_THEME_ROLES.dividerColor,
60
+ );
61
+ const accentColor = useResolvedFieldColor(
62
+ accentColorProp,
63
+ ERROR_BANNER_THEME_ROLES.accentColor,
64
+ );
65
+
66
+ const [height, setHeight] = useState(400);
67
+ const [reduceMotion, setReduceMotion] = useState(false);
68
+ const progress = useRef(new Animated.Value(0)).current;
69
+ const count = errors.length;
70
+ const title = errorBannerTitle(count);
71
+ const titleRef = useRef(title);
72
+ titleRef.current = title;
73
+
74
+ useEffect(() => {
75
+ AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
76
+ }, []);
77
+
78
+ useEffect(() => {
79
+ Animated.timing(progress, {
80
+ toValue: open ? 1 : 0,
81
+ duration: reduceMotion ? 120 : open ? 260 : 200,
82
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
83
+ useNativeDriver: true,
84
+ }).start();
85
+ if (open) {
86
+ AccessibilityInfo.announceForAccessibility(titleRef.current);
87
+ }
88
+ }, [open, progress, reduceMotion]);
89
+
90
+ const motion = reduceMotion
91
+ ? { opacity: progress }
92
+ : {
93
+ transform: [
94
+ {
95
+ translateY: progress.interpolate({
96
+ inputRange: [0, 1],
97
+ outputRange: [-(height + 24), 0],
98
+ }),
99
+ },
100
+ ],
101
+ };
102
+
103
+ const onLayout = (event: LayoutChangeEvent) => {
104
+ const next = Math.round(event.nativeEvent.layout.height);
105
+ setHeight((current) => (current === next ? current : next));
106
+ onHeightChange?.(next);
107
+ };
108
+
109
+ return (
110
+ <>
111
+ {open ? <StatusBar animated barStyle="light-content" /> : null}
112
+ <Animated.View
113
+ accessibilityRole="alert"
114
+ accessibilityElementsHidden={!open}
115
+ importantForAccessibility={open ? "auto" : "no-hide-descendants"}
116
+ onLayout={onLayout}
117
+ pointerEvents={open ? "auto" : "none"}
118
+ style={[
119
+ styles.banner,
120
+ {
121
+ backgroundColor,
122
+ paddingTop: topInset + 8,
123
+ paddingHorizontal,
124
+ borderBottomLeftRadius: BOTTOM_RADIUS,
125
+ borderBottomRightRadius: BOTTOM_RADIUS,
126
+ },
127
+ motion,
128
+ ]}
129
+ >
130
+ <View style={styles.header}>
131
+ <View
132
+ accessibilityElementsHidden
133
+ importantForAccessibility="no-hide-descendants"
134
+ style={[styles.iconDisc, { backgroundColor: accentColor }]}
135
+ >
136
+ <Megaphone color={backgroundColor} />
137
+ </View>
138
+ <Text style={[styles.title, { color: textColor }]}>{title}</Text>
139
+ <Pressable
140
+ accessibilityLabel="Dismiss"
141
+ accessibilityRole="button"
142
+ hitSlop={12}
143
+ onPress={onDismiss}
144
+ >
145
+ <Text style={[styles.dismiss, { color: secondaryColor }]}>✕</Text>
146
+ </Pressable>
147
+ </View>
148
+ <View style={styles.rows}>
149
+ {errors.map((error) => (
150
+ <Pressable
151
+ key={error.field}
152
+ accessibilityLabel={`${error.short}. Go to field.`}
153
+ accessibilityRole="button"
154
+ hitSlop={{ top: 4, bottom: 4 }}
155
+ onPress={() => onJump(error.field)}
156
+ style={({ pressed }) => [
157
+ styles.row,
158
+ { borderTopColor: dividerColor },
159
+ pressed && styles.pressed,
160
+ ]}
161
+ >
162
+ <Text style={[styles.rowLabel, { color: textColor }]}>
163
+ {error.short}
164
+ </Text>
165
+ <View
166
+ accessibilityElementsHidden
167
+ importantForAccessibility="no-hide-descendants"
168
+ >
169
+ <ArrowRight color={secondaryColor} />
170
+ </View>
171
+ </Pressable>
172
+ ))}
173
+ </View>
174
+ </Animated.View>
175
+ </>
176
+ );
177
+ }
178
+
179
+ const styles = StyleSheet.create({
180
+ banner: {
181
+ position: "absolute",
182
+ top: 0,
183
+ left: 0,
184
+ right: 0,
185
+ zIndex: 100,
186
+ paddingBottom: 10,
187
+ shadowColor: "#22201D",
188
+ shadowOpacity: 0.25,
189
+ shadowRadius: 12,
190
+ shadowOffset: { width: 0, height: 8 },
191
+ elevation: 12,
192
+ },
193
+ header: {
194
+ flexDirection: "row",
195
+ alignItems: "center",
196
+ gap: 10,
197
+ },
198
+ iconDisc: {
199
+ width: 28,
200
+ height: 28,
201
+ borderRadius: 14,
202
+ alignItems: "center",
203
+ justifyContent: "center",
204
+ },
205
+ title: {
206
+ flex: 1,
207
+ fontSize: 15,
208
+ lineHeight: 20,
209
+ fontWeight: "600",
210
+ },
211
+ dismiss: {
212
+ fontSize: 15,
213
+ },
214
+ rows: {
215
+ marginTop: 6,
216
+ },
217
+ row: {
218
+ flexDirection: "row",
219
+ alignItems: "center",
220
+ minHeight: 36,
221
+ marginLeft: 38,
222
+ borderTopWidth: 1,
223
+ },
224
+ pressed: {
225
+ opacity: 0.6,
226
+ },
227
+ rowLabel: {
228
+ flex: 1,
229
+ fontSize: 15,
230
+ lineHeight: 20,
231
+ },
232
+ });
@@ -0,0 +1,140 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ type ReactNode,
9
+ type RefObject,
10
+ } from "react";
11
+ import { View, type ReactNativeElement, type ScrollView } from "react-native";
12
+ import { fieldScrollY } from "./errorBannerCopy";
13
+ import type { FieldError } from "./types";
14
+
15
+ export type ErrorBannerAnchor = {
16
+ view: View;
17
+ focus?: () => void;
18
+ };
19
+
20
+ type ErrorBannerContextValue = {
21
+ errors: readonly FieldError[];
22
+ register: (field: string, anchor: ErrorBannerAnchor | null) => void;
23
+ dismiss: () => void;
24
+ jump: (field: string) => void;
25
+ };
26
+
27
+ const ErrorBannerContext = createContext<ErrorBannerContextValue | null>(null);
28
+
29
+ export function useErrorBannerContext(): ErrorBannerContextValue | null {
30
+ return useContext(ErrorBannerContext);
31
+ }
32
+
33
+ export function useErrorBannerDismiss(): () => void {
34
+ const ctx = useContext(ErrorBannerContext);
35
+ return ctx?.dismiss ?? (() => undefined);
36
+ }
37
+
38
+ /**
39
+ * Registers a field so a banner row can scroll it into view.
40
+ * No-ops outside `ErrorBannerProvider` (and `Form`, which provides one).
41
+ */
42
+ export function useErrorBannerField(
43
+ name: string | undefined,
44
+ focus?: () => void,
45
+ ) {
46
+ const ctx = useContext(ErrorBannerContext);
47
+ const viewRef = useRef<View>(null);
48
+ const focusRef = useRef(focus);
49
+ focusRef.current = focus;
50
+
51
+ useEffect(() => {
52
+ if (!name || !ctx) {
53
+ return;
54
+ }
55
+ const view = viewRef.current;
56
+ if (!view) {
57
+ return;
58
+ }
59
+ ctx.register(name, {
60
+ view,
61
+ focus: () => focusRef.current?.(),
62
+ });
63
+ return () => ctx.register(name, null);
64
+ }, [ctx, name]);
65
+
66
+ const listed = name
67
+ ? ctx?.errors.find((error) => error.field === name)
68
+ : undefined;
69
+
70
+ return { viewRef, listed };
71
+ }
72
+
73
+ export function ErrorBannerProvider({
74
+ children,
75
+ scrollRef,
76
+ errors,
77
+ onDismiss,
78
+ bannerHeightRef,
79
+ }: {
80
+ children: ReactNode;
81
+ scrollRef: RefObject<ScrollView | null>;
82
+ errors: readonly FieldError[];
83
+ onDismiss: () => void;
84
+ bannerHeightRef: RefObject<number>;
85
+ }) {
86
+ const anchors = useRef(new Map<string, ErrorBannerAnchor>());
87
+
88
+ const register = useCallback((field: string, anchor: ErrorBannerAnchor | null) => {
89
+ if (!anchor) {
90
+ anchors.current.delete(field);
91
+ return;
92
+ }
93
+ anchors.current.set(field, anchor);
94
+ }, []);
95
+
96
+ const jump = useCallback((field: string) => {
97
+ const anchor = anchors.current.get(field);
98
+ const scroll = scrollRef.current;
99
+ if (!anchor || !scroll) {
100
+ return;
101
+ }
102
+ anchor.view.measureLayout(
103
+ scroll as unknown as ReactNativeElement,
104
+ (_x, y) => {
105
+ scroll.scrollTo({
106
+ y: fieldScrollY(y, bannerHeightRef.current ?? 0),
107
+ animated: true,
108
+ });
109
+ anchor.focus?.();
110
+ },
111
+ () => undefined,
112
+ );
113
+ }, [bannerHeightRef, scrollRef]);
114
+
115
+ const value = useMemo(
116
+ () => ({ errors, register, dismiss: onDismiss, jump }),
117
+ [errors, jump, onDismiss, register],
118
+ );
119
+
120
+ return (
121
+ <ErrorBannerContext.Provider value={value}>
122
+ {children}
123
+ </ErrorBannerContext.Provider>
124
+ );
125
+ }
126
+
127
+ export function ErrorBannerField({
128
+ name,
129
+ children,
130
+ }: {
131
+ name: string;
132
+ children: ReactNode;
133
+ }) {
134
+ const { viewRef } = useErrorBannerField(name);
135
+ return (
136
+ <View ref={viewRef} collapsable={false}>
137
+ {children}
138
+ </View>
139
+ );
140
+ }
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ errorBannerOpen,
5
+ errorBannerTitle,
6
+ fieldScrollY,
7
+ } from "./errorBannerCopy.ts";
8
+
9
+ describe("errorBannerTitle", () => {
10
+ it("uses the singular line for one error", () => {
11
+ assert.equal(errorBannerTitle(1), "Nearly there. One thing to review");
12
+ });
13
+
14
+ it("counts several errors", () => {
15
+ assert.equal(errorBannerTitle(3), "Nearly there. 3 things to review");
16
+ });
17
+ });
18
+
19
+ describe("errorBannerOpen", () => {
20
+ it("stays closed with no errors", () => {
21
+ assert.equal(errorBannerOpen(0, false), false);
22
+ });
23
+
24
+ it("opens while errors remain and the runner has not dismissed it", () => {
25
+ assert.equal(errorBannerOpen(2, false), true);
26
+ });
27
+
28
+ it("closes when dismissed even if errors remain", () => {
29
+ assert.equal(errorBannerOpen(2, true), false);
30
+ });
31
+ });
32
+
33
+ describe("fieldScrollY", () => {
34
+ it("places the field below the banner", () => {
35
+ assert.equal(fieldScrollY(400, 120), 264);
36
+ });
37
+
38
+ it("does not scroll above the content", () => {
39
+ assert.equal(fieldScrollY(20, 120), 0);
40
+ });
41
+ });
@@ -0,0 +1,22 @@
1
+ /** Gap kept between the banner and a field scrolled into view. */
2
+ export const ERROR_BANNER_SCROLL_GAP = 16;
3
+
4
+ export function errorBannerTitle(count: number): string {
5
+ if (count === 1) {
6
+ return "Nearly there. One thing to review";
7
+ }
8
+ return `Nearly there. ${count} things to review`;
9
+ }
10
+
11
+ export function errorBannerOpen(errorCount: number, dismissed: boolean): boolean {
12
+ return errorCount > 0 && !dismissed;
13
+ }
14
+
15
+ /** Content y that places the field just below the banner. */
16
+ export function fieldScrollY(
17
+ fieldY: number,
18
+ bannerHeight: number,
19
+ gap = ERROR_BANNER_SCROLL_GAP,
20
+ ): number {
21
+ return Math.max(0, fieldY - bannerHeight - gap);
22
+ }
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { ERROR_BANNER_THEME_ROLES } from "./errorBannerThemeRoles.ts";
4
+
5
+ describe("ERROR_BANNER_THEME_ROLES", () => {
6
+ it("maps colour props to banner roles", () => {
7
+ assert.equal(
8
+ ERROR_BANNER_THEME_ROLES.backgroundColor,
9
+ "errorBannerBackground",
10
+ );
11
+ assert.equal(ERROR_BANNER_THEME_ROLES.textColor, "errorBannerText");
12
+ assert.equal(
13
+ ERROR_BANNER_THEME_ROLES.secondaryColor,
14
+ "errorBannerSecondary",
15
+ );
16
+ assert.equal(ERROR_BANNER_THEME_ROLES.dividerColor, "errorBannerDivider");
17
+ assert.equal(ERROR_BANNER_THEME_ROLES.accentColor, "errorBannerAccent");
18
+ });
19
+ });
@@ -0,0 +1,9 @@
1
+ export const ERROR_BANNER_THEME_ROLES = {
2
+ backgroundColor: "errorBannerBackground",
3
+ textColor: "errorBannerText",
4
+ secondaryColor: "errorBannerSecondary",
5
+ dividerColor: "errorBannerDivider",
6
+ accentColor: "errorBannerAccent",
7
+ } as const;
8
+
9
+ export type ErrorBannerThemeColorProp = keyof typeof ERROR_BANNER_THEME_ROLES;
@@ -0,0 +1,38 @@
1
+ import Svg, { Line, Path, Polyline } from "react-native-svg";
2
+
3
+ export function Megaphone({ color }: { color: string }) {
4
+ return (
5
+ <Svg
6
+ width={16}
7
+ height={16}
8
+ viewBox="0 0 24 24"
9
+ fill="none"
10
+ stroke={color}
11
+ strokeWidth={1.8}
12
+ >
13
+ <Path
14
+ strokeLinecap="round"
15
+ strokeLinejoin="round"
16
+ d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 0 0 1.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 0 1 0 3.46"
17
+ />
18
+ </Svg>
19
+ );
20
+ }
21
+
22
+ export function ArrowRight({ color }: { color: string }) {
23
+ return (
24
+ <Svg
25
+ width={18}
26
+ height={18}
27
+ viewBox="0 0 24 24"
28
+ fill="none"
29
+ stroke={color}
30
+ strokeWidth={2.25}
31
+ strokeLinecap="round"
32
+ strokeLinejoin="round"
33
+ >
34
+ <Line x1={5} y1={12} x2={19} y2={12} />
35
+ <Polyline points="13 6 19 12 13 18" />
36
+ </Svg>
37
+ );
38
+ }
@@ -0,0 +1,14 @@
1
+ export { ErrorBanner } from "./ErrorBanner";
2
+ export {
3
+ ErrorBannerField,
4
+ ErrorBannerProvider,
5
+ useErrorBannerContext,
6
+ useErrorBannerDismiss,
7
+ useErrorBannerField,
8
+ } from "./errorBannerContext";
9
+ export {
10
+ ERROR_BANNER_THEME_ROLES,
11
+ type ErrorBannerThemeColorProp,
12
+ } from "./errorBannerThemeRoles";
13
+ export { useErrorBanner } from "./useErrorBanner";
14
+ export type { ErrorBannerProps, FieldError } from "./types";
@@ -0,0 +1,31 @@
1
+ export type FieldError = {
2
+ /** Matches the field `name`. */
3
+ field: string;
4
+ /** Banner row, about 40 characters. */
5
+ short: string;
6
+ /** Fuller sentence for the field's accessibility hint. */
7
+ message: string;
8
+ };
9
+
10
+ export type ErrorBannerProps = {
11
+ open: boolean;
12
+ errors: readonly FieldError[];
13
+ /** Scroll the field into view. The banner stays open until dismiss or an edit. */
14
+ onJump: (field: string) => void;
15
+ onDismiss: () => void;
16
+ /** Reported so the host can scroll the field clear of the banner. */
17
+ onHeightChange?: (height: number) => void;
18
+ /** Override `useSafeAreaInsets().top`. */
19
+ safeAreaTop?: number;
20
+ paddingHorizontal?: number;
21
+ /** Defaults to theme role `errorBannerBackground`. */
22
+ backgroundColor?: string;
23
+ /** Defaults to theme role `errorBannerText`. */
24
+ textColor?: string;
25
+ /** Defaults to theme role `errorBannerSecondary`. Dismiss and arrows. */
26
+ secondaryColor?: string;
27
+ /** Defaults to theme role `errorBannerDivider`. */
28
+ dividerColor?: string;
29
+ /** Defaults to theme role `errorBannerAccent`. Megaphone disc. */
30
+ accentColor?: string;
31
+ };
@@ -0,0 +1,33 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { errorBannerOpen } from "./errorBannerCopy";
3
+ import type { FieldError } from "./types";
4
+
5
+ /**
6
+ * Open / dismiss state for a field-error banner.
7
+ * Call `present` when the runner submits. Call `dismiss` when they close it
8
+ * or edit the form. A cleared error list resets dismiss so the next failure
9
+ * can show the banner again.
10
+ */
11
+ export function useErrorBanner(errors: readonly FieldError[]) {
12
+ const [dismissed, setDismissed] = useState(false);
13
+
14
+ useEffect(() => {
15
+ if (errors.length === 0) {
16
+ setDismissed(false);
17
+ }
18
+ }, [errors.length]);
19
+
20
+ const dismiss = useCallback(() => {
21
+ setDismissed(true);
22
+ }, []);
23
+
24
+ const present = useCallback(() => {
25
+ setDismissed(false);
26
+ }, []);
27
+
28
+ return {
29
+ open: errorBannerOpen(errors.length, dismissed),
30
+ dismiss,
31
+ present,
32
+ };
33
+ }