@vvedantb/native-ui 0.2.0 → 0.3.12

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 (86) hide show
  1. package/README.md +160 -9
  2. package/package.json +88 -8
  3. package/src/AlertDialog.tsx +73 -0
  4. package/src/AmbientBackground.tsx +26 -11
  5. package/src/Avatar.tsx +141 -0
  6. package/src/Badge.tsx +108 -0
  7. package/src/BottomSheetContainer.tsx +7 -1
  8. package/src/Button.tsx +153 -52
  9. package/src/Card.tsx +111 -36
  10. package/src/Checkbox.tsx +78 -0
  11. package/src/Chip.tsx +5 -3
  12. package/src/ConfirmDialog.tsx +105 -0
  13. package/src/DateTimeField.tsx +6 -2
  14. package/src/Dialog.tsx +253 -0
  15. package/src/EmptyState.tsx +122 -7
  16. package/src/FloatingFAB.tsx +2 -4
  17. package/src/FloatingSurface.tsx +31 -9
  18. package/src/FloatingTabBar.tsx +39 -11
  19. package/src/Input.tsx +15 -4
  20. package/src/Label.tsx +31 -0
  21. package/src/LabeledSwitchRow.tsx +96 -0
  22. package/src/LiquidGlass.tsx +2 -10
  23. package/src/ListRow.tsx +7 -2
  24. package/src/PillTabs.tsx +28 -14
  25. package/src/Progress.tsx +67 -0
  26. package/src/SearchInput.tsx +163 -0
  27. package/src/Select.tsx +32 -6
  28. package/src/Separator.tsx +38 -0
  29. package/src/Sheet.tsx +107 -0
  30. package/src/Skeleton.tsx +25 -5
  31. package/src/Spinner.tsx +92 -0
  32. package/src/StatusDot.tsx +66 -0
  33. package/src/Switch.tsx +24 -7
  34. package/src/Tabs.tsx +190 -0
  35. package/src/Textarea.tsx +47 -0
  36. package/src/Toast.tsx +170 -0
  37. package/src/blur.ts +48 -0
  38. package/src/brand/AppIcons.tsx +106 -0
  39. package/src/brand/AppMark.tsx +83 -0
  40. package/src/brand/AppWordmark.tsx +97 -0
  41. package/src/brand/familyApps.ts +24 -0
  42. package/src/brand/index.ts +34 -0
  43. package/src/brand/svg.ts +263 -0
  44. package/src/brand/vProAppBenefits.ts +22 -0
  45. package/src/controlStyles.ts +360 -0
  46. package/src/dialogLayout.ts +20 -0
  47. package/src/entries/ambient.ts +3 -0
  48. package/src/entries/bottom-sheet.ts +2 -0
  49. package/src/entries/brand.ts +2 -0
  50. package/src/entries/button.ts +3 -0
  51. package/src/entries/date-time.ts +3 -0
  52. package/src/entries/floating.ts +24 -0
  53. package/src/entries/layout.ts +7 -0
  54. package/src/entries/marketing.ts +10 -0
  55. package/src/entries/primitives.ts +99 -0
  56. package/src/entries/search-input.ts +4 -0
  57. package/src/entries/tab-bar.ts +10 -0
  58. package/src/entries/theme.ts +15 -0
  59. package/src/entries/toast.ts +6 -0
  60. package/src/examples/ParityGallery.tsx +496 -0
  61. package/src/floating.ts +5 -1
  62. package/src/glassBlur.tsx +95 -0
  63. package/src/gradient.ts +21 -0
  64. package/src/haptics.ts +22 -2
  65. package/src/index.ts +99 -2
  66. package/src/initials.ts +10 -0
  67. package/src/marketing/PricingModal.tsx +410 -0
  68. package/src/marketing/UpgradeToProCard.tsx +103 -0
  69. package/src/marketing/pricing.ts +76 -0
  70. package/src/palette.ts +43 -0
  71. package/src/primitives/input.tsx +9 -2
  72. package/src/primitives/native-only-animated-view.tsx +10 -3
  73. package/src/primitives/overlay.tsx +179 -0
  74. package/src/primitives/select.tsx +25 -12
  75. package/src/primitives/switch.tsx +12 -13
  76. package/src/primitives/use-controllable-state.ts +24 -0
  77. package/src/settings/SettingsField.tsx +11 -2
  78. package/src/settings/SettingsSection.tsx +20 -6
  79. package/src/settings/SettingsToggleRow.tsx +7 -2
  80. package/src/tabBarRoutes.ts +52 -0
  81. package/src/toastChrome.ts +103 -0
  82. package/src/toastStore.ts +86 -0
  83. package/src/tokens.ts +19 -14
  84. package/src/typography.ts +24 -0
  85. package/src/useReduceMotion.ts +24 -0
  86. package/src/useScheme.ts +6 -0
@@ -1,9 +1,9 @@
1
- import type { ComponentProps } from "react";
2
1
  import { Pressable, Text, View } from "react-native";
3
2
  import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { Tabs } from "expo-router";
3
+ import { typeStyle } from "./controlStyles";
5
4
  import { selectionHaptic } from "./haptics";
6
5
  import { FloatingSurface } from "./FloatingSurface";
6
+ import { isTabBarRouteVisible, type FloatingTabOptions } from "./tabBarRoutes";
7
7
  import { useLargerText } from "./theme";
8
8
  import {
9
9
  FLOATING_INSET,
@@ -12,15 +12,38 @@ import {
12
12
  FLOATING_TAB_BAR_HEIGHT_LARGE,
13
13
  } from "./floating";
14
14
 
15
- type TabBarProps = Parameters<
16
- NonNullable<ComponentProps<typeof Tabs>["tabBar"]>
17
- >[0];
15
+ /**
16
+ * The fields this bar reads from the props Expo Router's `Tabs` passes to
17
+ * `tabBar` (React Navigation's `BottomTabBarProps`, Expo SDK 57). Declared
18
+ * here so the tab bar does not import `expo-router`.
19
+ */
20
+ export type FloatingTabBarProps = {
21
+ state: {
22
+ index: number;
23
+ routes: readonly { key: string; name: string; params?: object }[];
24
+ };
25
+ descriptors: Record<string, { options: FloatingTabOptions }>;
26
+ navigation: {
27
+ emit(event: {
28
+ type: "tabPress";
29
+ target: string;
30
+ canPreventDefault: true;
31
+ }): { defaultPrevented: boolean };
32
+ navigate(name: string, params?: object): void;
33
+ };
34
+ };
18
35
 
36
+ /**
37
+ * The floating pill tab bar, for the `tabBar` prop of Expo Router's `Tabs`.
38
+ * Hide a tab with `options={{ href: null }}` on its `Tabs.Screen`: the bar
39
+ * draws no tab for it (`isTabBarRouteVisible`), and the other tabs keep their
40
+ * focus and navigation.
41
+ */
19
42
  export function FloatingTabBar({
20
43
  state,
21
44
  descriptors,
22
45
  navigation,
23
- }: TabBarProps) {
46
+ }: FloatingTabBarProps) {
24
47
  const insets = useSafeAreaInsets();
25
48
  const largerText = useLargerText();
26
49
  const height = largerText
@@ -30,10 +53,11 @@ export function FloatingTabBar({
30
53
  return (
31
54
  <View
32
55
  pointerEvents="box-none"
56
+ // Inside the home indicator and, in landscape, the notch.
33
57
  style={{
34
58
  position: "absolute",
35
- left: FLOATING_INSET,
36
- right: FLOATING_INSET,
59
+ left: insets.left + FLOATING_INSET,
60
+ right: insets.right + FLOATING_INSET,
37
61
  bottom: FLOATING_INSET + Math.max(insets.bottom, 8),
38
62
  }}
39
63
  >
@@ -44,11 +68,12 @@ export function FloatingTabBar({
44
68
  accessibilityRole="tablist"
45
69
  >
46
70
  {state.routes.map((route, index) => {
47
- const focused = state.index === index;
48
71
  const options = descriptors[route.key]?.options;
49
- if (!options) {
72
+ if (!isTabBarRouteVisible(options)) {
50
73
  return null;
51
74
  }
75
+ // `index` counts hidden routes too, as `state.index` does.
76
+ const focused = state.index === index;
52
77
  const label =
53
78
  typeof options.tabBarLabel === "string"
54
79
  ? options.tabBarLabel
@@ -90,10 +115,13 @@ export function FloatingTabBar({
90
115
  : null}
91
116
  <Text
92
117
  numberOfLines={1}
118
+ // The bar has a fixed height (`tabBarReserve`), so labels
119
+ // scale with Dynamic Type only up to what fits in it.
120
+ maxFontSizeMultiplier={1.5}
93
121
  className={`font-instrument-sans-semibold ${
94
122
  largerText ? "text-base" : "text-sm"
95
123
  }`}
96
- style={{ color }}
124
+ style={[{ color }, typeStyle("semibold")]}
97
125
  >
98
126
  {label}
99
127
  </Text>
package/src/Input.tsx CHANGED
@@ -1,7 +1,10 @@
1
1
  import * as React from "react";
2
2
  import { Input as PrimitiveInput } from "./primitives/input";
3
3
  import { cn } from "./cn";
4
+ import { fieldStyle, fieldTextStyle, typeStyle } from "./controlStyles";
5
+ import { palette } from "./palette";
4
6
  import { useLargerText } from "./theme";
7
+ import { useScheme } from "./useScheme";
5
8
 
6
9
  type InputProps = React.ComponentPropsWithoutRef<typeof PrimitiveInput> & {
7
10
  className?: string;
@@ -10,17 +13,25 @@ type InputProps = React.ComponentPropsWithoutRef<typeof PrimitiveInput> & {
10
13
  const Input = React.forwardRef<
11
14
  React.ElementRef<typeof PrimitiveInput>,
12
15
  InputProps
13
- >(({ className, placeholderTextColor, ...props }, ref) => {
16
+ >(({ className, placeholderTextColor, style, ...props }, ref) => {
14
17
  const largerText = useLargerText();
18
+ const scheme = useScheme();
15
19
  return (
16
20
  <PrimitiveInput
17
21
  ref={ref}
18
- placeholderTextColor={placeholderTextColor ?? "#737373"}
22
+ placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
19
23
  className={cn(
20
- "min-h-14 rounded-full border border-white/40 bg-secondary px-4 py-3 text-lg font-instrument-sans-medium text-foreground dark:border-white/10",
21
- largerText && "min-h-16 py-4 text-xl",
24
+ // The web field: h-10 px-3.5 py-2, 16 px type (its phone size).
25
+ "min-h-10 border px-3.5 py-2 text-base font-instrument-sans",
26
+ largerText && "min-h-16 px-4 py-4 text-xl font-instrument-sans-medium",
22
27
  className,
23
28
  )}
29
+ style={[
30
+ fieldStyle(scheme),
31
+ fieldTextStyle(scheme),
32
+ largerText ? typeStyle("medium") : null,
33
+ style,
34
+ ]}
24
35
  {...props}
25
36
  />
26
37
  );
package/src/Label.tsx ADDED
@@ -0,0 +1,31 @@
1
+ import { Text, type TextProps } from "react-native";
2
+ import { cn } from "./cn";
3
+ import { sizeStyle, textStyle, typeStyle } from "./controlStyles";
4
+ import { useLargerText } from "./theme";
5
+ import { useScheme } from "./useScheme";
6
+
7
+ export type LabelProps = TextProps & {
8
+ className?: string;
9
+ /** Dims the label (the web `peer-disabled` style). */
10
+ disabled?: boolean;
11
+ };
12
+
13
+ /**
14
+ * Field label, the web `Label` type: `text-sm` medium (14 / 500), `text-lg`
15
+ * with larger text. Link it to a field with
16
+ * `nativeID` here and `accessibilityLabelledBy` on the field (Android), or
17
+ * give the field its own `accessibilityLabel`. The colour is a style that
18
+ * follows the colour scheme; recolour with `style`.
19
+ */
20
+ export function Label({ className, disabled, style, ...props }: LabelProps) {
21
+ const largerText = useLargerText();
22
+ const scheme = useScheme();
23
+ const size = largerText ? "text-lg" : "text-sm";
24
+ return (
25
+ <Text
26
+ className={cn("font-instrument-sans-medium", size, disabled && "opacity-70", className)}
27
+ style={[textStyle("foreground", scheme), sizeStyle(size), typeStyle("medium"), style]}
28
+ {...props}
29
+ />
30
+ );
31
+ }
@@ -0,0 +1,96 @@
1
+ import type { ReactNode } from "react";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import { cn } from "./cn";
4
+ import { textStyle, typeStyle } from "./controlStyles";
5
+ import { Switch } from "./Switch";
6
+ import { useLargerText } from "./theme";
7
+ import { useScheme } from "./useScheme";
8
+
9
+ export type LabeledSwitchRowProps = {
10
+ label: string;
11
+ description?: string;
12
+ checked: boolean;
13
+ onCheckedChange?: (checked: boolean) => void;
14
+ disabled?: boolean;
15
+ icon?: ReactNode;
16
+ /** Replaces the switch. */
17
+ trailing?: ReactNode;
18
+ className?: string;
19
+ testID?: string;
20
+ };
21
+
22
+ /**
23
+ * Label, optional description and a `Switch`, as the web `LabeledSwitchRow`.
24
+ * The text toggles the switch too (the web `<label htmlFor>`). Use
25
+ * `SettingsToggleRow` inside a `SettingsSection` list instead.
26
+ */
27
+ export function LabeledSwitchRow({
28
+ label,
29
+ description,
30
+ checked,
31
+ onCheckedChange,
32
+ disabled,
33
+ icon,
34
+ trailing,
35
+ className,
36
+ testID,
37
+ }: LabeledSwitchRowProps) {
38
+ const largerText = useLargerText();
39
+ const scheme = useScheme();
40
+ const toggle = () => onCheckedChange?.(!checked);
41
+ return (
42
+ <View className={cn("flex-row items-center justify-between gap-3", className)}>
43
+ <Pressable
44
+ testID={testID}
45
+ accessibilityRole={trailing ? undefined : "switch"}
46
+ accessibilityLabel={label}
47
+ accessibilityHint={description}
48
+ accessibilityState={
49
+ trailing ? undefined : { checked, disabled: Boolean(disabled) }
50
+ }
51
+ disabled={disabled || trailing != null}
52
+ onPress={toggle}
53
+ className="min-w-0 flex-1 flex-row items-start gap-2"
54
+ >
55
+ {icon ? <View className="mt-0.5 shrink-0">{icon}</View> : null}
56
+ <View className="min-w-0 flex-1">
57
+ <Text
58
+ className={cn(
59
+ "font-instrument-sans-semibold",
60
+ largerText ? "text-xl" : "text-lg",
61
+ disabled && "opacity-70",
62
+ )}
63
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
64
+ >
65
+ {label}
66
+ </Text>
67
+ {description ? (
68
+ <Text
69
+ className={cn(
70
+ "mt-1 font-instrument-sans",
71
+ largerText ? "text-lg" : "text-base",
72
+ )}
73
+ style={textStyle("muted", scheme)}
74
+ >
75
+ {description}
76
+ </Text>
77
+ ) : null}
78
+ </View>
79
+ </Pressable>
80
+ {trailing ?? (
81
+ // The row above is the accessible switch, so this one is hidden from assistive tech.
82
+ <View
83
+ accessibilityElementsHidden
84
+ importantForAccessibility="no-hide-descendants"
85
+ className="min-h-12 shrink-0 items-center justify-center"
86
+ >
87
+ <Switch
88
+ checked={checked}
89
+ onCheckedChange={onCheckedChange}
90
+ disabled={disabled}
91
+ />
92
+ </View>
93
+ )}
94
+ </View>
95
+ );
96
+ }
@@ -1,14 +1,6 @@
1
- import type { ReactNode } from "react";
2
-
3
1
  export {
4
2
  FloatingSurface as LiquidGlass,
5
3
  GlassSheetBackground,
6
4
  } from "./FloatingSurface";
7
-
8
- export function GlassBlurProvider({ children }: { children: ReactNode }) {
9
- return children;
10
- }
11
-
12
- export function GlassBlurTarget({ children }: { children: ReactNode }) {
13
- return children;
14
- }
5
+ export { GlassBlurProvider, GlassBlurTarget } from "./glassBlur";
6
+ export type { GlassBlurTargetProps } from "./glassBlur";
package/src/ListRow.tsx CHANGED
@@ -3,8 +3,10 @@ import { Pressable, Text, View } from "react-native";
3
3
  import type { IconProps } from "@tabler/icons-react-native";
4
4
  import { NEUTRAL_500 } from "./colors";
5
5
  import { cn } from "./cn";
6
+ import { textStyle, typeStyle } from "./controlStyles";
6
7
  import { FLOATING_ROW_RADIUS } from "./floating";
7
8
  import { useLargerText } from "./theme";
9
+ import { useScheme } from "./useScheme";
8
10
 
9
11
  export function ListRow({
10
12
  title,
@@ -28,6 +30,7 @@ export function ListRow({
28
30
  testID?: string;
29
31
  }) {
30
32
  const largerText = useLargerText();
33
+ const scheme = useScheme();
31
34
  const content = (
32
35
  <>
33
36
  {Icon ? <Icon size={largerText ? 30 : 26} color={iconColor} /> : null}
@@ -35,18 +38,20 @@ export function ListRow({
35
38
  <Text
36
39
  numberOfLines={2}
37
40
  className={cn(
38
- "font-instrument-sans-semibold text-foreground",
41
+ "font-instrument-sans-semibold",
39
42
  largerText ? "text-xl" : "text-lg",
40
43
  )}
44
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
41
45
  >
42
46
  {title}
43
47
  </Text>
44
48
  {meta ? (
45
49
  <Text
46
50
  className={cn(
47
- "font-instrument-sans text-muted-foreground",
51
+ "font-instrument-sans",
48
52
  largerText ? "text-lg" : "text-base",
49
53
  )}
54
+ style={textStyle("muted", scheme)}
50
55
  >
51
56
  {meta}
52
57
  </Text>
package/src/PillTabs.tsx CHANGED
@@ -1,6 +1,19 @@
1
1
  import { View, Text, Pressable } from "react-native";
2
+ import { cn } from "./cn";
3
+ import {
4
+ MIN_TOUCH_TARGET,
5
+ tabColors,
6
+ tabTriggerSize,
7
+ touchSlop,
8
+ typeStyle,
9
+ } from "./controlStyles";
2
10
  import { useLargerText } from "./theme";
11
+ import { useScheme } from "./useScheme";
3
12
 
13
+ /**
14
+ * A row of pill tabs from an options array: the `pills` `TabsList` look and
15
+ * size (30 high, `text-sm` medium), as wide as its pills, at the start.
16
+ */
4
17
  export function PillTabs<T extends string>({
5
18
  options,
6
19
  value,
@@ -11,10 +24,16 @@ export function PillTabs<T extends string>({
11
24
  onChange: (id: T) => void;
12
25
  }) {
13
26
  const largerText = useLargerText();
27
+ const scheme = useScheme();
28
+ const trigger = tabTriggerSize("pills", "default", largerText);
14
29
  return (
15
- <View className="flex-row gap-2">
30
+ <View
31
+ accessibilityRole="tablist"
32
+ className="max-w-full flex-row flex-wrap gap-1.5 self-start"
33
+ >
16
34
  {options.map((tab) => {
17
35
  const isSelected = value === tab.id;
36
+ const colors = tabColors("pills", isSelected, scheme);
18
37
 
19
38
  return (
20
39
  <Pressable
@@ -24,22 +43,17 @@ export function PillTabs<T extends string>({
24
43
  onPress={() => onChange(tab.id)}
25
44
  accessibilityRole="tab"
26
45
  accessibilityState={{ selected: isSelected }}
27
- className={`flex-1 justify-center rounded-full border border-white/40 px-3 dark:border-white/10 ${
28
- largerText ? "min-h-16 py-3" : "min-h-14 py-3"
29
- } ${
30
- isSelected ? "bg-pink-100 dark:bg-pink-900/40" : "bg-secondary"
31
- }`}
32
- style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
46
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, trigger.minHeight)}
47
+ className={cn(
48
+ "shrink justify-center rounded-full border border-white/40 active:opacity-70 dark:border-white/10",
49
+ trigger.className,
50
+ )}
51
+ style={{ backgroundColor: colors.backgroundColor, minHeight: trigger.minHeight }}
33
52
  >
34
53
  <Text
35
54
  numberOfLines={1}
36
- className={`text-center font-instrument-sans-semibold ${
37
- largerText ? "text-xl" : "text-lg"
38
- } ${
39
- isSelected
40
- ? "text-pink-700 dark:text-pink-300"
41
- : "text-muted-foreground"
42
- }`}
55
+ className={cn("text-center font-instrument-sans-medium", trigger.textClassName)}
56
+ style={[{ color: colors.color }, typeStyle("medium")]}
43
57
  >
44
58
  {tab.label}
45
59
  </Text>
@@ -0,0 +1,67 @@
1
+ import { useEffect } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withTiming,
8
+ } from "react-native-reanimated";
9
+ import { cn } from "./cn";
10
+ import { palette } from "./palette";
11
+ import { useAccentColor } from "./theme";
12
+ import { useReduceMotion } from "./useReduceMotion";
13
+ import { useScheme } from "./useScheme";
14
+
15
+ export type ProgressProps = ViewProps & {
16
+ /** Current value, 0 to `max`. Default 0. */
17
+ value?: number | null;
18
+ /** Default 100. */
19
+ max?: number;
20
+ /** Indicator fill. Default the theme accent. */
21
+ indicatorColor?: string;
22
+ className?: string;
23
+ };
24
+
25
+ /** Determinate bar: 10 high, rounded, `palette.default` track. The fill eases over 300 ms (instant with Reduce Motion). */
26
+ export function Progress({
27
+ value,
28
+ max = 100,
29
+ indicatorColor,
30
+ className,
31
+ style,
32
+ ...props
33
+ }: ProgressProps) {
34
+ const scheme = useScheme();
35
+ const accent = useAccentColor();
36
+ const reduceMotion = useReduceMotion();
37
+ const percent = Math.min(100, Math.max(0, ((value ?? 0) / max) * 100));
38
+ const width = useSharedValue(percent);
39
+
40
+ useEffect(() => {
41
+ width.value = reduceMotion
42
+ ? percent
43
+ : withTiming(percent, {
44
+ duration: 300,
45
+ easing: Easing.bezier(0.22, 1, 0.36, 1),
46
+ });
47
+ }, [percent, reduceMotion, width]);
48
+
49
+ const indicatorStyle = useAnimatedStyle(() => ({
50
+ width: `${width.value}%` as const,
51
+ }));
52
+
53
+ return (
54
+ <View
55
+ accessibilityRole="progressbar"
56
+ accessibilityValue={{ min: 0, max, now: value ?? 0 }}
57
+ className={cn("h-2.5 w-full overflow-hidden rounded-full", className)}
58
+ style={[{ backgroundColor: palette.default[scheme] }, style]}
59
+ {...props}
60
+ >
61
+ <Animated.View
62
+ className="h-full rounded-full"
63
+ style={[{ backgroundColor: indicatorColor ?? accent }, indicatorStyle]}
64
+ />
65
+ </View>
66
+ );
67
+ }
@@ -0,0 +1,163 @@
1
+ import * as React from "react";
2
+ import { Pressable, useWindowDimensions, View } from "react-native";
3
+ import { IconSearch, IconX } from "@tabler/icons-react-native";
4
+ import { Input as PrimitiveInput } from "./primitives/input";
5
+ import { cn } from "./cn";
6
+ import {
7
+ fieldStyle,
8
+ fieldTextStyle,
9
+ scaledFieldHeight,
10
+ searchInputSize,
11
+ sizeStyle,
12
+ touchSlop,
13
+ typeStyle,
14
+ type SearchInputSize,
15
+ } from "./controlStyles";
16
+ import { palette } from "./palette";
17
+ import { tokens } from "./tokens";
18
+ import { useLargerText } from "./theme";
19
+ import { useScheme } from "./useScheme";
20
+
21
+ type SearchInputProps = Omit<
22
+ React.ComponentPropsWithoutRef<typeof PrimitiveInput>,
23
+ "value" | "onChange" | "onChangeText"
24
+ > & {
25
+ value: string;
26
+ /** Called with the new text, as the web `onChange(value)`. */
27
+ onChange: (value: string) => void;
28
+ /** Accessibility label, and the placeholder when `placeholder` is not set. Default `"Search"`. */
29
+ label?: string;
30
+ /** `sm` is 32 high (web `h-8`), `md` is 40 (web `h-10`), border included. Default `"sm"`. */
31
+ size?: SearchInputSize;
32
+ /** Called after `onChange` whenever the value becomes empty (typed away or cleared). */
33
+ onClear?: () => void;
34
+ /** Accessibility label of the clear button. Default `"Clear search"`. */
35
+ clearLabel?: string;
36
+ /** Classes for the field. */
37
+ className?: string;
38
+ /** Classes for the `View` around the field, icon and clear button. */
39
+ wrapperClassName?: string;
40
+ };
41
+
42
+ /** The web change rule: `onChange`, then `onClear` whenever the value becomes empty. */
43
+ export function searchChange(onChange: (value: string) => void, onClear?: () => void) {
44
+ return (next: string) => {
45
+ onChange(next);
46
+ if (onClear && next.length === 0) onClear();
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Search field, the web `SearchInput`: a 16 search icon at the start and,
52
+ * while there is text, a clear button (a 14 cross labelled "Clear search") at
53
+ * the end. Controlled with `value` and `onChange(text)`, as on the web. The
54
+ * field has the `Input` look (`fieldStyle`) at the web sizes from
55
+ * `searchInputSize`. The clear button is painted at the web 24 and reaches 44
56
+ * with its hit slop (`touchSlop`). It clears at once and leaves focus where it
57
+ * was, as the default web clear does after its dissolve. It is hidden when the
58
+ * field is not editable. Inside a `ScrollView`, set
59
+ * `keyboardShouldPersistTaps="handled"` so the first tap clears while the
60
+ * keyboard is up. The web `shortcut` hint and `toolbar` variant are not ported.
61
+ */
62
+ const SearchInput = React.forwardRef<
63
+ React.ElementRef<typeof PrimitiveInput>,
64
+ SearchInputProps
65
+ >(
66
+ (
67
+ {
68
+ value,
69
+ onChange,
70
+ label = "Search",
71
+ placeholder,
72
+ size = "sm",
73
+ onClear,
74
+ clearLabel = "Clear search",
75
+ className,
76
+ wrapperClassName,
77
+ placeholderTextColor,
78
+ style,
79
+ ...props
80
+ },
81
+ ref,
82
+ ) => {
83
+ const largerText = useLargerText();
84
+ const scheme = useScheme();
85
+ const { fontScale } = useWindowDimensions();
86
+ const metrics = searchInputSize(size, largerText);
87
+ const change = searchChange(onChange, onClear);
88
+ const filled = value.length > 0;
89
+ const muted = tokens.color.muted[scheme];
90
+ return (
91
+ <View className={wrapperClassName}>
92
+ <PrimitiveInput
93
+ ref={ref}
94
+ value={value}
95
+ onChangeText={change}
96
+ accessibilityLabel={label}
97
+ placeholder={placeholder ?? label}
98
+ placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
99
+ // The web `enterKeyHint="search"`.
100
+ returnKeyType="search"
101
+ className={cn(
102
+ "border font-instrument-sans",
103
+ metrics.text,
104
+ largerText && "font-instrument-sans-medium",
105
+ className,
106
+ )}
107
+ style={[
108
+ fieldStyle(scheme),
109
+ fieldTextStyle(scheme),
110
+ {
111
+ minHeight: metrics.minHeight,
112
+ // The web border-box height, so the class line height cannot
113
+ // grow the field; it still grows with the system font size.
114
+ height: scaledFieldHeight(metrics.height, fontScale),
115
+ paddingTop: metrics.paddingY,
116
+ paddingBottom: metrics.paddingY,
117
+ paddingLeft: metrics.paddingLeft,
118
+ paddingRight: filled ? metrics.filledPaddingRight : metrics.paddingRight,
119
+ // The size without its line height: on a single-line field a
120
+ // line height can push the text off centre on iOS.
121
+ fontSize: sizeStyle(metrics.text).fontSize,
122
+ },
123
+ largerText ? typeStyle("medium") : null,
124
+ style,
125
+ ]}
126
+ {...props}
127
+ />
128
+ <View
129
+ pointerEvents="none"
130
+ className="absolute bottom-0 top-0 justify-center"
131
+ style={{ left: metrics.iconLeft }}
132
+ >
133
+ <IconSearch size={metrics.icon} color={muted} />
134
+ </View>
135
+ {filled && props.editable !== false ? (
136
+ <View
137
+ pointerEvents="box-none"
138
+ className="absolute bottom-0 top-0 justify-center"
139
+ style={{ right: metrics.clearRight }}
140
+ >
141
+ <Pressable
142
+ testID="search-input-clear"
143
+ accessibilityRole="button"
144
+ accessibilityLabel={clearLabel}
145
+ hitSlop={touchSlop(metrics.clear)}
146
+ onPress={() => change("")}
147
+ className="items-center justify-center rounded-md active:opacity-70"
148
+ style={{ width: metrics.clear, height: metrics.clear }}
149
+ >
150
+ {/* `strokeWidth`, not the web `stroke`: here `stroke` is the colour. */}
151
+ <IconX size={metrics.clearIcon} color={muted} strokeWidth={1.75} />
152
+ </Pressable>
153
+ </View>
154
+ ) : null}
155
+ </View>
156
+ );
157
+ },
158
+ );
159
+
160
+ SearchInput.displayName = "SearchInput";
161
+
162
+ export { SearchInput };
163
+ export type { SearchInputProps };