react-native-terra-ui 0.11.4 → 0.11.6

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 (101) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/lib/module/components/chip/Chip.js +25 -8
  3. package/lib/module/components/chip/Chip.js.map +1 -1
  4. package/lib/module/components/chip/utils.js +1 -1
  5. package/lib/module/components/chip/utils.js.map +1 -1
  6. package/lib/module/components/header/index.js.map +1 -1
  7. package/lib/module/components/header/parts/HeaderAccessory.js +55 -0
  8. package/lib/module/components/header/parts/HeaderAccessory.js.map +1 -0
  9. package/lib/module/components/header/parts/HeaderSurface.js +65 -0
  10. package/lib/module/components/header/parts/HeaderSurface.js.map +1 -0
  11. package/lib/module/components/header/types.js +2 -0
  12. package/lib/module/components/header/types.js.map +1 -0
  13. package/lib/module/components/header/utils.js +20 -0
  14. package/lib/module/components/header/utils.js.map +1 -0
  15. package/lib/module/components/header/variants/LargeTitleHeader/index.js +33 -30
  16. package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
  17. package/lib/module/components/header/variants/TitleHeader/index.js +29 -27
  18. package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
  19. package/lib/module/components/index.js +1 -0
  20. package/lib/module/components/index.js.map +1 -1
  21. package/lib/module/components/input/Input.js +32 -0
  22. package/lib/module/components/input/Input.js.map +1 -0
  23. package/lib/module/components/input/index.js +5 -0
  24. package/lib/module/components/input/index.js.map +1 -0
  25. package/lib/module/components/input/parts/InputField.js +161 -0
  26. package/lib/module/components/input/parts/InputField.js.map +1 -0
  27. package/lib/module/components/input/types.js +4 -0
  28. package/lib/module/components/input/types.js.map +1 -0
  29. package/lib/module/components/input/variants/SearchInput/index.js +100 -0
  30. package/lib/module/components/input/variants/SearchInput/index.js.map +1 -0
  31. package/lib/module/components/screen/ScreenContext.js +10 -1
  32. package/lib/module/components/screen/ScreenContext.js.map +1 -1
  33. package/lib/module/components/screen/ScreenList.js +5 -3
  34. package/lib/module/components/screen/ScreenList.js.map +1 -1
  35. package/lib/module/components/screen/ScreenScrollView.js +5 -3
  36. package/lib/module/components/screen/ScreenScrollView.js.map +1 -1
  37. package/lib/module/components/toolbar/Toolbar.js +12 -5
  38. package/lib/module/components/toolbar/Toolbar.js.map +1 -1
  39. package/lib/module/theme/defaults/icons.js +21 -0
  40. package/lib/module/theme/defaults/icons.js.map +1 -1
  41. package/lib/typescript/src/components/chip/Chip.d.ts +8 -0
  42. package/lib/typescript/src/components/chip/Chip.d.ts.map +1 -1
  43. package/lib/typescript/src/components/chip/utils.d.ts +6 -1
  44. package/lib/typescript/src/components/chip/utils.d.ts.map +1 -1
  45. package/lib/typescript/src/components/header/index.d.ts +1 -0
  46. package/lib/typescript/src/components/header/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts +19 -0
  48. package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts.map +1 -0
  49. package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts +24 -0
  50. package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts.map +1 -0
  51. package/lib/typescript/src/components/header/types.d.ts +11 -0
  52. package/lib/typescript/src/components/header/types.d.ts.map +1 -0
  53. package/lib/typescript/src/components/header/utils.d.ts +14 -0
  54. package/lib/typescript/src/components/header/utils.d.ts.map +1 -0
  55. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts +1 -1
  56. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +21 -2
  58. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
  59. package/lib/typescript/src/components/index.d.ts +1 -0
  60. package/lib/typescript/src/components/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/input/Input.d.ts +28 -0
  62. package/lib/typescript/src/components/input/Input.d.ts.map +1 -0
  63. package/lib/typescript/src/components/input/index.d.ts +4 -0
  64. package/lib/typescript/src/components/input/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/input/parts/InputField.d.ts +8 -0
  66. package/lib/typescript/src/components/input/parts/InputField.d.ts.map +1 -0
  67. package/lib/typescript/src/components/input/types.d.ts +29 -0
  68. package/lib/typescript/src/components/input/types.d.ts.map +1 -0
  69. package/lib/typescript/src/components/input/variants/SearchInput/index.d.ts +24 -0
  70. package/lib/typescript/src/components/input/variants/SearchInput/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/screen/ScreenContext.d.ts +15 -0
  72. package/lib/typescript/src/components/screen/ScreenContext.d.ts.map +1 -1
  73. package/lib/typescript/src/components/screen/ScreenList.d.ts.map +1 -1
  74. package/lib/typescript/src/components/screen/ScreenScrollView.d.ts.map +1 -1
  75. package/lib/typescript/src/components/toolbar/Toolbar.d.ts +8 -3
  76. package/lib/typescript/src/components/toolbar/Toolbar.d.ts.map +1 -1
  77. package/lib/typescript/src/theme/defaults/icons.d.ts.map +1 -1
  78. package/lib/typescript/src/theme/types.d.ts +1 -1
  79. package/lib/typescript/src/theme/types.d.ts.map +1 -1
  80. package/package.json +1 -1
  81. package/src/components/chip/Chip.tsx +22 -9
  82. package/src/components/chip/utils.ts +7 -2
  83. package/src/components/header/index.ts +1 -0
  84. package/src/components/header/parts/HeaderAccessory.tsx +57 -0
  85. package/src/components/header/parts/HeaderSurface.tsx +65 -0
  86. package/src/components/header/types.ts +10 -0
  87. package/src/components/header/utils.ts +19 -0
  88. package/src/components/header/variants/LargeTitleHeader/index.tsx +43 -28
  89. package/src/components/header/variants/TitleHeader/index.tsx +42 -18
  90. package/src/components/index.ts +1 -0
  91. package/src/components/input/Input.tsx +36 -0
  92. package/src/components/input/index.ts +3 -0
  93. package/src/components/input/parts/InputField.tsx +138 -0
  94. package/src/components/input/types.ts +31 -0
  95. package/src/components/input/variants/SearchInput/index.tsx +118 -0
  96. package/src/components/screen/ScreenContext.tsx +26 -0
  97. package/src/components/screen/ScreenList.tsx +8 -3
  98. package/src/components/screen/ScreenScrollView.tsx +12 -3
  99. package/src/components/toolbar/Toolbar.tsx +17 -7
  100. package/src/theme/defaults/icons.tsx +13 -0
  101. 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
+ }));
@@ -63,6 +63,21 @@ export interface ScreenContextValue {
63
63
  margins: ScreenContentPadding;
64
64
  /** Whether a `Screen.Header` is present. Used by scroll containers to add top inset. */
65
65
  hasHeader: boolean;
66
+ /**
67
+ * Measured height (dp) of the pinned accessory a header renders below its bar
68
+ * — a search field, a tab strip. Zero when there is none.
69
+ *
70
+ * The theme header height covers the bar alone, and an accessory is whatever
71
+ * the caller put there, so scroll containers reserve this on top of it. That
72
+ * is why it is measured rather than declared: only the accessory knows how
73
+ * tall it is, and it can change with Dynamic Type or its own content.
74
+ */
75
+ headerAccessoryHeight: number;
76
+ /**
77
+ * Reports the measured accessory height. Called by the header that owns the
78
+ * accessory; zero releases the reserved space.
79
+ */
80
+ setHeaderAccessoryHeight: (height: number) => void;
66
81
  /** Vertical scroll offset, updated on the UI thread for header animations. */
67
82
  scrollY: SharedValue<number>;
68
83
  /**
@@ -126,6 +141,7 @@ export function ScreenScrollProvider({
126
141
  hasHeader = false,
127
142
  }: ScreenScrollProviderProps) {
128
143
  const scrollY = useSharedValue(0);
144
+ const [headerAccessoryHeight, setAccessoryHeight] = useState(0);
129
145
  const headerCollapseHeight = useSharedValue(0);
130
146
  const [headerSnapOffsets, setHeaderSnapOffsets] = useState<number[]>();
131
147
  const scrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -139,6 +155,12 @@ export function ScreenScrollProvider({
139
155
  [headerCollapseHeight]
140
156
  );
141
157
 
158
+ // Rounded so a subpixel layout measurement cannot loop the content padding
159
+ // against the layout that produced it.
160
+ const setHeaderAccessoryHeight = useCallback((height: number) => {
161
+ setAccessoryHeight(Math.max(0, Math.round(height)));
162
+ }, []);
163
+
142
164
  const scrollHandler = useAnimatedScrollHandler({
143
165
  onScroll: (event) => {
144
166
  scrollY.value = event.contentOffset.y;
@@ -155,6 +177,8 @@ export function ScreenScrollProvider({
155
177
  platformInsetsEligible,
156
178
  margins: contentPadding,
157
179
  hasHeader,
180
+ headerAccessoryHeight,
181
+ setHeaderAccessoryHeight,
158
182
  scrollY,
159
183
  headerCollapseHeight,
160
184
  headerSnapOffsets,
@@ -170,6 +194,8 @@ export function ScreenScrollProvider({
170
194
  platformAdjustsInsets,
171
195
  platformInsetsEligible,
172
196
  hasHeader,
197
+ headerAccessoryHeight,
198
+ setHeaderAccessoryHeight,
173
199
  scrollY,
174
200
  headerCollapseHeight,
175
201
  headerSnapOffsets,
@@ -174,6 +174,7 @@ const ScreenListBase = forwardRef(function ScreenList(
174
174
  headerSnapOffsets,
175
175
  contentPadding: screenPadding,
176
176
  hasHeader,
177
+ headerAccessoryHeight,
177
178
  contentOwnsBottomInset,
178
179
  platformAdjustsInsets,
179
180
  } = useScreen();
@@ -224,7 +225,7 @@ const ScreenListBase = forwardRef(function ScreenList(
224
225
  automaticallyAdjustContentInsets={false}
225
226
  automaticallyAdjustsScrollIndicatorInsets={false}
226
227
  contentContainerStyle={[
227
- styles.content(resolvedPadding, hasHeader, bottomInset, extraBottomPadding),
228
+ styles.content(resolvedPadding, hasHeader, bottomInset, extraBottomPadding, headerAccessoryHeight),
228
229
  contentContainerStyle,
229
230
  ]}
230
231
  ListHeaderComponent={header}
@@ -265,12 +266,16 @@ const styles = StyleSheet.create((theme, runtime) => ({
265
266
  padding: ScreenContentPadding,
266
267
  hasHeader: boolean,
267
268
  bottomInset: number | undefined,
268
- extraBottomPadding: number
269
+ extraBottomPadding: number,
270
+ accessoryHeight: number
269
271
  ) => {
270
272
  const { x, y } = resolveScreenPadding(theme, runtime.breakpoint);
271
273
  return {
272
274
  flexGrow: 1,
273
- paddingTop: (hasHeader ? theme.layout.header.height : 0) + (hasVerticalPadding(padding) ? y : 0),
275
+ // The theme height covers the bar; an accessory pinned under it is extra.
276
+ paddingTop:
277
+ (hasHeader ? theme.layout.header.height + accessoryHeight : 0) +
278
+ (hasVerticalPadding(padding) ? y : 0),
274
279
  paddingHorizontal: hasHorizontalPadding(padding) ? x : 0,
275
280
  paddingBottom: (bottomInset ?? (hasVerticalPadding(padding) ? y : 0)) + extraBottomPadding,
276
281
  };
@@ -71,6 +71,7 @@ export const ScreenScrollView = forwardRef<ComponentRef<typeof ScrollView>, Scre
71
71
  scrollHandler,
72
72
  contentPadding: screenPadding,
73
73
  hasHeader,
74
+ headerAccessoryHeight,
74
75
  background,
75
76
  contentOwnsBottomInset,
76
77
  platformAdjustsInsets,
@@ -105,7 +106,7 @@ export const ScreenScrollView = forwardRef<ComponentRef<typeof ScrollView>, Scre
105
106
  showsVerticalScrollIndicator={false}
106
107
  scrollEventThrottle={16}
107
108
  contentContainerStyle={[
108
- styles.scrollContent(hasHeader, resolvedPadding, extraBottomPadding),
109
+ styles.scrollContent(hasHeader, resolvedPadding, extraBottomPadding, headerAccessoryHeight),
109
110
  contentContainerStyle,
110
111
  ]}
111
112
  {...rest}
@@ -124,11 +125,19 @@ export const ScreenScrollView = forwardRef<ComponentRef<typeof ScrollView>, Scre
124
125
  ScreenScrollView.displayName = "Screen.ScrollView";
125
126
 
126
127
  const styles = StyleSheet.create((theme, runtime) => ({
127
- scrollContent: (hasHeader: boolean, padding: ScreenContentPadding, extraBottomPadding: number) => {
128
+ scrollContent: (
129
+ hasHeader: boolean,
130
+ padding: ScreenContentPadding,
131
+ extraBottomPadding: number,
132
+ accessoryHeight: number
133
+ ) => {
128
134
  const { x, y } = resolveScreenPadding(theme, runtime.breakpoint);
129
135
  return {
130
136
  flexGrow: 1,
131
- paddingTop: (hasHeader ? theme.layout.header.height : 0) + (hasVerticalPadding(padding) ? y : 0),
137
+ // The theme height covers the bar; an accessory pinned under it is extra.
138
+ paddingTop:
139
+ (hasHeader ? theme.layout.header.height + accessoryHeight : 0) +
140
+ (hasVerticalPadding(padding) ? y : 0),
132
141
  paddingHorizontal: hasHorizontalPadding(padding) ? x : 0,
133
142
  paddingBottom: (hasVerticalPadding(padding) ? y : 0) + extraBottomPadding,
134
143
  };
@@ -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"