react-native-terra-ui 0.13.6 → 0.13.8
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/CHANGELOG.md +18 -0
- package/lib/module/components/input/hooks/use-focus-state.js +25 -0
- package/lib/module/components/input/hooks/use-focus-state.js.map +1 -0
- package/lib/module/components/input/parts/InputField.js +53 -31
- package/lib/module/components/input/parts/InputField.js.map +1 -1
- package/lib/module/components/input/variants/SearchInput/SearchCancelButton.js +93 -0
- package/lib/module/components/input/variants/SearchInput/SearchCancelButton.js.map +1 -0
- package/lib/module/components/input/variants/SearchInput/SearchClearButton.js +52 -0
- package/lib/module/components/input/variants/SearchInput/SearchClearButton.js.map +1 -0
- package/lib/module/components/input/variants/SearchInput/index.js +85 -30
- package/lib/module/components/input/variants/SearchInput/index.js.map +1 -1
- package/lib/module/components/input/variants/SearchInput/utils.js +9 -0
- package/lib/module/components/input/variants/SearchInput/utils.js.map +1 -0
- package/lib/module/components/screen/ScreenList.js +5 -1
- package/lib/module/components/screen/ScreenList.js.map +1 -1
- package/lib/module/components/screen/ScreenScrollView.js +5 -1
- package/lib/module/components/screen/ScreenScrollView.js.map +1 -1
- package/lib/module/theme/tokens/dark.js +16 -16
- package/lib/module/theme/tokens/dark.js.map +1 -1
- package/lib/module/theme/tokens/light.js +4 -4
- package/lib/module/theme/tokens/light.js.map +1 -1
- package/lib/module/theme/tokens/primitives.js +12 -0
- package/lib/module/theme/tokens/primitives.js.map +1 -1
- package/lib/typescript/src/components/input/hooks/use-focus-state.d.ts +15 -0
- package/lib/typescript/src/components/input/hooks/use-focus-state.d.ts.map +1 -0
- package/lib/typescript/src/components/input/parts/InputField.d.ts +3 -1
- package/lib/typescript/src/components/input/parts/InputField.d.ts.map +1 -1
- package/lib/typescript/src/components/input/types.d.ts +11 -1
- package/lib/typescript/src/components/input/types.d.ts.map +1 -1
- package/lib/typescript/src/components/input/variants/SearchInput/SearchCancelButton.d.ts +19 -0
- package/lib/typescript/src/components/input/variants/SearchInput/SearchCancelButton.d.ts.map +1 -0
- package/lib/typescript/src/components/input/variants/SearchInput/SearchClearButton.d.ts +13 -0
- package/lib/typescript/src/components/input/variants/SearchInput/SearchClearButton.d.ts.map +1 -0
- package/lib/typescript/src/components/input/variants/SearchInput/index.d.ts +15 -1
- package/lib/typescript/src/components/input/variants/SearchInput/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/variants/SearchInput/utils.d.ts +4 -0
- package/lib/typescript/src/components/input/variants/SearchInput/utils.d.ts.map +1 -0
- package/lib/typescript/src/components/screen/ScreenList.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/ScreenScrollView.d.ts.map +1 -1
- package/lib/typescript/src/theme/tokens/primitives.d.ts +11 -0
- package/lib/typescript/src/theme/tokens/primitives.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/input/hooks/use-focus-state.ts +32 -0
- package/src/components/input/parts/InputField.tsx +45 -31
- package/src/components/input/types.ts +11 -1
- package/src/components/input/variants/SearchInput/SearchCancelButton.tsx +109 -0
- package/src/components/input/variants/SearchInput/SearchClearButton.tsx +58 -0
- package/src/components/input/variants/SearchInput/index.tsx +92 -24
- package/src/components/input/variants/SearchInput/utils.ts +4 -0
- package/src/components/screen/ScreenList.tsx +3 -0
- package/src/components/screen/ScreenScrollView.tsx +3 -0
- package/src/theme/tokens/dark.ts +16 -16
- package/src/theme/tokens/light.ts +4 -4
- package/src/theme/tokens/primitives.ts +12 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ComponentRef, forwardRef
|
|
1
|
+
import { type ComponentRef, forwardRef } from "react";
|
|
2
2
|
import { TextInput, View } from "react-native";
|
|
3
3
|
|
|
4
4
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
@@ -6,8 +6,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
|
6
6
|
import type { ColorToken, RadiusKey } from "#theme/types";
|
|
7
7
|
import { resolveThemeColor } from "#utils/resolve-theme-color";
|
|
8
8
|
import { FONT_WEIGHT_VALUE, SYSTEM_FONT } from "#utils/typography";
|
|
9
|
+
import { useFocusState } from "../hooks/use-focus-state";
|
|
9
10
|
import type { InputBaseProps, InputSize } from "../types";
|
|
10
11
|
|
|
12
|
+
/** Container height per size, in dp. Sibling controls sized to match read from here. */
|
|
13
|
+
export const FIELD_HEIGHT: Record<InputSize, number> = { sm: 36, md: 44, lg: 52 };
|
|
14
|
+
|
|
11
15
|
/** Typographic role of the text, per size. */
|
|
12
16
|
const TEXT_VARIANT: Record<InputSize, "subheadline" | "callout" | "body"> = {
|
|
13
17
|
sm: "subheadline",
|
|
@@ -25,6 +29,9 @@ export const InputField = forwardRef<ComponentRef<typeof TextInput>, InputBasePr
|
|
|
25
29
|
size = "md",
|
|
26
30
|
isDisabled = false,
|
|
27
31
|
isInvalid = false,
|
|
32
|
+
focusBorderColor,
|
|
33
|
+
focusBorderWidth,
|
|
34
|
+
hasFocusGlow = false,
|
|
28
35
|
leading,
|
|
29
36
|
trailing,
|
|
30
37
|
background = "surface.sunken",
|
|
@@ -36,32 +43,24 @@ export const InputField = forwardRef<ComponentRef<typeof TextInput>, InputBasePr
|
|
|
36
43
|
},
|
|
37
44
|
ref
|
|
38
45
|
) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// as a prop rather than as a style.
|
|
42
|
-
const [isFocused, setIsFocused] = useState(false);
|
|
43
|
-
const { theme } = useUnistyles();
|
|
44
|
-
|
|
45
|
-
const handleFocus = useCallback<NonNullable<InputBaseProps["onFocus"]>>(
|
|
46
|
-
(event) => {
|
|
47
|
-
setIsFocused(true);
|
|
48
|
-
onFocus?.(event);
|
|
49
|
-
},
|
|
50
|
-
[onFocus]
|
|
51
|
-
);
|
|
46
|
+
const { isFocused, handleFocus, handleBlur } = useFocusState(onFocus, onBlur);
|
|
47
|
+
const showsFocus = isFocused && !isDisabled;
|
|
52
48
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
onBlur?.(event);
|
|
57
|
-
},
|
|
58
|
-
[onBlur]
|
|
59
|
-
);
|
|
49
|
+
// The placeholder colour has to leave the stylesheet because `TextInput` takes
|
|
50
|
+
// it as a prop rather than as a style.
|
|
51
|
+
const { theme } = useUnistyles();
|
|
60
52
|
|
|
61
|
-
styles.useVariants({ size, disabled: isDisabled, invalid: isInvalid
|
|
53
|
+
styles.useVariants({ size, disabled: isDisabled, invalid: isInvalid });
|
|
62
54
|
|
|
63
55
|
return (
|
|
64
|
-
<View
|
|
56
|
+
<View
|
|
57
|
+
style={[
|
|
58
|
+
styles.container,
|
|
59
|
+
styles.fill(background, radius),
|
|
60
|
+
showsFocus && styles.focus(focusBorderColor, focusBorderWidth, hasFocusGlow, isInvalid),
|
|
61
|
+
style,
|
|
62
|
+
]}
|
|
63
|
+
>
|
|
65
64
|
{leading}
|
|
66
65
|
<TextInput
|
|
67
66
|
ref={ref}
|
|
@@ -97,13 +96,9 @@ const styles = StyleSheet.create((theme) => {
|
|
|
97
96
|
borderColor: theme.color["border.subtle"],
|
|
98
97
|
variants: {
|
|
99
98
|
size: {
|
|
100
|
-
sm: { height:
|
|
101
|
-
md: { height:
|
|
102
|
-
lg: { height:
|
|
103
|
-
},
|
|
104
|
-
focused: {
|
|
105
|
-
true: { borderColor: theme.color["accent.border"] },
|
|
106
|
-
false: {},
|
|
99
|
+
sm: { height: FIELD_HEIGHT.sm, paddingHorizontal: theme.spacing["3"], gap: 6 },
|
|
100
|
+
md: { height: FIELD_HEIGHT.md, paddingHorizontal: theme.spacing["3"], gap: 6 },
|
|
101
|
+
lg: { height: FIELD_HEIGHT.lg, paddingHorizontal: theme.spacing["4"], gap: 6 },
|
|
107
102
|
},
|
|
108
103
|
invalid: {
|
|
109
104
|
true: { borderColor: theme.color["danger.text"] },
|
|
@@ -115,6 +110,26 @@ const styles = StyleSheet.create((theme) => {
|
|
|
115
110
|
},
|
|
116
111
|
},
|
|
117
112
|
},
|
|
113
|
+
// Only the keys that apply are set, so an unset option leaves the resting
|
|
114
|
+
// border alone rather than overriding it with `undefined`. An invalid field
|
|
115
|
+
// keeps its danger border and skips the glow, so the error stays the loudest
|
|
116
|
+
// signal; only the width still follows focus.
|
|
117
|
+
focus: (
|
|
118
|
+
color: ColorToken | undefined,
|
|
119
|
+
width: number | undefined,
|
|
120
|
+
hasGlow: boolean,
|
|
121
|
+
isInvalid: boolean
|
|
122
|
+
) => {
|
|
123
|
+
const focusColor = resolveThemeColor(color ?? "border.default", theme);
|
|
124
|
+
return {
|
|
125
|
+
...(width !== undefined && { borderWidth: width }),
|
|
126
|
+
...(color && !isInvalid && { borderColor: focusColor }),
|
|
127
|
+
...(hasGlow &&
|
|
128
|
+
!isInvalid && {
|
|
129
|
+
boxShadow: [{ offsetX: 0, offsetY: 0, blurRadius: 4, spreadDistance: 1, color: focusColor }],
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
},
|
|
118
133
|
// `background` is an open `ColorToken` and `radius` an eight-step scale, so
|
|
119
134
|
// both resolve in a dynamic function rather than enumerating as variants.
|
|
120
135
|
fill: (background: ColorToken, radius: RadiusKey) => ({
|
|
@@ -137,7 +152,6 @@ const styles = StyleSheet.create((theme) => {
|
|
|
137
152
|
md: { fontSize: theme.typography.variants[TEXT_VARIANT.md].fontSize },
|
|
138
153
|
lg: { fontSize: theme.typography.variants[TEXT_VARIANT.lg].fontSize },
|
|
139
154
|
},
|
|
140
|
-
focused: { true: {}, false: {} },
|
|
141
155
|
invalid: { true: {}, false: {} },
|
|
142
156
|
disabled: { true: {}, false: {} },
|
|
143
157
|
},
|
|
@@ -24,8 +24,18 @@ export interface InputBaseProps extends Omit<TextInputProps, "style" | "editable
|
|
|
24
24
|
trailing?: ReactNode;
|
|
25
25
|
/** Fill token. Defaults to `'surface.sunken'`. */
|
|
26
26
|
background?: ColorToken;
|
|
27
|
-
/** Corner radius. Defaults to `'
|
|
27
|
+
/** Corner radius. Defaults to `'lg'`; `Input.Search` uses `'full'`. */
|
|
28
28
|
radius?: RadiusKey;
|
|
29
|
+
/** Border colour while focused. Defaults to `undefined` — the resting border stays. */
|
|
30
|
+
focusBorderColor?: ColorToken;
|
|
31
|
+
/** Border width while focused, in dp. Defaults to `undefined` — the resting hairline stays. */
|
|
32
|
+
focusBorderWidth?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Surround the field with a soft glow while focused, tinted by
|
|
35
|
+
* `focusBorderColor` or `border.default` when that is unset. Defaults to
|
|
36
|
+
* `false`; `Input.Search` turns it on.
|
|
37
|
+
*/
|
|
38
|
+
hasFocusGlow?: boolean;
|
|
29
39
|
/** Merged over the container's own style. */
|
|
30
40
|
style?: StyleProp<ViewStyle>;
|
|
31
41
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import Animated, {
|
|
4
|
+
Easing,
|
|
5
|
+
interpolate,
|
|
6
|
+
useAnimatedStyle,
|
|
7
|
+
useSharedValue,
|
|
8
|
+
withTiming,
|
|
9
|
+
} from "react-native-reanimated";
|
|
10
|
+
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
11
|
+
|
|
12
|
+
import type { ColorToken } from "#theme/types";
|
|
13
|
+
import { resolveThemeColor } from "#utils/resolve-theme-color";
|
|
14
|
+
import { Icon } from "../../../icon";
|
|
15
|
+
import { PressableOpacity } from "../../../pressable";
|
|
16
|
+
import { FIELD_HEIGHT } from "../../parts/InputField";
|
|
17
|
+
import type { InputSize } from "../../types";
|
|
18
|
+
import { ICON_SIZE } from "./utils";
|
|
19
|
+
|
|
20
|
+
/** How long the button takes to slide in and the field to make room, in ms. */
|
|
21
|
+
const TRANSITION_MS = 250;
|
|
22
|
+
|
|
23
|
+
/** Scale the button grows from, so it pops in rather than only fading. */
|
|
24
|
+
const START_SCALE = 0.6;
|
|
25
|
+
|
|
26
|
+
/** The x runs a touch larger than the field's glyphs; it has a circle to itself. */
|
|
27
|
+
const GLYPH_EXTRA = 2;
|
|
28
|
+
|
|
29
|
+
interface SearchCancelButtonProps {
|
|
30
|
+
/** Slides the button in when `true` and out when `false`. */
|
|
31
|
+
isVisible: boolean;
|
|
32
|
+
size: InputSize;
|
|
33
|
+
/** Fill token, matched to the field it sits beside. */
|
|
34
|
+
background: ColorToken;
|
|
35
|
+
accessibilityLabel: string;
|
|
36
|
+
onPress: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A round button the height of the field, in a slot that opens and closes
|
|
41
|
+
* beside it. The slot stays mounted so it can animate out; while collapsed it
|
|
42
|
+
* is hidden from touch and from screen readers.
|
|
43
|
+
*/
|
|
44
|
+
export function SearchCancelButton({
|
|
45
|
+
isVisible,
|
|
46
|
+
size,
|
|
47
|
+
background,
|
|
48
|
+
accessibilityLabel,
|
|
49
|
+
onPress,
|
|
50
|
+
}: SearchCancelButtonProps) {
|
|
51
|
+
// One progress value drives the slot's width, so the field (flex: 1) gives
|
|
52
|
+
// up its room frame by frame instead of jumping when the button appears.
|
|
53
|
+
const { theme } = useUnistyles();
|
|
54
|
+
const progress = useSharedValue(0);
|
|
55
|
+
const diameter = FIELD_HEIGHT[size];
|
|
56
|
+
// The gap lives inside the slot, so it opens with the button and the field
|
|
57
|
+
// sits flush to the edge when the button is gone.
|
|
58
|
+
const slotWidth = diameter + theme.spacing["2"];
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
progress.value = withTiming(isVisible ? 1 : 0, {
|
|
62
|
+
duration: TRANSITION_MS,
|
|
63
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
64
|
+
});
|
|
65
|
+
}, [isVisible, progress]);
|
|
66
|
+
|
|
67
|
+
const slotStyle = useAnimatedStyle(() => ({
|
|
68
|
+
width: progress.value * slotWidth,
|
|
69
|
+
opacity: progress.value,
|
|
70
|
+
transform: [{ scale: interpolate(progress.value, [0, 1], [START_SCALE, 1]) }],
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Animated.View
|
|
75
|
+
style={[styles.slot(isVisible), slotStyle]}
|
|
76
|
+
accessibilityElementsHidden={!isVisible}
|
|
77
|
+
importantForAccessibility={isVisible ? "auto" : "no-hide-descendants"}
|
|
78
|
+
>
|
|
79
|
+
<PressableOpacity
|
|
80
|
+
accessibilityLabel={accessibilityLabel}
|
|
81
|
+
accessibilityRole="button"
|
|
82
|
+
onPress={onPress}
|
|
83
|
+
style={styles.button(diameter, background)}
|
|
84
|
+
>
|
|
85
|
+
<Icon name="terra.x" size={ICON_SIZE[size] + GLYPH_EXTRA} color="text.primary" />
|
|
86
|
+
</PressableOpacity>
|
|
87
|
+
</Animated.View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = StyleSheet.create((theme) => ({
|
|
92
|
+
slot: (isVisible: boolean) => ({
|
|
93
|
+
flexDirection: "row",
|
|
94
|
+
justifyContent: "flex-end",
|
|
95
|
+
overflow: "hidden",
|
|
96
|
+
pointerEvents: isVisible ? "auto" : "none",
|
|
97
|
+
}),
|
|
98
|
+
// A circle the height of the field, filled and edged to match it.
|
|
99
|
+
button: (diameter: number, background: ColorToken) => ({
|
|
100
|
+
width: diameter,
|
|
101
|
+
height: diameter,
|
|
102
|
+
borderRadius: diameter / 2,
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
justifyContent: "center",
|
|
105
|
+
backgroundColor: resolveThemeColor(background, theme),
|
|
106
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
107
|
+
borderColor: theme.color["border.subtle"],
|
|
108
|
+
}),
|
|
109
|
+
}));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Pressable } from "react-native";
|
|
2
|
+
|
|
3
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
4
|
+
|
|
5
|
+
import { Icon } from "../../../icon";
|
|
6
|
+
import type { InputSize } from "../../types";
|
|
7
|
+
import { ICON_SIZE } from "./utils";
|
|
8
|
+
|
|
9
|
+
/** Touch expansion around the button, in dp — it is smaller than a cell. */
|
|
10
|
+
const HIT_SLOP = 8;
|
|
11
|
+
|
|
12
|
+
/** The x as a share of its disc, so it reads inside the circle. */
|
|
13
|
+
const GLYPH_RATIO = 0.6;
|
|
14
|
+
|
|
15
|
+
/** Heavier than the icon default so the small glyph stays legible on the disc. */
|
|
16
|
+
const GLYPH_STROKE_WIDTH = 3.3;
|
|
17
|
+
|
|
18
|
+
interface SearchClearButtonProps {
|
|
19
|
+
size: InputSize;
|
|
20
|
+
accessibilityLabel: string;
|
|
21
|
+
onPress: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A solid disc with an x, like iOS's `xmark.circle.fill`. Sized to the field's
|
|
26
|
+
* glyphs so it sits in the trailing slot without changing the row height.
|
|
27
|
+
*/
|
|
28
|
+
export function SearchClearButton({ size, accessibilityLabel, onPress }: SearchClearButtonProps) {
|
|
29
|
+
const diameter = ICON_SIZE[size];
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Pressable
|
|
33
|
+
accessibilityLabel={accessibilityLabel}
|
|
34
|
+
accessibilityRole="button"
|
|
35
|
+
hitSlop={HIT_SLOP}
|
|
36
|
+
onPress={onPress}
|
|
37
|
+
style={styles.disc(diameter)}
|
|
38
|
+
>
|
|
39
|
+
<Icon
|
|
40
|
+
name="terra.x"
|
|
41
|
+
size={Math.round(diameter * GLYPH_RATIO)}
|
|
42
|
+
color="text.inverse"
|
|
43
|
+
strokeWidth={GLYPH_STROKE_WIDTH}
|
|
44
|
+
/>
|
|
45
|
+
</Pressable>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const styles = StyleSheet.create((theme) => ({
|
|
50
|
+
disc: (diameter: number) => ({
|
|
51
|
+
width: diameter,
|
|
52
|
+
height: diameter,
|
|
53
|
+
borderRadius: diameter / 2,
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
justifyContent: "center",
|
|
56
|
+
backgroundColor: theme.color["text.tertiary"],
|
|
57
|
+
}),
|
|
58
|
+
}));
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { type ComponentRef, forwardRef, useCallback, useState } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { type ComponentRef, forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
|
|
2
|
+
import { type TextInput, View } from "react-native";
|
|
3
3
|
|
|
4
4
|
import { StyleSheet } from "react-native-unistyles";
|
|
5
5
|
|
|
6
6
|
import { Icon } from "../../../icon";
|
|
7
|
+
import { useFocusState } from "../../hooks/use-focus-state";
|
|
7
8
|
import { InputField } from "../../parts/InputField";
|
|
8
9
|
import type { InputBaseProps } from "../../types";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/** Touch expansion around the clear button, in dp — it is smaller than a cell. */
|
|
14
|
-
const CLEAR_HIT_SLOP = 8;
|
|
10
|
+
import { SearchCancelButton } from "./SearchCancelButton";
|
|
11
|
+
import { SearchClearButton } from "./SearchClearButton";
|
|
12
|
+
import { ICON_SIZE } from "./utils";
|
|
15
13
|
|
|
16
14
|
export interface SearchInputProps extends Omit<InputBaseProps, "leading" | "trailing"> {
|
|
17
15
|
/**
|
|
@@ -23,16 +21,30 @@ export interface SearchInputProps extends Omit<InputBaseProps, "leading" | "trai
|
|
|
23
21
|
isClearable?: boolean;
|
|
24
22
|
/** Accessible name of the clear button. Defaults to `'Clear search'`. */
|
|
25
23
|
clearAccessibilityLabel?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Show a round cancel button beside the field while it is focused. Pressing it
|
|
26
|
+
* blurs the field and calls `onCancelPress`. Defaults to `false`.
|
|
27
|
+
*/
|
|
28
|
+
hasCancelButton?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Called when the cancel button is pressed, after the field has blurred. The
|
|
31
|
+
* text is left alone; clear it here if cancelling should also reset the query.
|
|
32
|
+
*/
|
|
33
|
+
onCancelPress?: () => void;
|
|
34
|
+
/** Accessible name of the cancel button. Defaults to `'Cancel search'`. */
|
|
35
|
+
cancelAccessibilityLabel?: string;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
39
|
* A search field: a leading magnifier, and a clear button that appears as soon
|
|
30
40
|
* as there is text. Controlled through `value` or left to manage its own, and
|
|
31
|
-
* it defaults to the search return key so the keyboard reads correctly.
|
|
41
|
+
* it defaults to the search return key so the keyboard reads correctly. With
|
|
42
|
+
* `hasCancelButton`, a round cancel button slides in beside it while focused.
|
|
32
43
|
*
|
|
33
44
|
* @example
|
|
34
45
|
* ```tsx
|
|
35
46
|
* <Input.Search value={query} onChangeText={setQuery} onSubmitEditing={run} />
|
|
47
|
+
* <Input.Search hasCancelButton onCancelPress={() => setQuery("")} />
|
|
36
48
|
* ```
|
|
37
49
|
*/
|
|
38
50
|
export const SearchInput = forwardRef<ComponentRef<typeof TextInput>, SearchInputProps>(function SearchInput(
|
|
@@ -43,16 +55,29 @@ export const SearchInput = forwardRef<ComponentRef<typeof TextInput>, SearchInpu
|
|
|
43
55
|
onClear,
|
|
44
56
|
isClearable = true,
|
|
45
57
|
clearAccessibilityLabel = "Clear search",
|
|
58
|
+
hasCancelButton = false,
|
|
59
|
+
onCancelPress,
|
|
60
|
+
cancelAccessibilityLabel = "Cancel search",
|
|
46
61
|
placeholder = "Search",
|
|
47
62
|
isDisabled = false,
|
|
48
63
|
size = "md",
|
|
49
64
|
radius = "full",
|
|
65
|
+
background = "surface.sunken",
|
|
66
|
+
hasFocusGlow = true,
|
|
50
67
|
returnKeyType = "search",
|
|
51
68
|
clearButtonMode = "never",
|
|
69
|
+
style,
|
|
70
|
+
onFocus,
|
|
71
|
+
onBlur,
|
|
52
72
|
...rest
|
|
53
73
|
},
|
|
54
74
|
ref
|
|
55
75
|
) {
|
|
76
|
+
// The cancel button needs the native input to blur it, and the caller still
|
|
77
|
+
// gets the same instance through the forwarded ref.
|
|
78
|
+
const inputRef = useRef<ComponentRef<typeof TextInput>>(null);
|
|
79
|
+
useImperativeHandle(ref, () => inputRef.current as ComponentRef<typeof TextInput>);
|
|
80
|
+
|
|
56
81
|
// The clear button only exists while there is text, so an uncontrolled field
|
|
57
82
|
// still has to be tracked. That state also has to drive the native input:
|
|
58
83
|
// resetting it alone would hide the button while the text stayed on screen,
|
|
@@ -61,6 +86,20 @@ export const SearchInput = forwardRef<ComponentRef<typeof TextInput>, SearchInpu
|
|
|
61
86
|
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
|
|
62
87
|
const isControlled = value !== undefined;
|
|
63
88
|
const text = isControlled ? value : uncontrolledValue;
|
|
89
|
+
// Ending the edit makes iOS commit any pending autocorrect suggestion as one
|
|
90
|
+
// last `onChangeText`. `onCancelPress` waits for the blur event, which comes
|
|
91
|
+
// after it, so a query cleared there is not overwritten by the late commit.
|
|
92
|
+
const isCancellingRef = useRef(false);
|
|
93
|
+
const handleBlurAfterCancel = useCallback<NonNullable<SearchInputProps["onBlur"]>>(
|
|
94
|
+
(event) => {
|
|
95
|
+
onBlur?.(event);
|
|
96
|
+
if (!isCancellingRef.current) return;
|
|
97
|
+
isCancellingRef.current = false;
|
|
98
|
+
onCancelPress?.();
|
|
99
|
+
},
|
|
100
|
+
[onBlur, onCancelPress]
|
|
101
|
+
);
|
|
102
|
+
const { isFocused, handleFocus, handleBlur } = useFocusState(onFocus, handleBlurAfterCancel);
|
|
64
103
|
|
|
65
104
|
const handleChangeText = useCallback(
|
|
66
105
|
(next: string) => {
|
|
@@ -76,14 +115,28 @@ export const SearchInput = forwardRef<ComponentRef<typeof TextInput>, SearchInpu
|
|
|
76
115
|
onClear?.();
|
|
77
116
|
}, [isControlled, onChangeText, onClear]);
|
|
78
117
|
|
|
79
|
-
const
|
|
118
|
+
const handleCancel = useCallback(() => {
|
|
119
|
+
const input = inputRef.current;
|
|
120
|
+
// No blur event will come for a field that is not focused, so call through.
|
|
121
|
+
if (!input?.isFocused()) {
|
|
122
|
+
onCancelPress?.();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
isCancellingRef.current = true;
|
|
126
|
+
input.blur();
|
|
127
|
+
}, [onCancelPress]);
|
|
80
128
|
|
|
81
|
-
|
|
129
|
+
const showsClear = isClearable && text.length > 0 && !isDisabled;
|
|
130
|
+
const showsCancel = isFocused && !isDisabled;
|
|
131
|
+
|
|
132
|
+
const field = (
|
|
82
133
|
<InputField
|
|
83
|
-
ref={
|
|
134
|
+
ref={inputRef}
|
|
84
135
|
size={size}
|
|
85
136
|
radius={radius}
|
|
137
|
+
background={background}
|
|
86
138
|
isDisabled={isDisabled}
|
|
139
|
+
hasFocusGlow={hasFocusGlow}
|
|
87
140
|
placeholder={placeholder}
|
|
88
141
|
returnKeyType={returnKeyType}
|
|
89
142
|
// iOS's native clear button would sit beside ours; ours is the
|
|
@@ -91,28 +144,43 @@ export const SearchInput = forwardRef<ComponentRef<typeof TextInput>, SearchInpu
|
|
|
91
144
|
clearButtonMode={clearButtonMode}
|
|
92
145
|
value={text}
|
|
93
146
|
onChangeText={handleChangeText}
|
|
147
|
+
onFocus={handleFocus}
|
|
148
|
+
onBlur={handleBlur}
|
|
94
149
|
leading={<Icon name="terra.search" size={ICON_SIZE[size]} color="text.tertiary" />}
|
|
95
150
|
trailing={
|
|
96
|
-
|
|
97
|
-
<
|
|
98
|
-
accessibilityLabel={clearAccessibilityLabel}
|
|
99
|
-
accessibilityRole="button"
|
|
100
|
-
hitSlop={CLEAR_HIT_SLOP}
|
|
101
|
-
onPress={handleClear}
|
|
102
|
-
style={styles.clear}
|
|
103
|
-
>
|
|
104
|
-
<Icon name="terra.x" size={ICON_SIZE[size]} color="text.tertiary" />
|
|
105
|
-
</Pressable>
|
|
151
|
+
showsClear ? (
|
|
152
|
+
<SearchClearButton size={size} accessibilityLabel={clearAccessibilityLabel} onPress={handleClear} />
|
|
106
153
|
) : undefined
|
|
107
154
|
}
|
|
155
|
+
// With a cancel button the row is the outer element, so `style` moves there.
|
|
156
|
+
style={hasCancelButton ? undefined : style}
|
|
108
157
|
{...rest}
|
|
109
158
|
/>
|
|
110
159
|
);
|
|
160
|
+
|
|
161
|
+
// Without a cancel button the field renders bare, so its layout is unchanged.
|
|
162
|
+
if (!hasCancelButton) return field;
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<View style={[styles.row, style]}>
|
|
166
|
+
<View style={styles.fieldSlot}>{field}</View>
|
|
167
|
+
<SearchCancelButton
|
|
168
|
+
isVisible={showsCancel}
|
|
169
|
+
size={size}
|
|
170
|
+
background={background}
|
|
171
|
+
accessibilityLabel={cancelAccessibilityLabel}
|
|
172
|
+
onPress={handleCancel}
|
|
173
|
+
/>
|
|
174
|
+
</View>
|
|
175
|
+
);
|
|
111
176
|
});
|
|
112
177
|
|
|
113
178
|
const styles = StyleSheet.create(() => ({
|
|
114
|
-
|
|
179
|
+
row: {
|
|
180
|
+
flexDirection: "row",
|
|
115
181
|
alignItems: "center",
|
|
116
|
-
|
|
182
|
+
},
|
|
183
|
+
fieldSlot: {
|
|
184
|
+
flex: 1,
|
|
117
185
|
},
|
|
118
186
|
}));
|
|
@@ -245,6 +245,9 @@ const ScreenListBase = forwardRef(function ScreenList(
|
|
|
245
245
|
contentInsetAdjustmentBehavior={platformAdjustsInsets ? "automatic" : "never"}
|
|
246
246
|
automaticallyAdjustContentInsets={false}
|
|
247
247
|
automaticallyAdjustsScrollIndicatorInsets={false}
|
|
248
|
+
// iOS: inset by the keyboard so a focused field can scroll above it.
|
|
249
|
+
automaticallyAdjustKeyboardInsets
|
|
250
|
+
keyboardShouldPersistTaps="handled"
|
|
248
251
|
contentContainerStyle={[
|
|
249
252
|
styles.content(
|
|
250
253
|
resolvedPadding,
|
|
@@ -117,6 +117,9 @@ export const ScreenScrollView = forwardRef<ComponentRef<typeof ScrollView>, Scre
|
|
|
117
117
|
contentInsetAdjustmentBehavior={platformAdjustsInsets ? "automatic" : "never"}
|
|
118
118
|
automaticallyAdjustContentInsets={false}
|
|
119
119
|
automaticallyAdjustsScrollIndicatorInsets={false}
|
|
120
|
+
// iOS: inset by the keyboard so a focused field can scroll above it.
|
|
121
|
+
automaticallyAdjustKeyboardInsets
|
|
122
|
+
keyboardShouldPersistTaps="handled"
|
|
120
123
|
showsVerticalScrollIndicator={false}
|
|
121
124
|
scrollEventThrottle={16}
|
|
122
125
|
contentContainerStyle={[
|
package/src/theme/tokens/dark.ts
CHANGED
|
@@ -4,24 +4,24 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const dark = {
|
|
6
6
|
// Surfaces
|
|
7
|
-
"color.surface.canvas": "
|
|
8
|
-
"color.surface.default": "
|
|
9
|
-
"color.surface.raised": "
|
|
7
|
+
"color.surface.canvas": "{palette.ink.950}",
|
|
8
|
+
"color.surface.default": "{palette.ink.900}",
|
|
9
|
+
"color.surface.raised": "{palette.ink.800}",
|
|
10
10
|
"color.surface.sunken": "#0f1318",
|
|
11
11
|
|
|
12
12
|
// Text
|
|
13
|
-
"color.text.primary": "{palette.
|
|
13
|
+
"color.text.primary": "{palette.ink.50}",
|
|
14
14
|
// Custom grays preserve supporting-text emphasis on dark surfaces.
|
|
15
15
|
"color.text.secondary": "#b8b8be",
|
|
16
16
|
"color.text.tertiary": "#8d8d96",
|
|
17
17
|
"color.text.disabled": "#606069",
|
|
18
|
-
"color.text.inverse": "{palette.
|
|
18
|
+
"color.text.inverse": "{palette.ink.950}",
|
|
19
19
|
"color.text.link": "{palette.blue.300}",
|
|
20
20
|
|
|
21
21
|
// Borders and focus
|
|
22
|
-
"color.border.subtle": "{palette.
|
|
23
|
-
"color.border.default": "{palette.
|
|
24
|
-
"color.border.strong": "{palette.
|
|
22
|
+
"color.border.subtle": "{palette.ink.800}",
|
|
23
|
+
"color.border.default": "{palette.ink.700}",
|
|
24
|
+
"color.border.strong": "{palette.ink.500}",
|
|
25
25
|
"color.focus.ring": "{palette.blue.400}",
|
|
26
26
|
|
|
27
27
|
// Effects
|
|
@@ -41,19 +41,19 @@ export const dark = {
|
|
|
41
41
|
"color.accent.soft.bg": "{palette.emerald.950}",
|
|
42
42
|
"color.accent.soft.bg.hover": "{palette.emerald.900}",
|
|
43
43
|
"color.accent.soft.bg.pressed": "{palette.emerald.800}",
|
|
44
|
-
"color.accent.soft.bg.disabled": "{palette.
|
|
44
|
+
"color.accent.soft.bg.disabled": "{palette.ink.900}",
|
|
45
45
|
"color.accent.soft.fg": "{palette.emerald.300}",
|
|
46
46
|
|
|
47
47
|
// Neutral
|
|
48
|
-
"color.neutral.solid.bg": "{palette.
|
|
49
|
-
"color.neutral.solid.fg": "{palette.
|
|
48
|
+
"color.neutral.solid.bg": "{palette.ink.700}",
|
|
49
|
+
"color.neutral.solid.fg": "{palette.ink.50}",
|
|
50
50
|
|
|
51
51
|
// Translucent fills keep soft controls distinct from surrounding surfaces.
|
|
52
|
-
"color.neutral.soft.bg": "{palette.
|
|
53
|
-
"color.neutral.soft.bg.hover": "{palette.
|
|
54
|
-
"color.neutral.soft.bg.pressed": "{palette.
|
|
55
|
-
"color.neutral.soft.bg.disabled": "{palette.
|
|
56
|
-
"color.neutral.soft.fg": "{palette.
|
|
52
|
+
"color.neutral.soft.bg": "{palette.ink.400}/25",
|
|
53
|
+
"color.neutral.soft.bg.hover": "{palette.ink.400}/35",
|
|
54
|
+
"color.neutral.soft.bg.pressed": "{palette.ink.400}/45",
|
|
55
|
+
"color.neutral.soft.bg.disabled": "{palette.ink.900}",
|
|
56
|
+
"color.neutral.soft.fg": "{palette.ink.200}",
|
|
57
57
|
|
|
58
58
|
// Success
|
|
59
59
|
"color.success.text": "{palette.green.400}",
|
|
@@ -7,13 +7,13 @@ export const light = {
|
|
|
7
7
|
"color.surface.canvas": "#fbfbfb",
|
|
8
8
|
"color.surface.default": "{palette.white}",
|
|
9
9
|
"color.surface.raised": "{palette.white}",
|
|
10
|
-
"color.surface.sunken": "
|
|
10
|
+
"color.surface.sunken": "{palette.neutral.100}",
|
|
11
11
|
|
|
12
12
|
// Text
|
|
13
13
|
"color.text.primary": "{palette.neutral.950}",
|
|
14
|
-
"color.text.secondary": "#
|
|
15
|
-
"color.text.tertiary": "#
|
|
16
|
-
"color.text.disabled": "#
|
|
14
|
+
"color.text.secondary": "#55555d",
|
|
15
|
+
"color.text.tertiary": "#6e6e76",
|
|
16
|
+
"color.text.disabled": "#d4d4d8",
|
|
17
17
|
"color.text.inverse": "{palette.white}",
|
|
18
18
|
"color.text.link": "{palette.blue.700}",
|
|
19
19
|
|
|
@@ -164,6 +164,18 @@ export const primitives = {
|
|
|
164
164
|
"palette.mist.800": "#22292b",
|
|
165
165
|
"palette.mist.900": "#161b1d",
|
|
166
166
|
"palette.mist.950": "#090b0c",
|
|
167
|
+
// Custom: blue-tinted neutral built on the dark surfaces (canvas 950, default 900, raised 800).
|
|
168
|
+
"palette.ink.50": "#f8f9fb",
|
|
169
|
+
"palette.ink.100": "#eef1f4",
|
|
170
|
+
"palette.ink.200": "#dde2e8",
|
|
171
|
+
"palette.ink.300": "#c3cad3",
|
|
172
|
+
"palette.ink.400": "#9aa4b1",
|
|
173
|
+
"palette.ink.500": "#6b7684",
|
|
174
|
+
"palette.ink.600": "#424d5a",
|
|
175
|
+
"palette.ink.700": "#2e3742",
|
|
176
|
+
"palette.ink.800": "#1f262e",
|
|
177
|
+
"palette.ink.900": "#14191f",
|
|
178
|
+
"palette.ink.950": "#080a0d",
|
|
167
179
|
"palette.olive.50": "#fbfbf9",
|
|
168
180
|
"palette.olive.100": "#f4f4f0",
|
|
169
181
|
"palette.olive.200": "#e8e8e3",
|