@umituz/react-native-design-system 2.0.16 → 2.1.1
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/package.json +1 -3
- package/src/atoms/AtomicIcon.tsx +35 -7
- package/src/atoms/AtomicIcon.types.ts +48 -0
- package/src/atoms/index.ts +2 -0
- package/src/molecules/SearchBar/SearchBar.tsx +142 -0
- package/src/molecules/SearchBar/SearchHistory.tsx +131 -0
- package/src/molecules/SearchBar/SearchSuggestions.tsx +113 -0
- package/src/molecules/SearchBar/index.ts +4 -0
- package/src/molecules/SearchBar/types.ts +46 -0
- package/src/molecules/SearchBar.tsx +0 -198
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-design-system",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Universal design system for React Native apps - Consolidated package with atoms, molecules, organisms, theme, typography, responsive and safe area utilities",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"@react-native-async-storage/async-storage": ">=2.0.0",
|
|
54
54
|
"@react-native-community/datetimepicker": ">=8.0.0",
|
|
55
55
|
"@react-navigation/native": ">=6.0.0",
|
|
56
|
-
"@umituz/react-native-icons": "latest",
|
|
57
56
|
"expo-linear-gradient": ">=15.0.0",
|
|
58
57
|
"react": ">=19.0.0",
|
|
59
58
|
"react-native": ">=0.81.0",
|
|
@@ -85,7 +84,6 @@
|
|
|
85
84
|
"@types/react": "~19.1.10",
|
|
86
85
|
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
87
86
|
"@typescript-eslint/parser": "^8.50.1",
|
|
88
|
-
"@umituz/react-native-icons": "latest",
|
|
89
87
|
"eslint": "^9.39.2",
|
|
90
88
|
"eslint-plugin-react": "^7.37.5",
|
|
91
89
|
"eslint-plugin-react-hooks": "^7.0.1",
|
package/src/atoms/AtomicIcon.tsx
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AtomicIcon - Theme-aware Icon Component
|
|
3
3
|
*
|
|
4
|
-
* Uses @
|
|
4
|
+
* Uses @expo/vector-icons/Ionicons internally
|
|
5
5
|
* Adds theme-aware semantic colors and background support
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React from "react";
|
|
9
9
|
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
10
|
-
import {
|
|
10
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
11
11
|
import { useAppDesignTokens } from '../theme';
|
|
12
|
+
import {
|
|
13
|
+
getIconSize,
|
|
14
|
+
ICON_SIZES,
|
|
15
|
+
type IconSize as BaseIconSize
|
|
16
|
+
} from "./AtomicIcon.types";
|
|
12
17
|
|
|
13
|
-
// Re-export IconSize
|
|
18
|
+
// Re-export IconSize for convenience
|
|
14
19
|
export type IconSize = BaseIconSize;
|
|
15
20
|
|
|
21
|
+
const FALLBACK_ICON = "help-circle-outline";
|
|
22
|
+
|
|
16
23
|
// Semantic color names that map to theme tokens
|
|
17
24
|
export type IconColor =
|
|
18
25
|
| "primary"
|
|
@@ -96,7 +103,7 @@ export const AtomicIcon: React.FC<AtomicIconProps> = React.memo(({
|
|
|
96
103
|
const tokens = useAppDesignTokens();
|
|
97
104
|
|
|
98
105
|
// Calculate size
|
|
99
|
-
const
|
|
106
|
+
const sizeInPixels = customSize ?? getIconSize(size);
|
|
100
107
|
|
|
101
108
|
// Calculate color
|
|
102
109
|
const iconColor = customColor
|
|
@@ -105,9 +112,29 @@ export const AtomicIcon: React.FC<AtomicIconProps> = React.memo(({
|
|
|
105
112
|
? getSemanticColor(color, tokens)
|
|
106
113
|
: tokens.colors.textPrimary;
|
|
107
114
|
|
|
115
|
+
// Validate icon
|
|
116
|
+
const isValidIcon = name in Ionicons.glyphMap;
|
|
117
|
+
const iconName = isValidIcon ? name : FALLBACK_ICON;
|
|
118
|
+
|
|
119
|
+
if (__DEV__ && !isValidIcon) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`[AtomicIcon] Invalid icon name: "${name}". Using fallback icon "${FALLBACK_ICON}". ` +
|
|
122
|
+
`Available icons: https://icons.expo.fyi/`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const iconElement = (
|
|
127
|
+
<Ionicons
|
|
128
|
+
name={iconName as keyof typeof Ionicons.glyphMap}
|
|
129
|
+
size={sizeInPixels}
|
|
130
|
+
color={iconColor}
|
|
131
|
+
testID={testID ? `${testID}-icon` : undefined}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
|
|
108
135
|
if (withBackground) {
|
|
109
136
|
const bgColor = backgroundColor || tokens.colors.surfaceVariant;
|
|
110
|
-
const containerSize =
|
|
137
|
+
const containerSize = sizeInPixels + 16;
|
|
111
138
|
|
|
112
139
|
return (
|
|
113
140
|
<View
|
|
@@ -124,14 +151,14 @@ export const AtomicIcon: React.FC<AtomicIconProps> = React.memo(({
|
|
|
124
151
|
testID={testID}
|
|
125
152
|
accessibilityLabel={accessibilityLabel}
|
|
126
153
|
>
|
|
127
|
-
|
|
154
|
+
{iconElement}
|
|
128
155
|
</View>
|
|
129
156
|
);
|
|
130
157
|
}
|
|
131
158
|
|
|
132
159
|
return (
|
|
133
160
|
<View accessibilityLabel={accessibilityLabel} testID={testID} style={style}>
|
|
134
|
-
|
|
161
|
+
{iconElement}
|
|
135
162
|
</View>
|
|
136
163
|
);
|
|
137
164
|
});
|
|
@@ -147,3 +174,4 @@ const styles = StyleSheet.create({
|
|
|
147
174
|
|
|
148
175
|
// Legacy type alias for backward compatibility
|
|
149
176
|
export type IconProps = AtomicIconProps;
|
|
177
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon Type Definitions
|
|
3
|
+
* Centralized icon types for @expo/vector-icons Ionicons
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Ionicons } from "@expo/vector-icons";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* All available Ionicons names (type-safe)
|
|
10
|
+
*/
|
|
11
|
+
export type IoniconsName = keyof typeof Ionicons.glyphMap;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Semantic icon size presets
|
|
15
|
+
*/
|
|
16
|
+
export type IconSizePreset = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Icon size - preset name or custom number in pixels
|
|
20
|
+
*/
|
|
21
|
+
export type IconSize = IconSizePreset | number;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Icon size mapping to pixels
|
|
25
|
+
*/
|
|
26
|
+
export const ICON_SIZES: Record<IconSizePreset, number> = {
|
|
27
|
+
xs: 12,
|
|
28
|
+
sm: 16,
|
|
29
|
+
md: 20,
|
|
30
|
+
lg: 24,
|
|
31
|
+
xl: 32,
|
|
32
|
+
xxl: 48,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get icon size in pixels
|
|
37
|
+
*/
|
|
38
|
+
export function getIconSize(size: IconSize): number {
|
|
39
|
+
if (typeof size === "number") return size;
|
|
40
|
+
return ICON_SIZES[size];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if size is a preset
|
|
45
|
+
*/
|
|
46
|
+
export function isIconSizePreset(size: IconSize): size is IconSizePreset {
|
|
47
|
+
return typeof size === "string" && size in ICON_SIZES;
|
|
48
|
+
}
|
package/src/atoms/index.ts
CHANGED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
TextInput,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
ActivityIndicator,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import { useAppDesignTokens } from '../../theme';
|
|
10
|
+
import { AtomicIcon } from '../../atoms/AtomicIcon';
|
|
11
|
+
import type { SearchBarProps } from './types';
|
|
12
|
+
|
|
13
|
+
export const SearchBar = forwardRef<TextInput, SearchBarProps>(({
|
|
14
|
+
value,
|
|
15
|
+
onChangeText,
|
|
16
|
+
onSubmit,
|
|
17
|
+
onClear,
|
|
18
|
+
onFocus,
|
|
19
|
+
onBlur,
|
|
20
|
+
placeholder = 'Search...',
|
|
21
|
+
autoFocus = false,
|
|
22
|
+
loading = false,
|
|
23
|
+
disabled = false,
|
|
24
|
+
containerStyle,
|
|
25
|
+
inputStyle,
|
|
26
|
+
testID,
|
|
27
|
+
}, ref) => {
|
|
28
|
+
const tokens = useAppDesignTokens();
|
|
29
|
+
|
|
30
|
+
const handleClear = () => {
|
|
31
|
+
onChangeText('');
|
|
32
|
+
onClear?.();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const showClear = value.length > 0 && !loading;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<View
|
|
39
|
+
style={[
|
|
40
|
+
styles.container,
|
|
41
|
+
{
|
|
42
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
43
|
+
borderColor: tokens.colors.border,
|
|
44
|
+
},
|
|
45
|
+
containerStyle,
|
|
46
|
+
]}
|
|
47
|
+
testID={testID}
|
|
48
|
+
>
|
|
49
|
+
<View style={styles.iconContainer}>
|
|
50
|
+
<AtomicIcon
|
|
51
|
+
name="search"
|
|
52
|
+
size="md"
|
|
53
|
+
customColor={tokens.colors.textSecondary}
|
|
54
|
+
/>
|
|
55
|
+
</View>
|
|
56
|
+
|
|
57
|
+
<TextInput
|
|
58
|
+
ref={ref}
|
|
59
|
+
value={value}
|
|
60
|
+
onChangeText={onChangeText}
|
|
61
|
+
onSubmitEditing={onSubmit}
|
|
62
|
+
onFocus={onFocus}
|
|
63
|
+
onBlur={onBlur}
|
|
64
|
+
placeholder={placeholder}
|
|
65
|
+
placeholderTextColor={tokens.colors.textSecondary}
|
|
66
|
+
autoFocus={autoFocus}
|
|
67
|
+
editable={!disabled}
|
|
68
|
+
returnKeyType="search"
|
|
69
|
+
autoCapitalize="none"
|
|
70
|
+
autoCorrect={false}
|
|
71
|
+
style={[
|
|
72
|
+
styles.input,
|
|
73
|
+
{
|
|
74
|
+
color: tokens.colors.textPrimary,
|
|
75
|
+
fontSize: 16, // Body medium usually
|
|
76
|
+
},
|
|
77
|
+
inputStyle,
|
|
78
|
+
]}
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
{(loading || showClear) && (
|
|
82
|
+
<View style={styles.rightActions}>
|
|
83
|
+
{loading && (
|
|
84
|
+
<ActivityIndicator
|
|
85
|
+
size="small"
|
|
86
|
+
color={tokens.colors.primary}
|
|
87
|
+
style={styles.loader}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{showClear && (
|
|
92
|
+
<TouchableOpacity
|
|
93
|
+
onPress={handleClear}
|
|
94
|
+
style={styles.clearButton}
|
|
95
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
96
|
+
accessibilityRole="button"
|
|
97
|
+
accessibilityLabel="Clear search"
|
|
98
|
+
>
|
|
99
|
+
<AtomicIcon
|
|
100
|
+
name="close-circle"
|
|
101
|
+
size="md"
|
|
102
|
+
customColor={tokens.colors.textSecondary}
|
|
103
|
+
/>
|
|
104
|
+
</TouchableOpacity>
|
|
105
|
+
)}
|
|
106
|
+
</View>
|
|
107
|
+
)}
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
SearchBar.displayName = 'SearchBar';
|
|
113
|
+
|
|
114
|
+
const styles = StyleSheet.create({
|
|
115
|
+
container: {
|
|
116
|
+
flexDirection: 'row',
|
|
117
|
+
alignItems: 'center',
|
|
118
|
+
paddingHorizontal: 12,
|
|
119
|
+
height: 48,
|
|
120
|
+
borderRadius: 24, // Pill shape
|
|
121
|
+
borderWidth: 1,
|
|
122
|
+
},
|
|
123
|
+
iconContainer: {
|
|
124
|
+
marginRight: 8,
|
|
125
|
+
},
|
|
126
|
+
input: {
|
|
127
|
+
flex: 1,
|
|
128
|
+
height: '100%',
|
|
129
|
+
paddingVertical: 0, // Reset default padding
|
|
130
|
+
},
|
|
131
|
+
rightActions: {
|
|
132
|
+
flexDirection: 'row',
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
marginLeft: 8,
|
|
135
|
+
},
|
|
136
|
+
loader: {
|
|
137
|
+
marginRight: 8,
|
|
138
|
+
},
|
|
139
|
+
clearButton: {
|
|
140
|
+
padding: 2,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
TouchableOpacity,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
} from 'react-native';
|
|
7
|
+
import { AtomicText } from '../../atoms/AtomicText';
|
|
8
|
+
import { AtomicIcon } from '../../atoms/AtomicIcon';
|
|
9
|
+
import { useAppDesignTokens } from '../../theme';
|
|
10
|
+
import type { SearchHistoryProps } from './types';
|
|
11
|
+
|
|
12
|
+
export const SearchHistory: React.FC<SearchHistoryProps> = ({
|
|
13
|
+
history,
|
|
14
|
+
onSelectItem,
|
|
15
|
+
onRemoveItem,
|
|
16
|
+
onClearAll,
|
|
17
|
+
maxItems = 10,
|
|
18
|
+
style,
|
|
19
|
+
title = 'Recent Searches',
|
|
20
|
+
clearLabel = 'Clear All',
|
|
21
|
+
}) => {
|
|
22
|
+
const tokens = useAppDesignTokens();
|
|
23
|
+
|
|
24
|
+
if (!history || history.length === 0) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const displayedHistory = history.slice(0, maxItems);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<View style={[styles.container, style]}>
|
|
32
|
+
<View style={styles.header}>
|
|
33
|
+
<AtomicText
|
|
34
|
+
type="labelLarge"
|
|
35
|
+
style={{ color: tokens.colors.textSecondary }}
|
|
36
|
+
>
|
|
37
|
+
{title}
|
|
38
|
+
</AtomicText>
|
|
39
|
+
<TouchableOpacity
|
|
40
|
+
onPress={onClearAll}
|
|
41
|
+
style={styles.clearButton}
|
|
42
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
43
|
+
>
|
|
44
|
+
<AtomicText
|
|
45
|
+
type="labelMedium"
|
|
46
|
+
style={{ color: tokens.colors.primary }}
|
|
47
|
+
>
|
|
48
|
+
{clearLabel}
|
|
49
|
+
</AtomicText>
|
|
50
|
+
</TouchableOpacity>
|
|
51
|
+
</View>
|
|
52
|
+
|
|
53
|
+
{displayedHistory.map((item) => (
|
|
54
|
+
<TouchableOpacity
|
|
55
|
+
key={item.id}
|
|
56
|
+
onPress={() => onSelectItem(item.query)}
|
|
57
|
+
style={styles.item}
|
|
58
|
+
>
|
|
59
|
+
<View style={styles.itemLeft}>
|
|
60
|
+
<AtomicIcon
|
|
61
|
+
name="time-outline"
|
|
62
|
+
size="sm"
|
|
63
|
+
customColor={tokens.colors.textSecondary}
|
|
64
|
+
/>
|
|
65
|
+
<AtomicText
|
|
66
|
+
type="bodyMedium"
|
|
67
|
+
style={[
|
|
68
|
+
styles.itemText,
|
|
69
|
+
{ color: tokens.colors.textPrimary }
|
|
70
|
+
]}
|
|
71
|
+
numberOfLines={1}
|
|
72
|
+
>
|
|
73
|
+
{item.query}
|
|
74
|
+
</AtomicText>
|
|
75
|
+
</View>
|
|
76
|
+
|
|
77
|
+
<TouchableOpacity
|
|
78
|
+
onPress={() => onRemoveItem(item.id)}
|
|
79
|
+
style={styles.removeButton}
|
|
80
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
81
|
+
>
|
|
82
|
+
<AtomicIcon
|
|
83
|
+
name="close"
|
|
84
|
+
size="sm"
|
|
85
|
+
customColor={tokens.colors.textSecondary}
|
|
86
|
+
/>
|
|
87
|
+
</TouchableOpacity>
|
|
88
|
+
</TouchableOpacity>
|
|
89
|
+
))}
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const styles = StyleSheet.create({
|
|
95
|
+
container: {
|
|
96
|
+
paddingVertical: 8,
|
|
97
|
+
},
|
|
98
|
+
header: {
|
|
99
|
+
flexDirection: 'row',
|
|
100
|
+
justifyContent: 'space-between',
|
|
101
|
+
alignItems: 'center',
|
|
102
|
+
paddingHorizontal: 16,
|
|
103
|
+
paddingVertical: 8,
|
|
104
|
+
marginBottom: 4,
|
|
105
|
+
},
|
|
106
|
+
clearButton: {
|
|
107
|
+
paddingVertical: 4,
|
|
108
|
+
paddingHorizontal: 8,
|
|
109
|
+
},
|
|
110
|
+
item: {
|
|
111
|
+
flexDirection: 'row',
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
justifyContent: 'space-between',
|
|
114
|
+
paddingHorizontal: 16,
|
|
115
|
+
paddingVertical: 12,
|
|
116
|
+
minHeight: 48,
|
|
117
|
+
},
|
|
118
|
+
itemLeft: {
|
|
119
|
+
flexDirection: 'row',
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
flex: 1,
|
|
122
|
+
marginRight: 12,
|
|
123
|
+
},
|
|
124
|
+
itemText: {
|
|
125
|
+
marginLeft: 12,
|
|
126
|
+
flex: 1,
|
|
127
|
+
},
|
|
128
|
+
removeButton: {
|
|
129
|
+
padding: 4,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
TouchableOpacity,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
} from 'react-native';
|
|
7
|
+
import { AtomicText } from '../../atoms/AtomicText';
|
|
8
|
+
import { useAppDesignTokens } from '../../theme';
|
|
9
|
+
import type { SearchSuggestionsProps } from './types';
|
|
10
|
+
|
|
11
|
+
export function SearchSuggestions<T>({
|
|
12
|
+
query,
|
|
13
|
+
suggestions,
|
|
14
|
+
renderItem,
|
|
15
|
+
onSelectSuggestion,
|
|
16
|
+
maxSuggestions = 5,
|
|
17
|
+
style,
|
|
18
|
+
emptyComponent,
|
|
19
|
+
}: SearchSuggestionsProps<T>) {
|
|
20
|
+
const tokens = useAppDesignTokens();
|
|
21
|
+
|
|
22
|
+
if (!query.trim() && !emptyComponent) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (suggestions.length === 0) {
|
|
27
|
+
return <>{emptyComponent}</>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const displayedSuggestions = suggestions.slice(0, maxSuggestions);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<View
|
|
34
|
+
style={[
|
|
35
|
+
styles.container,
|
|
36
|
+
{
|
|
37
|
+
backgroundColor: tokens.colors.surface,
|
|
38
|
+
},
|
|
39
|
+
style,
|
|
40
|
+
]}
|
|
41
|
+
>
|
|
42
|
+
{displayedSuggestions.map((item, index) => (
|
|
43
|
+
<TouchableOpacity
|
|
44
|
+
key={index}
|
|
45
|
+
onPress={() => onSelectSuggestion(item)}
|
|
46
|
+
style={[
|
|
47
|
+
styles.item,
|
|
48
|
+
index > 0 ? {
|
|
49
|
+
borderTopWidth: 1,
|
|
50
|
+
borderTopColor: tokens.colors.border,
|
|
51
|
+
} : undefined,
|
|
52
|
+
]}
|
|
53
|
+
>
|
|
54
|
+
{renderItem(item, query)}
|
|
55
|
+
</TouchableOpacity>
|
|
56
|
+
))}
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const styles = StyleSheet.create({
|
|
62
|
+
container: {
|
|
63
|
+
borderRadius: 12,
|
|
64
|
+
overflow: 'hidden',
|
|
65
|
+
},
|
|
66
|
+
item: {
|
|
67
|
+
paddingHorizontal: 16,
|
|
68
|
+
paddingVertical: 12,
|
|
69
|
+
minHeight: 48,
|
|
70
|
+
justifyContent: 'center',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Default suggestion renderer (simple text with highlighting)
|
|
76
|
+
*/
|
|
77
|
+
export const DefaultSuggestionRenderer = (
|
|
78
|
+
text: string,
|
|
79
|
+
query: string,
|
|
80
|
+
tokens: ReturnType<typeof useAppDesignTokens>
|
|
81
|
+
) => {
|
|
82
|
+
const lowerText = text.toLowerCase();
|
|
83
|
+
const lowerQuery = query.toLowerCase();
|
|
84
|
+
const index = lowerText.indexOf(lowerQuery);
|
|
85
|
+
|
|
86
|
+
if (index === -1) {
|
|
87
|
+
return (
|
|
88
|
+
<AtomicText
|
|
89
|
+
type="bodyMedium"
|
|
90
|
+
style={{ color: tokens.colors.textPrimary }}
|
|
91
|
+
>
|
|
92
|
+
{text}
|
|
93
|
+
</AtomicText>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const before = text.slice(0, index);
|
|
98
|
+
const match = text.slice(index, index + query.length);
|
|
99
|
+
const after = text.slice(index + query.length);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<AtomicText
|
|
103
|
+
type="bodyMedium"
|
|
104
|
+
style={{ color: tokens.colors.textPrimary }}
|
|
105
|
+
>
|
|
106
|
+
{before}
|
|
107
|
+
<AtomicText type="bodyMedium" style={{ fontWeight: '700', color: tokens.colors.primary }}>
|
|
108
|
+
{match}
|
|
109
|
+
</AtomicText>
|
|
110
|
+
{after}
|
|
111
|
+
</AtomicText>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export interface SearchHistoryItem {
|
|
4
|
+
id: string;
|
|
5
|
+
query: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SearchBarProps {
|
|
10
|
+
value: string;
|
|
11
|
+
onChangeText: (text: string) => void;
|
|
12
|
+
onSubmit?: () => void;
|
|
13
|
+
onClear?: () => void;
|
|
14
|
+
onFocus?: () => void;
|
|
15
|
+
onBlur?: () => void;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
autoFocus?: boolean;
|
|
18
|
+
loading?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
showCancelButton?: boolean;
|
|
21
|
+
onCancel?: () => void;
|
|
22
|
+
containerStyle?: StyleProp<ViewStyle>;
|
|
23
|
+
inputStyle?: StyleProp<TextStyle>;
|
|
24
|
+
testID?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchHistoryProps {
|
|
28
|
+
history: SearchHistoryItem[];
|
|
29
|
+
onSelectItem: (query: string) => void;
|
|
30
|
+
onRemoveItem: (id: string) => void;
|
|
31
|
+
onClearAll: () => void;
|
|
32
|
+
maxItems?: number;
|
|
33
|
+
style?: StyleProp<ViewStyle>;
|
|
34
|
+
title?: string;
|
|
35
|
+
clearLabel?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SearchSuggestionsProps<T> {
|
|
39
|
+
query: string;
|
|
40
|
+
suggestions: T[];
|
|
41
|
+
renderItem: (item: T, query: string) => React.ReactNode;
|
|
42
|
+
onSelectSuggestion: (item: T) => void;
|
|
43
|
+
maxSuggestions?: number;
|
|
44
|
+
style?: StyleProp<ViewStyle>;
|
|
45
|
+
emptyComponent?: React.ReactNode;
|
|
46
|
+
}
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SearchBar Molecule - Enhanced Search Input with Clear Button
|
|
3
|
-
*
|
|
4
|
-
* Universal search input with clear functionality and loading state
|
|
5
|
-
* Fully configurable for general purpose use
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import React from 'react';
|
|
9
|
-
import {
|
|
10
|
-
View,
|
|
11
|
-
TextInput,
|
|
12
|
-
TouchableOpacity,
|
|
13
|
-
ActivityIndicator,
|
|
14
|
-
StyleSheet,
|
|
15
|
-
type StyleProp,
|
|
16
|
-
type ViewStyle,
|
|
17
|
-
} from 'react-native';
|
|
18
|
-
import { useAppDesignTokens } from '../theme';
|
|
19
|
-
import { AtomicIcon } from '../atoms';
|
|
20
|
-
|
|
21
|
-
export interface SearchBarProps {
|
|
22
|
-
value: string;
|
|
23
|
-
onChangeText: (text: string) => void;
|
|
24
|
-
onSubmit?: () => void;
|
|
25
|
-
onClear?: () => void;
|
|
26
|
-
placeholder?: string;
|
|
27
|
-
autoFocus?: boolean;
|
|
28
|
-
loading?: boolean;
|
|
29
|
-
disabled?: boolean;
|
|
30
|
-
style?: StyleProp<ViewStyle>;
|
|
31
|
-
containerStyle?: ViewStyle;
|
|
32
|
-
searchIconName?: string;
|
|
33
|
-
clearIconName?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const useSearchBarLogic = (
|
|
37
|
-
value: string,
|
|
38
|
-
loading: boolean,
|
|
39
|
-
onChangeText: (text: string) => void,
|
|
40
|
-
onClear?: () => void,
|
|
41
|
-
onSubmit?: () => void
|
|
42
|
-
) => {
|
|
43
|
-
const handleClear = React.useCallback(() => {
|
|
44
|
-
onChangeText('');
|
|
45
|
-
onClear?.();
|
|
46
|
-
}, [onChangeText, onClear]);
|
|
47
|
-
|
|
48
|
-
const handleSubmit = React.useCallback(() => {
|
|
49
|
-
onSubmit?.();
|
|
50
|
-
}, [onSubmit]);
|
|
51
|
-
|
|
52
|
-
const showClear = React.useMemo(() =>
|
|
53
|
-
value.length > 0 && !loading, [value, loading]
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
return { handleClear, handleSubmit, showClear };
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const SearchBarInput: React.FC<{
|
|
60
|
-
value: string;
|
|
61
|
-
placeholder?: string;
|
|
62
|
-
autoFocus?: boolean;
|
|
63
|
-
disabled?: boolean;
|
|
64
|
-
style?: StyleProp<ViewStyle>;
|
|
65
|
-
onChangeText: (text: string) => void;
|
|
66
|
-
onSubmitEditing?: () => void;
|
|
67
|
-
tokens: ReturnType<typeof useAppDesignTokens>;
|
|
68
|
-
}> = ({ value, placeholder, autoFocus, disabled, style, onChangeText, onSubmitEditing, tokens }) => {
|
|
69
|
-
const styles = React.useMemo(() => getSearchBarStyles(), []);
|
|
70
|
-
|
|
71
|
-
const inputStyle = React.useMemo(() => [
|
|
72
|
-
styles.input,
|
|
73
|
-
{
|
|
74
|
-
color: tokens.colors.textPrimary,
|
|
75
|
-
...tokens.typography.bodyMedium,
|
|
76
|
-
},
|
|
77
|
-
], [styles.input, tokens.colors.textPrimary, tokens.typography.bodyMedium]);
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<TextInput
|
|
81
|
-
value={value}
|
|
82
|
-
onChangeText={onChangeText}
|
|
83
|
-
onSubmitEditing={onSubmitEditing}
|
|
84
|
-
placeholder={placeholder}
|
|
85
|
-
placeholderTextColor={tokens.colors.textSecondary}
|
|
86
|
-
autoFocus={autoFocus}
|
|
87
|
-
editable={!disabled}
|
|
88
|
-
returnKeyType="search"
|
|
89
|
-
autoCapitalize="none"
|
|
90
|
-
autoCorrect={false}
|
|
91
|
-
style={[inputStyle, style]}
|
|
92
|
-
/>
|
|
93
|
-
);
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const SearchBarIcons: React.FC<{
|
|
97
|
-
searchIconName: string;
|
|
98
|
-
clearIconName: string;
|
|
99
|
-
loading: boolean;
|
|
100
|
-
showClear: boolean;
|
|
101
|
-
onClear: () => void;
|
|
102
|
-
tokens: ReturnType<typeof useAppDesignTokens>;
|
|
103
|
-
}> = ({ searchIconName, clearIconName, loading, showClear, onClear, tokens }) => {
|
|
104
|
-
const styles = React.useMemo(() => getSearchBarStyles(), []);
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<>
|
|
108
|
-
<AtomicIcon
|
|
109
|
-
name={searchIconName}
|
|
110
|
-
customSize={20}
|
|
111
|
-
customColor={tokens.colors.textSecondary}
|
|
112
|
-
style={styles.searchIcon}
|
|
113
|
-
/>
|
|
114
|
-
|
|
115
|
-
{loading && (
|
|
116
|
-
<ActivityIndicator
|
|
117
|
-
size="small"
|
|
118
|
-
color={tokens.colors.primary}
|
|
119
|
-
style={styles.icon}
|
|
120
|
-
/>
|
|
121
|
-
)}
|
|
122
|
-
|
|
123
|
-
{showClear && (
|
|
124
|
-
<TouchableOpacity onPress={onClear} style={styles.icon}>
|
|
125
|
-
<AtomicIcon name={clearIconName} customSize={20} customColor={tokens.colors.textSecondary} />
|
|
126
|
-
</TouchableOpacity>
|
|
127
|
-
)}
|
|
128
|
-
</>
|
|
129
|
-
);
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const SearchBarComponent: React.FC<SearchBarProps> = ({
|
|
133
|
-
value,
|
|
134
|
-
onChangeText,
|
|
135
|
-
onSubmit,
|
|
136
|
-
onClear,
|
|
137
|
-
placeholder,
|
|
138
|
-
autoFocus = false,
|
|
139
|
-
loading = false,
|
|
140
|
-
disabled = false,
|
|
141
|
-
style,
|
|
142
|
-
containerStyle,
|
|
143
|
-
searchIconName = 'search',
|
|
144
|
-
clearIconName = 'close-circle',
|
|
145
|
-
}) => {
|
|
146
|
-
const tokens = useAppDesignTokens();
|
|
147
|
-
const { handleClear, handleSubmit, showClear } = useSearchBarLogic(
|
|
148
|
-
value, loading, onChangeText, onClear, onSubmit
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
return (
|
|
152
|
-
<View style={containerStyle}>
|
|
153
|
-
<SearchBarIcons
|
|
154
|
-
searchIconName={searchIconName}
|
|
155
|
-
clearIconName={clearIconName}
|
|
156
|
-
loading={loading}
|
|
157
|
-
showClear={showClear}
|
|
158
|
-
onClear={handleClear}
|
|
159
|
-
tokens={tokens}
|
|
160
|
-
/>
|
|
161
|
-
|
|
162
|
-
<SearchBarInput
|
|
163
|
-
value={value}
|
|
164
|
-
placeholder={placeholder}
|
|
165
|
-
autoFocus={autoFocus}
|
|
166
|
-
disabled={disabled}
|
|
167
|
-
style={style}
|
|
168
|
-
onChangeText={onChangeText}
|
|
169
|
-
onSubmitEditing={handleSubmit}
|
|
170
|
-
tokens={tokens}
|
|
171
|
-
/>
|
|
172
|
-
</View>
|
|
173
|
-
);
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
export const SearchBar = SearchBarComponent;
|
|
177
|
-
|
|
178
|
-
const getSearchBarStyles = () => StyleSheet.create({
|
|
179
|
-
container: {
|
|
180
|
-
flexDirection: 'row',
|
|
181
|
-
alignItems: 'center',
|
|
182
|
-
paddingHorizontal: 12,
|
|
183
|
-
paddingVertical: 8,
|
|
184
|
-
borderRadius: 12,
|
|
185
|
-
minHeight: 44,
|
|
186
|
-
},
|
|
187
|
-
searchIcon: {
|
|
188
|
-
marginRight: 12,
|
|
189
|
-
},
|
|
190
|
-
input: {
|
|
191
|
-
flex: 1,
|
|
192
|
-
paddingVertical: 0,
|
|
193
|
-
},
|
|
194
|
-
icon: {
|
|
195
|
-
marginLeft: 8,
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
|