react-native-terra-ui 0.11.4 → 0.11.5

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 (58) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/module/components/header/utils.js +20 -0
  3. package/lib/module/components/header/utils.js.map +1 -0
  4. package/lib/module/components/header/variants/LargeTitleHeader/index.js +6 -5
  5. package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
  6. package/lib/module/components/header/variants/TitleHeader/index.js +4 -3
  7. package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
  8. package/lib/module/components/index.js +1 -0
  9. package/lib/module/components/index.js.map +1 -1
  10. package/lib/module/components/input/Input.js +32 -0
  11. package/lib/module/components/input/Input.js.map +1 -0
  12. package/lib/module/components/input/index.js +5 -0
  13. package/lib/module/components/input/index.js.map +1 -0
  14. package/lib/module/components/input/parts/InputField.js +161 -0
  15. package/lib/module/components/input/parts/InputField.js.map +1 -0
  16. package/lib/module/components/input/types.js +4 -0
  17. package/lib/module/components/input/types.js.map +1 -0
  18. package/lib/module/components/input/variants/SearchInput/index.js +100 -0
  19. package/lib/module/components/input/variants/SearchInput/index.js.map +1 -0
  20. package/lib/module/components/toolbar/Toolbar.js +12 -5
  21. package/lib/module/components/toolbar/Toolbar.js.map +1 -1
  22. package/lib/module/theme/defaults/icons.js +21 -0
  23. package/lib/module/theme/defaults/icons.js.map +1 -1
  24. package/lib/typescript/src/components/header/utils.d.ts +14 -0
  25. package/lib/typescript/src/components/header/utils.d.ts.map +1 -0
  26. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +6 -1
  28. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/index.d.ts +1 -0
  30. package/lib/typescript/src/components/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/input/Input.d.ts +28 -0
  32. package/lib/typescript/src/components/input/Input.d.ts.map +1 -0
  33. package/lib/typescript/src/components/input/index.d.ts +4 -0
  34. package/lib/typescript/src/components/input/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/input/parts/InputField.d.ts +8 -0
  36. package/lib/typescript/src/components/input/parts/InputField.d.ts.map +1 -0
  37. package/lib/typescript/src/components/input/types.d.ts +29 -0
  38. package/lib/typescript/src/components/input/types.d.ts.map +1 -0
  39. package/lib/typescript/src/components/input/variants/SearchInput/index.d.ts +24 -0
  40. package/lib/typescript/src/components/input/variants/SearchInput/index.d.ts.map +1 -0
  41. package/lib/typescript/src/components/toolbar/Toolbar.d.ts +8 -3
  42. package/lib/typescript/src/components/toolbar/Toolbar.d.ts.map +1 -1
  43. package/lib/typescript/src/theme/defaults/icons.d.ts.map +1 -1
  44. package/lib/typescript/src/theme/types.d.ts +1 -1
  45. package/lib/typescript/src/theme/types.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/src/components/header/utils.ts +19 -0
  48. package/src/components/header/variants/LargeTitleHeader/index.tsx +27 -13
  49. package/src/components/header/variants/TitleHeader/index.tsx +13 -3
  50. package/src/components/index.ts +1 -0
  51. package/src/components/input/Input.tsx +36 -0
  52. package/src/components/input/index.ts +3 -0
  53. package/src/components/input/parts/InputField.tsx +138 -0
  54. package/src/components/input/types.ts +31 -0
  55. package/src/components/input/variants/SearchInput/index.tsx +118 -0
  56. package/src/components/toolbar/Toolbar.tsx +17 -7
  57. package/src/theme/defaults/icons.tsx +13 -0
  58. package/src/theme/types.ts +1 -0
@@ -0,0 +1,138 @@
1
+ import { type ComponentRef, forwardRef, useCallback, useState } from "react";
2
+ import { TextInput, View } from "react-native";
3
+
4
+ import { StyleSheet, useUnistyles } from "react-native-unistyles";
5
+
6
+ import type { ColorToken, RadiusKey } from "#theme/types";
7
+ import { resolveThemeColor } from "#utils/resolve-theme-color";
8
+ import { FONT_WEIGHT_VALUE } from "#utils/typography";
9
+ import type { InputBaseProps, InputSize } from "../types";
10
+
11
+ /** Typographic role of the text, per size. */
12
+ const TEXT_VARIANT: Record<InputSize, "body-sm" | "body-md" | "body-lg"> = {
13
+ sm: "body-sm",
14
+ md: "body-md",
15
+ lg: "body-lg",
16
+ };
17
+
18
+ /**
19
+ * The field every input in the family renders: a themed container with optional
20
+ * leading and trailing slots around a native `TextInput`. Internal — consumers
21
+ * reach it through `Input` or one of its variants.
22
+ */
23
+ export const InputField = forwardRef<ComponentRef<typeof TextInput>, InputBaseProps>(function InputField(
24
+ {
25
+ size = "md",
26
+ isDisabled = false,
27
+ isInvalid = false,
28
+ leading,
29
+ trailing,
30
+ background = "surface.sunken",
31
+ radius = "lg",
32
+ style,
33
+ onFocus,
34
+ onBlur,
35
+ ...rest
36
+ },
37
+ ref
38
+ ) {
39
+ // The focus ring is a style decision, so it stays a variant; only the
40
+ // placeholder colour has to leave the stylesheet, because `TextInput` takes it
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
+ );
52
+
53
+ const handleBlur = useCallback<NonNullable<InputBaseProps["onBlur"]>>(
54
+ (event) => {
55
+ setIsFocused(false);
56
+ onBlur?.(event);
57
+ },
58
+ [onBlur]
59
+ );
60
+
61
+ styles.useVariants({ size, disabled: isDisabled, invalid: isInvalid, focused: isFocused && !isDisabled });
62
+
63
+ return (
64
+ <View style={[styles.container, styles.fill(background, radius), style]}>
65
+ {leading}
66
+ <TextInput
67
+ ref={ref}
68
+ editable={!isDisabled}
69
+ placeholderTextColor={theme.color["text.tertiary"]}
70
+ style={styles.input}
71
+ onFocus={handleFocus}
72
+ onBlur={handleBlur}
73
+ {...rest}
74
+ />
75
+ {trailing}
76
+ </View>
77
+ );
78
+ });
79
+
80
+ const styles = StyleSheet.create((theme) => ({
81
+ container: {
82
+ flexDirection: "row",
83
+ alignItems: "center",
84
+ borderWidth: StyleSheet.hairlineWidth,
85
+ borderColor: theme.color["border.subtle"],
86
+ variants: {
87
+ size: {
88
+ sm: { height: 36, paddingHorizontal: theme.spacing["4"], gap: 6 },
89
+ md: { height: 44, paddingHorizontal: theme.spacing["4"], gap: 6 },
90
+ lg: { height: 52, paddingHorizontal: theme.spacing["4"], gap: 6 },
91
+ },
92
+ focused: {
93
+ true: { borderColor: theme.color["accent.border"] },
94
+ false: {},
95
+ },
96
+ invalid: {
97
+ true: { borderColor: theme.color["danger.text"] },
98
+ false: {},
99
+ },
100
+ disabled: {
101
+ true: { opacity: theme.opacity.disabled },
102
+ false: {},
103
+ },
104
+ },
105
+ },
106
+ // `background` is an open `ColorToken` and `radius` an eight-step scale, so
107
+ // both resolve in a dynamic function rather than enumerating as variants.
108
+ fill: (background: ColorToken, radius: RadiusKey) => ({
109
+ backgroundColor: resolveThemeColor(background, theme),
110
+ borderRadius: theme.radius[radius],
111
+ }),
112
+ input: {
113
+ flex: 1,
114
+ // Android centres text in a taller box by default; zeroing the padding lets
115
+ // the container's own height own the geometry on both platforms.
116
+ paddingVertical: 0,
117
+ color: theme.color["text.primary"],
118
+ variants: {
119
+ size: {
120
+ sm: {
121
+ fontSize: theme.typography.variants[TEXT_VARIANT.sm].fontSize,
122
+ fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants[TEXT_VARIANT.sm].fontWeight],
123
+ },
124
+ md: {
125
+ fontSize: theme.typography.variants[TEXT_VARIANT.md].fontSize,
126
+ fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants[TEXT_VARIANT.md].fontWeight],
127
+ },
128
+ lg: {
129
+ fontSize: theme.typography.variants[TEXT_VARIANT.lg].fontSize,
130
+ fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants[TEXT_VARIANT.lg].fontWeight],
131
+ },
132
+ },
133
+ focused: { true: {}, false: {} },
134
+ invalid: { true: {}, false: {} },
135
+ disabled: { true: {}, false: {} },
136
+ },
137
+ },
138
+ }));
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react";
2
+ import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
3
+
4
+ import type { ColorToken, RadiusKey } from "#theme/types";
5
+
6
+ /** Field height and typography step. */
7
+ export type InputSize = "sm" | "md" | "lg";
8
+
9
+ /**
10
+ * Props shared by every input in the family. `style` reaches the field's
11
+ * container rather than the native `TextInput`, matching the rest of the
12
+ * library — the text itself is themed from `size`.
13
+ */
14
+ export interface InputBaseProps extends Omit<TextInputProps, "style" | "editable"> {
15
+ /** Field height and text step. Defaults to `'md'`. */
16
+ size?: InputSize;
17
+ /** Blocks typing and dims the field. Defaults to `false`. */
18
+ isDisabled?: boolean;
19
+ /** Paints the border in the danger tone. Defaults to `false`. */
20
+ isInvalid?: boolean;
21
+ /** Content pinned before the text, e.g. an `Icon`. */
22
+ leading?: ReactNode;
23
+ /** Content pinned after the text, e.g. a unit or a button. */
24
+ trailing?: ReactNode;
25
+ /** Fill token. Defaults to `'surface.sunken'`. */
26
+ background?: ColorToken;
27
+ /** Corner radius. Defaults to `'md'`. */
28
+ radius?: RadiusKey;
29
+ /** Merged over the container's own style. */
30
+ style?: StyleProp<ViewStyle>;
31
+ }
@@ -0,0 +1,118 @@
1
+ import { type ComponentRef, forwardRef, useCallback, useState } from "react";
2
+ import { Pressable, type TextInput } from "react-native";
3
+
4
+ import { StyleSheet } from "react-native-unistyles";
5
+
6
+ import { Icon } from "../../../icon";
7
+ import { InputField } from "../../parts/InputField";
8
+ import type { InputBaseProps } from "../../types";
9
+
10
+ /** Glyph size per field size, in dp. */
11
+ const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
12
+
13
+ /** Touch expansion around the clear button, in dp — it is smaller than a cell. */
14
+ const CLEAR_HIT_SLOP = 8;
15
+
16
+ export interface SearchInputProps extends Omit<InputBaseProps, "leading" | "trailing"> {
17
+ /**
18
+ * Called after the clear button empties the field. `onChangeText` fires with
19
+ * an empty string first, so a controlled field needs no extra wiring.
20
+ */
21
+ onClear?: () => void;
22
+ /** Show the clear button once there is text. Defaults to `true`. */
23
+ isClearable?: boolean;
24
+ /** Accessible name of the clear button. Defaults to `'Clear search'`. */
25
+ clearAccessibilityLabel?: string;
26
+ }
27
+
28
+ /**
29
+ * A search field: a leading magnifier, and a clear button that appears as soon
30
+ * 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.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * <Input.Search value={query} onChangeText={setQuery} onSubmitEditing={run} />
36
+ * ```
37
+ */
38
+ export const SearchInput = forwardRef<ComponentRef<typeof TextInput>, SearchInputProps>(function SearchInput(
39
+ {
40
+ value,
41
+ defaultValue,
42
+ onChangeText,
43
+ onClear,
44
+ isClearable = true,
45
+ clearAccessibilityLabel = "Clear search",
46
+ placeholder = "Search",
47
+ isDisabled = false,
48
+ size = "md",
49
+ radius = "full",
50
+ returnKeyType = "search",
51
+ clearButtonMode = "never",
52
+ ...rest
53
+ },
54
+ ref
55
+ ) {
56
+ // The clear button only exists while there is text, so an uncontrolled field
57
+ // still has to be tracked. That state also has to drive the native input:
58
+ // resetting it alone would hide the button while the text stayed on screen,
59
+ // so `TextInput` is always handed a `value`, seeded from `defaultValue` when
60
+ // the caller does not own one.
61
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
62
+ const isControlled = value !== undefined;
63
+ const text = isControlled ? value : uncontrolledValue;
64
+
65
+ const handleChangeText = useCallback(
66
+ (next: string) => {
67
+ if (!isControlled) setUncontrolledValue(next);
68
+ onChangeText?.(next);
69
+ },
70
+ [isControlled, onChangeText]
71
+ );
72
+
73
+ const handleClear = useCallback(() => {
74
+ if (!isControlled) setUncontrolledValue("");
75
+ onChangeText?.("");
76
+ onClear?.();
77
+ }, [isControlled, onChangeText, onClear]);
78
+
79
+ const hasText = text.length > 0;
80
+
81
+ return (
82
+ <InputField
83
+ ref={ref}
84
+ size={size}
85
+ radius={radius}
86
+ isDisabled={isDisabled}
87
+ placeholder={placeholder}
88
+ returnKeyType={returnKeyType}
89
+ // iOS's native clear button would sit beside ours; ours is the
90
+ // cross-platform one, so the native affordance stays off by default.
91
+ clearButtonMode={clearButtonMode}
92
+ value={text}
93
+ onChangeText={handleChangeText}
94
+ leading={<Icon name="terra.search" size={ICON_SIZE[size]} color="text.tertiary" />}
95
+ trailing={
96
+ isClearable && hasText && !isDisabled ? (
97
+ <Pressable
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>
106
+ ) : undefined
107
+ }
108
+ {...rest}
109
+ />
110
+ );
111
+ });
112
+
113
+ const styles = StyleSheet.create(() => ({
114
+ clear: {
115
+ alignItems: "center",
116
+ justifyContent: "center",
117
+ },
118
+ }));
@@ -87,16 +87,21 @@ const LABEL_VARIANT = "label-md";
87
87
 
88
88
  // ─── Button ───────────────────────────────────────────────────────────────────
89
89
 
90
- interface ToolbarButtonBaseProps extends Omit<PressableProps, "children" | "style"> {
90
+ interface ToolbarButtonBaseProps extends Omit<PressableProps, "children" | "style" | "disabled"> {
91
91
  /** Icon to render, before the label when both are given. */
92
92
  icon?: TerraIconName;
93
93
  /** Icon size in density-independent pixels. Defaults to `20`. */
94
94
  iconSize?: number;
95
95
  /**
96
96
  * Foreground token for the icon and label. Defaults to the group's `tint`,
97
- * or `text.primary` outside a group.
97
+ * or `text.primary` outside a group. Ignored while `isDisabled`.
98
98
  */
99
99
  tint?: ColorToken;
100
+ /**
101
+ * Blocks presses and dims the cell, repainting the icon and label in
102
+ * `text.tertiary`. Defaults to `false`.
103
+ */
104
+ isDisabled?: boolean;
100
105
  /**
101
106
  * Fill. Defaults to `surface.default` standalone, and to `transparent`
102
107
  * inside a {@link ToolbarGroup}, which paints the shared capsule instead.
@@ -146,6 +151,7 @@ export function ToolbarButton({
146
151
  children,
147
152
  accessibilityLabel,
148
153
  onPress,
154
+ isDisabled = false,
149
155
  hitSlop = HIT_SLOP,
150
156
  style,
151
157
  ...rest
@@ -154,7 +160,9 @@ export function ToolbarButton({
154
160
 
155
161
  // Tokens, not resolved values: `Icon` and `Text` each resolve against the
156
162
  // live theme, so they keep their own subscriptions.
157
- const fg = tint ?? groupTint;
163
+ // A disabled cell drops to the muted foreground, so an explicit `tint` — or a
164
+ // group's — cannot paint it as though it were still actionable.
165
+ const fg = isDisabled ? "text.tertiary" : (tint ?? groupTint);
158
166
  const bg = background ?? groupBackground;
159
167
  const shape = Children.count(children) > 0 ? "label" : "icon";
160
168
  // A grouped cell normally shrinks to its glyph, which is invisible while it is
@@ -177,12 +185,14 @@ export function ToolbarButton({
177
185
  <Pressable
178
186
  accessibilityLabel={accessibilityLabel}
179
187
  accessibilityRole="button"
188
+ accessibilityState={{ disabled: isDisabled }}
189
+ disabled={isDisabled}
180
190
  hitSlop={hitSlop}
181
- onPress={onPress}
191
+ onPress={isDisabled ? undefined : onPress}
182
192
  {...rest}
183
193
  style={(state) => [
184
194
  cell,
185
- styles.fill(bg, state.pressed),
195
+ styles.fill(bg, state.pressed, isDisabled),
186
196
  typeof style === "function" ? style(state) : style,
187
197
  ]}
188
198
  >
@@ -393,8 +403,8 @@ const styles = StyleSheet.create((theme) => ({
393
403
  },
394
404
  // Split from `button` so the open-ended fill and the press state stay in the
395
405
  // stylesheet without turning the geometry into a dynamic function too.
396
- fill: (background: ColorToken, pressed: boolean) => ({
406
+ fill: (background: ColorToken, pressed: boolean, isDisabled: boolean) => ({
397
407
  backgroundColor: resolveThemeColor(background, theme),
398
- opacity: pressed ? theme.opacity.pressed : 1,
408
+ opacity: isDisabled ? theme.opacity.disabled : pressed ? theme.opacity.pressed : 1,
399
409
  }),
400
410
  }));
@@ -126,12 +126,25 @@ export const PersonIcon: TerraIconComponent = (props) => {
126
126
  );
127
127
  };
128
128
 
129
+ const SearchIcon: TerraIconComponent = (props) => {
130
+ const { color, size, strokeWidth } = resolveIconProps(props);
131
+ const stroke = strokeProps(color, strokeWidth);
132
+
133
+ return (
134
+ <SvgIcon size={size}>
135
+ <Circle cx={11} cy={11} r={7} {...stroke} />
136
+ <Path d="m20 20-3.9-3.9" {...stroke} />
137
+ </SvgIcon>
138
+ );
139
+ };
140
+
129
141
  export const defaultIcons: Record<TerraSemanticIconName, TerraIconComponent> = {
130
142
  "terra.chevron-left": ChevronLeftIcon,
131
143
  "terra.chevron-right": ChevronRightIcon,
132
144
  "terra.x": XIcon,
133
145
  "terra.check": CheckIcon,
134
146
  "terra.person": PersonIcon,
147
+ "terra.search": SearchIcon,
135
148
  "status.info": InfoIcon,
136
149
  "status.success": SuccessIcon,
137
150
  "status.warning": WarningIcon,
@@ -393,6 +393,7 @@ export type TerraSemanticIconName =
393
393
  | "terra.x"
394
394
  | "terra.check"
395
395
  | "terra.person"
396
+ | "terra.search"
396
397
  | "status.info"
397
398
  | "status.success"
398
399
  | "status.warning"