@umituz/react-native-bottom-sheet 1.3.5 → 1.4.0
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 +11 -4
- package/src/domain/entities/Filter.ts +48 -0
- package/src/index.ts +6 -0
- package/src/presentation/components/filter/FilterBottomSheet.tsx +166 -0
- package/src/presentation/components/filter/FilterSheet.tsx +115 -0
- package/src/presentation/components/filter/FilterSheetComponents/FilterSheetHeader.tsx +33 -0
- package/src/presentation/components/filter/FilterSheetComponents/FilterSheetOption.tsx +59 -0
- package/src/presentation/hooks/useListFilters.ts +95 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-bottom-sheet",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Modern, performant bottom sheets for React Native with preset configurations, keyboard handling, and
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Modern, performant bottom sheets for React Native with preset configurations, keyboard handling, smooth animations, and advanced filtering capabilities",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"bottom-sheet",
|
|
17
17
|
"modal",
|
|
18
18
|
"sheet",
|
|
19
|
-
"overlay"
|
|
19
|
+
"overlay",
|
|
20
|
+
"filter",
|
|
21
|
+
"filtering"
|
|
20
22
|
],
|
|
21
23
|
"author": "Ümit UZ <umit@umituz.com>",
|
|
22
24
|
"license": "MIT",
|
|
@@ -27,10 +29,12 @@
|
|
|
27
29
|
"peerDependencies": {
|
|
28
30
|
"@gorhom/bottom-sheet": ">=4.6.0",
|
|
29
31
|
"@umituz/react-native-design-system": "2.0.16",
|
|
32
|
+
"@umituz/react-native-localization": "latest",
|
|
30
33
|
"react": ">=18.2.0",
|
|
31
34
|
"react-native": ">=0.74.0",
|
|
32
35
|
"react-native-gesture-handler": ">=2.0.0",
|
|
33
|
-
"react-native-reanimated": ">=3.0.0"
|
|
36
|
+
"react-native-reanimated": ">=3.0.0",
|
|
37
|
+
"react-native-safe-area-context": ">=4.0.0"
|
|
34
38
|
},
|
|
35
39
|
"devDependencies": {
|
|
36
40
|
"@gorhom/bottom-sheet": "^5.0.0",
|
|
@@ -38,6 +42,8 @@
|
|
|
38
42
|
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
39
43
|
"@typescript-eslint/parser": "^8.50.1",
|
|
40
44
|
"@umituz/react-native-design-system": "2.0.16",
|
|
45
|
+
"@umituz/react-native-localization": "^3.5.25",
|
|
46
|
+
"@umituz/react-native-storage": "^2.4.5",
|
|
41
47
|
"eslint": "^9.39.2",
|
|
42
48
|
"eslint-plugin-react": "^7.37.5",
|
|
43
49
|
"eslint-plugin-react-native": "^5.0.0",
|
|
@@ -45,6 +51,7 @@
|
|
|
45
51
|
"react-native": "0.81.5",
|
|
46
52
|
"react-native-gesture-handler": "^2.0.0",
|
|
47
53
|
"react-native-reanimated": "^3.0.0",
|
|
54
|
+
"react-native-safe-area-context": "^5.6.2",
|
|
48
55
|
"typescript": "~5.9.2"
|
|
49
56
|
},
|
|
50
57
|
"publishConfig": {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain - Filter Entities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface FilterOption {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon?: string;
|
|
9
|
+
type?: string;
|
|
10
|
+
count?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FilterCategory {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
options: FilterOption[];
|
|
17
|
+
multiSelect?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FilterUtils {
|
|
21
|
+
static hasActiveFilter(selectedIds: string[], defaultId: string = "all"): boolean {
|
|
22
|
+
if (selectedIds.length === 0) return false;
|
|
23
|
+
if (selectedIds.length === 1 && selectedIds[0] === defaultId) return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static toggleFilter(
|
|
28
|
+
selectedIds: string[],
|
|
29
|
+
filterId: string,
|
|
30
|
+
multiSelect: boolean = false,
|
|
31
|
+
defaultId: string = "all"
|
|
32
|
+
): string[] {
|
|
33
|
+
if (filterId === defaultId) {
|
|
34
|
+
return [defaultId];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (multiSelect) {
|
|
38
|
+
const newIds = selectedIds.filter((id) => id !== defaultId);
|
|
39
|
+
if (newIds.includes(filterId)) {
|
|
40
|
+
const filtered = newIds.filter((id) => id !== filterId);
|
|
41
|
+
return filtered.length === 0 ? [defaultId] : filtered;
|
|
42
|
+
}
|
|
43
|
+
return [...newIds, filterId];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [filterId];
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,3 +4,9 @@ export * from './presentation/components/SafeBottomSheetModalProvider';
|
|
|
4
4
|
export * from './presentation/hooks/useBottomSheet';
|
|
5
5
|
export * from './presentation/hooks/useBottomSheetModal';
|
|
6
6
|
export * from './domain/entities/BottomSheet';
|
|
7
|
+
|
|
8
|
+
// Filter exports
|
|
9
|
+
export * from './domain/entities/Filter';
|
|
10
|
+
export * from './presentation/components/filter/FilterSheet';
|
|
11
|
+
export * from './presentation/components/filter/FilterBottomSheet';
|
|
12
|
+
export * from './presentation/hooks/useListFilters';
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation - Filter Bottom Sheet component
|
|
3
|
+
*
|
|
4
|
+
* Advanced filtering UI using @umituz/react-native-bottom-sheet
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { forwardRef, useCallback } from 'react';
|
|
8
|
+
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
|
9
|
+
import { BottomSheetModal } from '../BottomSheetModal';
|
|
10
|
+
import type { BottomSheetModalRef } from '../../../domain/entities/BottomSheet';
|
|
11
|
+
import { AtomicText, AtomicIcon, AtomicButton } from '@umituz/react-native-design-system';
|
|
12
|
+
import { useAppDesignTokens } from '@umituz/react-native-design-system';
|
|
13
|
+
import { useLocalization } from '@umituz/react-native-localization';
|
|
14
|
+
import type { FilterOption, FilterCategory } from '../../../domain/entities/Filter';
|
|
15
|
+
import { FilterUtils } from '../../../domain/entities/Filter';
|
|
16
|
+
|
|
17
|
+
export interface FilterBottomSheetProps {
|
|
18
|
+
readonly categories: FilterCategory[];
|
|
19
|
+
readonly selectedIds: string[];
|
|
20
|
+
readonly onFilterPress: (id: string, categoryId: string) => void;
|
|
21
|
+
readonly onClearFilters: () => void;
|
|
22
|
+
readonly onDismiss?: () => void;
|
|
23
|
+
readonly title?: string;
|
|
24
|
+
readonly defaultId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const FilterBottomSheet = forwardRef<BottomSheetModalRef, FilterBottomSheetProps>(({
|
|
28
|
+
categories,
|
|
29
|
+
selectedIds,
|
|
30
|
+
onFilterPress,
|
|
31
|
+
onClearFilters,
|
|
32
|
+
onDismiss,
|
|
33
|
+
title,
|
|
34
|
+
defaultId = 'all'
|
|
35
|
+
}, ref) => {
|
|
36
|
+
const tokens = useAppDesignTokens();
|
|
37
|
+
const { t } = useLocalization();
|
|
38
|
+
|
|
39
|
+
const styles = React.useMemo(() => StyleSheet.create({
|
|
40
|
+
container: {
|
|
41
|
+
flex: 1,
|
|
42
|
+
padding: 16,
|
|
43
|
+
},
|
|
44
|
+
header: {
|
|
45
|
+
flexDirection: 'row',
|
|
46
|
+
justifyContent: 'space-between',
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
marginBottom: 20,
|
|
49
|
+
},
|
|
50
|
+
category: {
|
|
51
|
+
marginBottom: 24,
|
|
52
|
+
},
|
|
53
|
+
categoryTitle: {
|
|
54
|
+
marginBottom: 12,
|
|
55
|
+
opacity: 0.7,
|
|
56
|
+
},
|
|
57
|
+
optionsGrid: {
|
|
58
|
+
flexDirection: 'row',
|
|
59
|
+
flexWrap: 'wrap',
|
|
60
|
+
gap: 8,
|
|
61
|
+
},
|
|
62
|
+
option: {
|
|
63
|
+
flexDirection: 'row',
|
|
64
|
+
alignItems: 'center',
|
|
65
|
+
paddingHorizontal: 12,
|
|
66
|
+
paddingVertical: 8,
|
|
67
|
+
borderRadius: 20,
|
|
68
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
69
|
+
gap: 6,
|
|
70
|
+
borderWidth: 1,
|
|
71
|
+
borderColor: 'transparent',
|
|
72
|
+
},
|
|
73
|
+
optionLeft: {
|
|
74
|
+
flexDirection: 'row',
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
gap: 6,
|
|
77
|
+
},
|
|
78
|
+
footer: {
|
|
79
|
+
marginTop: 16,
|
|
80
|
+
paddingBottom: 8,
|
|
81
|
+
}
|
|
82
|
+
}), [tokens]);
|
|
83
|
+
|
|
84
|
+
const renderOption = useCallback((option: FilterOption, categoryId: string) => {
|
|
85
|
+
const isSelected = selectedIds.includes(option.id);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<TouchableOpacity
|
|
89
|
+
key={option.id}
|
|
90
|
+
style={[
|
|
91
|
+
styles.option,
|
|
92
|
+
isSelected && { backgroundColor: tokens.colors.primary + '15' }
|
|
93
|
+
]}
|
|
94
|
+
onPress={() => onFilterPress(option.id, categoryId)}
|
|
95
|
+
>
|
|
96
|
+
<View style={styles.optionLeft}>
|
|
97
|
+
{option.icon && (
|
|
98
|
+
<AtomicIcon
|
|
99
|
+
name={option.icon as any}
|
|
100
|
+
size="sm"
|
|
101
|
+
color={isSelected ? 'primary' : 'secondary'}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
<AtomicText
|
|
105
|
+
type="bodyMedium"
|
|
106
|
+
style={[isSelected && { color: tokens.colors.primary, fontWeight: '600' }]}
|
|
107
|
+
>
|
|
108
|
+
{option.label}
|
|
109
|
+
</AtomicText>
|
|
110
|
+
</View>
|
|
111
|
+
{isSelected && (
|
|
112
|
+
<AtomicIcon name="checkmark" size="sm" color="primary" />
|
|
113
|
+
)}
|
|
114
|
+
</TouchableOpacity>
|
|
115
|
+
);
|
|
116
|
+
}, [selectedIds, tokens, onFilterPress]);
|
|
117
|
+
|
|
118
|
+
const renderCategory = useCallback((category: FilterCategory) => (
|
|
119
|
+
<View key={category.id} style={styles.category}>
|
|
120
|
+
<AtomicText type="labelLarge" style={styles.categoryTitle}>
|
|
121
|
+
{category.title}
|
|
122
|
+
</AtomicText>
|
|
123
|
+
<View style={styles.optionsGrid}>
|
|
124
|
+
{category.options.map(option => renderOption(option, category.id))}
|
|
125
|
+
</View>
|
|
126
|
+
</View>
|
|
127
|
+
), [renderOption, styles]);
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
const hasActiveFilters = FilterUtils.hasActiveFilter(selectedIds, defaultId);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<BottomSheetModal
|
|
134
|
+
ref={ref}
|
|
135
|
+
preset="medium"
|
|
136
|
+
onDismiss={onDismiss}
|
|
137
|
+
backgroundColor={tokens.colors.surface}
|
|
138
|
+
>
|
|
139
|
+
<View style={styles.container}>
|
|
140
|
+
<View style={styles.header}>
|
|
141
|
+
<AtomicText type="headlineSmall">{title || t('common.filter')}</AtomicText>
|
|
142
|
+
{hasActiveFilters && (
|
|
143
|
+
<TouchableOpacity onPress={onClearFilters}>
|
|
144
|
+
<AtomicText type="labelLarge" color="error">{t('common.clear')}</AtomicText>
|
|
145
|
+
</TouchableOpacity>
|
|
146
|
+
)}
|
|
147
|
+
</View>
|
|
148
|
+
|
|
149
|
+
<ScrollView showsVerticalScrollIndicator={false}>
|
|
150
|
+
{categories.map(renderCategory)}
|
|
151
|
+
</ScrollView>
|
|
152
|
+
|
|
153
|
+
<View style={styles.footer}>
|
|
154
|
+
<AtomicButton
|
|
155
|
+
onPress={() => (ref as any).current?.dismiss()}
|
|
156
|
+
fullWidth
|
|
157
|
+
>
|
|
158
|
+
{t('common.apply')}
|
|
159
|
+
</AtomicButton>
|
|
160
|
+
</View>
|
|
161
|
+
</View>
|
|
162
|
+
</BottomSheetModal>
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
FilterBottomSheet.displayName = 'FilterBottomSheet';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React, { useCallback } from "react";
|
|
2
|
+
import { View, StyleSheet, ScrollView, Modal, Pressable } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { AtomicButton, useAppDesignTokens } from "@umituz/react-native-design-system";
|
|
5
|
+
import { useLocalization } from "@umituz/react-native-localization";
|
|
6
|
+
import type { FilterOption } from "../../../domain/entities/Filter";
|
|
7
|
+
import { FilterUtils } from "../../../domain/entities/Filter";
|
|
8
|
+
import { FilterSheetHeader } from "./FilterSheetComponents/FilterSheetHeader";
|
|
9
|
+
import { FilterSheetOption } from "./FilterSheetComponents/FilterSheetOption";
|
|
10
|
+
|
|
11
|
+
export interface FilterSheetProps {
|
|
12
|
+
visible: boolean;
|
|
13
|
+
options: FilterOption[];
|
|
14
|
+
selectedIds: string[];
|
|
15
|
+
onFilterPress: (filterId: string) => void;
|
|
16
|
+
onClearFilters: () => void;
|
|
17
|
+
onClose?: () => void;
|
|
18
|
+
defaultFilterId?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const FilterSheet: React.FC<FilterSheetProps> = ({
|
|
23
|
+
visible,
|
|
24
|
+
options,
|
|
25
|
+
selectedIds,
|
|
26
|
+
onFilterPress,
|
|
27
|
+
onClearFilters,
|
|
28
|
+
onClose,
|
|
29
|
+
defaultFilterId = "all",
|
|
30
|
+
title,
|
|
31
|
+
}) => {
|
|
32
|
+
const tokens = useAppDesignTokens();
|
|
33
|
+
const { t } = useLocalization();
|
|
34
|
+
const insets = useSafeAreaInsets();
|
|
35
|
+
|
|
36
|
+
const hasActiveFilter = FilterUtils.hasActiveFilter(selectedIds, defaultFilterId);
|
|
37
|
+
|
|
38
|
+
const handleFilterPressWithClose = useCallback((id: string) => {
|
|
39
|
+
onFilterPress(id);
|
|
40
|
+
onClose?.();
|
|
41
|
+
}, [onFilterPress, onClose]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
|
45
|
+
<Pressable style={styles.backdrop} onPress={onClose}>
|
|
46
|
+
<Pressable
|
|
47
|
+
style={[styles.sheet, { backgroundColor: tokens.colors.surface, paddingBottom: insets.bottom }]}
|
|
48
|
+
onPress={(e) => e.stopPropagation()}
|
|
49
|
+
>
|
|
50
|
+
<View style={[styles.handle, { backgroundColor: tokens.colors.border }]} />
|
|
51
|
+
|
|
52
|
+
<FilterSheetHeader
|
|
53
|
+
title={title || t("general.filter")}
|
|
54
|
+
onClose={() => onClose?.()}
|
|
55
|
+
tokens={tokens}
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
<ScrollView style={styles.optionsList} showsVerticalScrollIndicator={false}>
|
|
59
|
+
{options.map((option) => (
|
|
60
|
+
<FilterSheetOption
|
|
61
|
+
key={option.id}
|
|
62
|
+
option={option}
|
|
63
|
+
isSelected={selectedIds.includes(option.id)}
|
|
64
|
+
onPress={handleFilterPressWithClose}
|
|
65
|
+
tokens={tokens}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</ScrollView>
|
|
69
|
+
|
|
70
|
+
{hasActiveFilter && (
|
|
71
|
+
<View style={[styles.footer, { borderTopColor: tokens.colors.border, borderTopWidth: tokens.borders.width.thin }]}>
|
|
72
|
+
<AtomicButton variant="outline" onPress={onClearFilters} fullWidth>
|
|
73
|
+
{t("general.clear")}
|
|
74
|
+
</AtomicButton>
|
|
75
|
+
</View>
|
|
76
|
+
)}
|
|
77
|
+
</Pressable>
|
|
78
|
+
</Pressable>
|
|
79
|
+
</Modal>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
FilterSheet.displayName = "FilterSheet";
|
|
84
|
+
|
|
85
|
+
const styles = StyleSheet.create({
|
|
86
|
+
backdrop: {
|
|
87
|
+
flex: 1,
|
|
88
|
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
89
|
+
justifyContent: "flex-end",
|
|
90
|
+
},
|
|
91
|
+
sheet: {
|
|
92
|
+
borderTopLeftRadius: 16,
|
|
93
|
+
borderTopRightRadius: 16,
|
|
94
|
+
maxHeight: "80%",
|
|
95
|
+
},
|
|
96
|
+
handle: {
|
|
97
|
+
width: 40,
|
|
98
|
+
height: 4,
|
|
99
|
+
borderRadius: 2,
|
|
100
|
+
alignSelf: "center",
|
|
101
|
+
marginTop: 8,
|
|
102
|
+
marginBottom: 8,
|
|
103
|
+
},
|
|
104
|
+
optionsList: {
|
|
105
|
+
maxHeight: 400,
|
|
106
|
+
paddingVertical: 8,
|
|
107
|
+
},
|
|
108
|
+
footer: {
|
|
109
|
+
paddingHorizontal: 20,
|
|
110
|
+
paddingTop: 16,
|
|
111
|
+
paddingBottom: 8,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { AtomicText, AtomicIcon } from '@umituz/react-native-design-system';
|
|
4
|
+
import type { useAppDesignTokens } from '@umituz/react-native-design-system';
|
|
5
|
+
|
|
6
|
+
interface FilterSheetHeaderProps {
|
|
7
|
+
title: string;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
tokens: ReturnType<typeof useAppDesignTokens>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FilterSheetHeader = ({ title, onClose, tokens }: FilterSheetHeaderProps) => (
|
|
13
|
+
<View style={[styles.header, { borderBottomColor: tokens.colors.border, borderBottomWidth: tokens.borders.width.thin }]}>
|
|
14
|
+
<AtomicText type="headlineMedium" style={styles.title}>{title}</AtomicText>
|
|
15
|
+
<TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
16
|
+
<AtomicIcon name="close" size="md" color="primary" />
|
|
17
|
+
</TouchableOpacity>
|
|
18
|
+
</View>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
header: {
|
|
23
|
+
flexDirection: 'row',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
justifyContent: 'space-between',
|
|
26
|
+
paddingHorizontal: 20,
|
|
27
|
+
paddingBottom: 16,
|
|
28
|
+
paddingTop: 8,
|
|
29
|
+
},
|
|
30
|
+
title: {
|
|
31
|
+
fontWeight: '600',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { AtomicText, AtomicIcon } from '@umituz/react-native-design-system';
|
|
4
|
+
import type { useAppDesignTokens } from '@umituz/react-native-design-system';
|
|
5
|
+
import type { FilterOption } from '../../../../domain/entities/Filter';
|
|
6
|
+
|
|
7
|
+
interface FilterSheetOptionProps {
|
|
8
|
+
option: FilterOption;
|
|
9
|
+
isSelected: boolean;
|
|
10
|
+
onPress: (id: string) => void;
|
|
11
|
+
tokens: ReturnType<typeof useAppDesignTokens>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const FilterSheetOption = ({ option, isSelected, onPress, tokens }: FilterSheetOptionProps) => (
|
|
15
|
+
<TouchableOpacity
|
|
16
|
+
onPress={() => onPress(option.id)}
|
|
17
|
+
style={[
|
|
18
|
+
styles.option,
|
|
19
|
+
{ borderBottomColor: tokens.colors.borderLight, borderBottomWidth: tokens.borders.width.thin },
|
|
20
|
+
isSelected && { backgroundColor: tokens.colors.primary + '15' }
|
|
21
|
+
]}
|
|
22
|
+
>
|
|
23
|
+
<View style={styles.optionContent}>
|
|
24
|
+
{option.icon && (
|
|
25
|
+
<AtomicIcon
|
|
26
|
+
name={option.icon as any}
|
|
27
|
+
size="md"
|
|
28
|
+
color={isSelected ? 'primary' : 'secondary'}
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
<AtomicText
|
|
32
|
+
type="bodyLarge"
|
|
33
|
+
style={[styles.optionLabel, isSelected && { color: tokens.colors.primary, fontWeight: '600' }]}
|
|
34
|
+
>
|
|
35
|
+
{option.label}
|
|
36
|
+
</AtomicText>
|
|
37
|
+
</View>
|
|
38
|
+
{isSelected && <AtomicIcon name="checkmark-circle" size="md" color="primary" />}
|
|
39
|
+
</TouchableOpacity>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const styles = StyleSheet.create({
|
|
43
|
+
option: {
|
|
44
|
+
flexDirection: 'row',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
justifyContent: 'space-between',
|
|
47
|
+
paddingHorizontal: 20,
|
|
48
|
+
paddingVertical: 16,
|
|
49
|
+
},
|
|
50
|
+
optionContent: {
|
|
51
|
+
flexDirection: 'row',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
gap: 12,
|
|
54
|
+
flex: 1,
|
|
55
|
+
},
|
|
56
|
+
optionLabel: {
|
|
57
|
+
flex: 1,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListFilters Hook
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
6
|
+
import { FilterOption } from '../../domain/entities/Filter';
|
|
7
|
+
|
|
8
|
+
export interface FilterItem extends FilterOption {
|
|
9
|
+
active?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseListFiltersReturn<T> {
|
|
13
|
+
filters: FilterItem[];
|
|
14
|
+
setFilters: (filters: FilterItem[]) => void;
|
|
15
|
+
activeFilters: FilterItem[];
|
|
16
|
+
selectedIds: string[];
|
|
17
|
+
applyFilters: (items: T[]) => T[];
|
|
18
|
+
toggleFilter: (id: string) => void;
|
|
19
|
+
handleFilterPress: (id: string) => void;
|
|
20
|
+
clearFilters: () => void;
|
|
21
|
+
handleClearFilters: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseListFiltersConfig<T> {
|
|
25
|
+
options: FilterOption[];
|
|
26
|
+
defaultFilterId?: string;
|
|
27
|
+
singleSelect?: boolean;
|
|
28
|
+
filterFn?: (item: T, activeFilters: FilterItem[]) => boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Overload 1: When config object is passed
|
|
32
|
+
export function useListFilters<T>(config: UseListFiltersConfig<T>): UseListFiltersReturn<T>;
|
|
33
|
+
// Overload 2: When initial filters array and optional filterFn are passed
|
|
34
|
+
export function useListFilters<T>(initialFilters: FilterItem[], filterFn?: (item: T, activeFilters: FilterItem[]) => boolean): UseListFiltersReturn<T>;
|
|
35
|
+
// Unified implementation
|
|
36
|
+
export function useListFilters<T>(
|
|
37
|
+
configOrFilters: FilterItem[] | UseListFiltersConfig<T>,
|
|
38
|
+
filterFnParam?: (item: T, activeFilters: FilterItem[]) => boolean
|
|
39
|
+
): UseListFiltersReturn<T> {
|
|
40
|
+
const isConfig = !Array.isArray(configOrFilters);
|
|
41
|
+
|
|
42
|
+
// Normalize initial state
|
|
43
|
+
const initialFilters = isConfig
|
|
44
|
+
? (configOrFilters as UseListFiltersConfig<T>).options.map(opt => ({
|
|
45
|
+
...opt,
|
|
46
|
+
active: (configOrFilters as UseListFiltersConfig<T>).defaultFilterId
|
|
47
|
+
? opt.id === (configOrFilters as UseListFiltersConfig<T>).defaultFilterId
|
|
48
|
+
: false
|
|
49
|
+
}))
|
|
50
|
+
: (configOrFilters as FilterItem[]);
|
|
51
|
+
|
|
52
|
+
const filterFn = isConfig
|
|
53
|
+
? (configOrFilters as UseListFiltersConfig<T>).filterFn
|
|
54
|
+
: filterFnParam;
|
|
55
|
+
|
|
56
|
+
const singleSelect = isConfig
|
|
57
|
+
? (configOrFilters as UseListFiltersConfig<T>).singleSelect
|
|
58
|
+
: false;
|
|
59
|
+
|
|
60
|
+
const [filters, setFilters] = useState<FilterItem[]>(initialFilters);
|
|
61
|
+
|
|
62
|
+
const activeFilters = useMemo(() => filters.filter((f) => f.active), [filters]);
|
|
63
|
+
|
|
64
|
+
const toggleFilter = useCallback((id: string) => {
|
|
65
|
+
setFilters((current) => {
|
|
66
|
+
if (singleSelect) {
|
|
67
|
+
return current.map((f) => ({ ...f, active: f.id === id }));
|
|
68
|
+
}
|
|
69
|
+
return current.map((f) => (f.id === id ? { ...f, active: !f.active } : f));
|
|
70
|
+
});
|
|
71
|
+
}, [singleSelect]);
|
|
72
|
+
|
|
73
|
+
const clearFilters = useCallback(() => {
|
|
74
|
+
setFilters((current) => current.map((f) => ({ ...f, active: false })));
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const applyFilters = useCallback((items: T[]) => {
|
|
78
|
+
if (activeFilters.length === 0 || !filterFn) return items;
|
|
79
|
+
return items.filter((item) => filterFn(item, activeFilters));
|
|
80
|
+
}, [activeFilters, filterFn]);
|
|
81
|
+
|
|
82
|
+
const selectedIds = useMemo(() => activeFilters.map((f) => f.id), [activeFilters]);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
filters,
|
|
86
|
+
setFilters,
|
|
87
|
+
activeFilters,
|
|
88
|
+
selectedIds,
|
|
89
|
+
applyFilters,
|
|
90
|
+
toggleFilter,
|
|
91
|
+
handleFilterPress: toggleFilter,
|
|
92
|
+
clearFilters,
|
|
93
|
+
handleClearFilters: clearFilters,
|
|
94
|
+
};
|
|
95
|
+
}
|