@studio-piot/native-ui 0.2.3 → 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 +2 -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 +1 -1
- package/dist/components/SegmentedControl/SegmentedControl.d.ts.map +1 -1
- package/dist/components/SegmentedControl/SegmentedControl.js +8 -8
- package/dist/components/SegmentedControl/SegmentedControl.js.map +1 -1
- package/dist/components/SegmentedControl/segmentThemeRoles.d.ts +5 -6
- package/dist/components/SegmentedControl/segmentThemeRoles.d.ts.map +1 -1
- package/dist/components/SegmentedControl/segmentThemeRoles.js +5 -6
- package/dist/components/SegmentedControl/segmentThemeRoles.js.map +1 -1
- 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 +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +10 -1
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -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 +23 -13
- 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 +7 -11
- package/src/components/SegmentedControl/segmentThemeRoles.test.ts +9 -7
- package/src/components/SegmentedControl/segmentThemeRoles.ts +5 -6
- 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 +13 -0
- package/src/index.ts +14 -1
|
@@ -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
|
+
}
|
|
@@ -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}>
|
|
@@ -7,6 +7,7 @@ import Animated, {
|
|
|
7
7
|
withTiming,
|
|
8
8
|
} from "react-native-reanimated";
|
|
9
9
|
import { buttonRadiusValue } from "../Button/buttonRadius";
|
|
10
|
+
import { useErrorBannerDismiss } from "../ErrorBanner";
|
|
10
11
|
import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
|
|
11
12
|
import {
|
|
12
13
|
resolveSegmentIndex,
|
|
@@ -26,7 +27,6 @@ export function SegmentedControl<T extends string = string>({
|
|
|
26
27
|
radius = "lg",
|
|
27
28
|
disabled = false,
|
|
28
29
|
backgroundColor: backgroundColorProp,
|
|
29
|
-
borderColor: borderColorProp,
|
|
30
30
|
selectedBackgroundColor: selectedBackgroundColorProp,
|
|
31
31
|
labelColor: labelColorProp,
|
|
32
32
|
selectedLabelColor: selectedLabelColorProp,
|
|
@@ -37,10 +37,6 @@ export function SegmentedControl<T extends string = string>({
|
|
|
37
37
|
backgroundColorProp,
|
|
38
38
|
SEGMENTED_THEME_ROLES.backgroundColor,
|
|
39
39
|
);
|
|
40
|
-
const borderColor = useResolvedFieldColor(
|
|
41
|
-
borderColorProp,
|
|
42
|
-
SEGMENTED_THEME_ROLES.borderColor,
|
|
43
|
-
);
|
|
44
40
|
const selectedBackgroundColor = useResolvedFieldColor(
|
|
45
41
|
selectedBackgroundColorProp,
|
|
46
42
|
SEGMENTED_THEME_ROLES.selectedBackgroundColor,
|
|
@@ -65,6 +61,7 @@ export function SegmentedControl<T extends string = string>({
|
|
|
65
61
|
const widthSV = useSharedValue(0);
|
|
66
62
|
const opacitySV = useSharedValue(0);
|
|
67
63
|
|
|
64
|
+
const dismissBanner = useErrorBannerDismiss();
|
|
68
65
|
const borderRadius = buttonRadiusValue(radius);
|
|
69
66
|
const pillRadius = Math.max(0, borderRadius - SEGMENT_INSET);
|
|
70
67
|
|
|
@@ -113,10 +110,7 @@ export function SegmentedControl<T extends string = string>({
|
|
|
113
110
|
<View
|
|
114
111
|
accessibilityLabel={label}
|
|
115
112
|
accessibilityRole="radiogroup"
|
|
116
|
-
style={[
|
|
117
|
-
styles.track,
|
|
118
|
-
{ backgroundColor, borderColor, borderRadius },
|
|
119
|
-
]}
|
|
113
|
+
style={[styles.track, { backgroundColor, borderRadius }]}
|
|
120
114
|
>
|
|
121
115
|
<Animated.View
|
|
122
116
|
pointerEvents="none"
|
|
@@ -158,7 +152,10 @@ export function SegmentedControl<T extends string = string>({
|
|
|
158
152
|
disabled: blocked,
|
|
159
153
|
}}
|
|
160
154
|
disabled={blocked}
|
|
161
|
-
onPress={() =>
|
|
155
|
+
onPress={() => {
|
|
156
|
+
onChange(option.value);
|
|
157
|
+
dismissBanner();
|
|
158
|
+
}}
|
|
162
159
|
style={({ pressed }) => [
|
|
163
160
|
styles.segment,
|
|
164
161
|
pressed && !blocked && styles.pressed,
|
|
@@ -200,7 +197,6 @@ const styles = StyleSheet.create({
|
|
|
200
197
|
},
|
|
201
198
|
track: {
|
|
202
199
|
width: "100%",
|
|
203
|
-
borderWidth: 1,
|
|
204
200
|
padding: SEGMENT_INSET,
|
|
205
201
|
overflow: "hidden",
|
|
206
202
|
},
|