@umituz/react-native-design-system 2.3.8 → 2.3.10
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 -1
- package/src/molecules/avatar/Avatar.tsx +206 -0
- package/src/molecules/avatar/Avatar.utils.ts +288 -0
- package/src/molecules/avatar/AvatarGroup.tsx +175 -0
- package/src/molecules/avatar/index.ts +10 -0
- package/src/molecules/bottom-sheet/components/BottomSheet.tsx +122 -0
- package/src/molecules/bottom-sheet/components/BottomSheetModal.tsx +124 -0
- package/src/molecules/bottom-sheet/components/SafeBottomSheetModalProvider.tsx +11 -0
- package/src/molecules/bottom-sheet/components/filter/FilterBottomSheet.tsx +168 -0
- package/src/molecules/bottom-sheet/components/filter/FilterSheet.tsx +116 -0
- package/src/molecules/bottom-sheet/components/filter/FilterSheetComponents/FilterSheetHeader.tsx +33 -0
- package/src/molecules/bottom-sheet/components/filter/FilterSheetComponents/FilterSheetOption.tsx +59 -0
- package/src/molecules/bottom-sheet/hooks/useBottomSheet.ts +16 -0
- package/src/molecules/bottom-sheet/hooks/useBottomSheetModal.ts +17 -0
- package/src/molecules/bottom-sheet/hooks/useListFilters.ts +95 -0
- package/src/molecules/bottom-sheet/index.ts +10 -0
- package/src/molecules/bottom-sheet/types/BottomSheet.ts +122 -0
- package/src/molecules/bottom-sheet/types/Filter.ts +48 -0
- package/src/molecules/index.ts +2 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useMemo, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import GorhomBottomSheet, {
|
|
4
|
+
BottomSheetView,
|
|
5
|
+
BottomSheetBackdrop,
|
|
6
|
+
type BottomSheetBackdropProps,
|
|
7
|
+
} from '@gorhom/bottom-sheet';
|
|
8
|
+
import { useAppDesignTokens } from '../../../theme';
|
|
9
|
+
import type {
|
|
10
|
+
BottomSheetConfig,
|
|
11
|
+
BottomSheetRef,
|
|
12
|
+
BottomSheetProps,
|
|
13
|
+
} from '../types/BottomSheet';
|
|
14
|
+
import { BottomSheetUtils } from '../types/BottomSheet';
|
|
15
|
+
|
|
16
|
+
export const BottomSheet = forwardRef<BottomSheetRef, BottomSheetProps>((props, ref) => {
|
|
17
|
+
const {
|
|
18
|
+
children,
|
|
19
|
+
preset = 'medium',
|
|
20
|
+
snapPoints: customSnapPoints,
|
|
21
|
+
initialIndex,
|
|
22
|
+
enableBackdrop = true,
|
|
23
|
+
backdropAppearsOnIndex,
|
|
24
|
+
backdropDisappearsOnIndex,
|
|
25
|
+
keyboardBehavior = 'interactive',
|
|
26
|
+
enableHandleIndicator = true,
|
|
27
|
+
enablePanDownToClose = true,
|
|
28
|
+
enableDynamicSizing = false,
|
|
29
|
+
onChange,
|
|
30
|
+
onClose,
|
|
31
|
+
} = props;
|
|
32
|
+
|
|
33
|
+
const tokens = useAppDesignTokens();
|
|
34
|
+
const sheetRef = useRef<GorhomBottomSheet>(null);
|
|
35
|
+
|
|
36
|
+
const config: BottomSheetConfig = useMemo(() => {
|
|
37
|
+
if (customSnapPoints) {
|
|
38
|
+
return BottomSheetUtils.createConfig({
|
|
39
|
+
snapPoints: customSnapPoints,
|
|
40
|
+
initialIndex,
|
|
41
|
+
enableBackdrop,
|
|
42
|
+
backdropAppearsOnIndex,
|
|
43
|
+
backdropDisappearsOnIndex,
|
|
44
|
+
keyboardBehavior,
|
|
45
|
+
enableHandleIndicator,
|
|
46
|
+
enablePanDownToClose,
|
|
47
|
+
enableDynamicSizing,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return BottomSheetUtils.getPreset(preset);
|
|
51
|
+
}, [preset, customSnapPoints, initialIndex, enableBackdrop, backdropAppearsOnIndex, backdropDisappearsOnIndex, keyboardBehavior, enableHandleIndicator, enablePanDownToClose, enableDynamicSizing]);
|
|
52
|
+
|
|
53
|
+
const renderBackdrop = useCallback(
|
|
54
|
+
(backdropProps: BottomSheetBackdropProps) =>
|
|
55
|
+
enableBackdrop ? (
|
|
56
|
+
<BottomSheetBackdrop
|
|
57
|
+
{...backdropProps}
|
|
58
|
+
appearsOnIndex={config.backdropAppearsOnIndex ?? 0}
|
|
59
|
+
disappearsOnIndex={config.backdropDisappearsOnIndex ?? -1}
|
|
60
|
+
opacity={0.5}
|
|
61
|
+
pressBehavior="close"
|
|
62
|
+
/>
|
|
63
|
+
) : null,
|
|
64
|
+
[enableBackdrop, config.backdropAppearsOnIndex, config.backdropDisappearsOnIndex]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const handleSheetChange = useCallback(
|
|
68
|
+
(index: number) => {
|
|
69
|
+
onChange?.(index);
|
|
70
|
+
if (index === -1) onClose?.();
|
|
71
|
+
},
|
|
72
|
+
[onChange, onClose]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
useImperativeHandle(ref, () => ({
|
|
76
|
+
snapToIndex: (index: number) => sheetRef.current?.snapToIndex(index),
|
|
77
|
+
snapToPosition: (pos: string | number) => sheetRef.current?.snapToPosition(pos),
|
|
78
|
+
expand: () => sheetRef.current?.expand(),
|
|
79
|
+
collapse: () => sheetRef.current?.collapse(),
|
|
80
|
+
close: () => sheetRef.current?.close(),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
if (!config.snapPoints || config.snapPoints.length === 0) return null;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<GorhomBottomSheet
|
|
87
|
+
ref={sheetRef}
|
|
88
|
+
index={-1}
|
|
89
|
+
snapPoints={config.snapPoints}
|
|
90
|
+
enableDynamicSizing={config.enableDynamicSizing}
|
|
91
|
+
backdropComponent={renderBackdrop}
|
|
92
|
+
keyboardBehavior={config.keyboardBehavior}
|
|
93
|
+
enableHandlePanningGesture={config.enableHandleIndicator}
|
|
94
|
+
enablePanDownToClose={config.enablePanDownToClose}
|
|
95
|
+
onChange={handleSheetChange}
|
|
96
|
+
backgroundStyle={[styles.background, { backgroundColor: tokens.colors.surface }]}
|
|
97
|
+
handleIndicatorStyle={[styles.handleIndicator, { backgroundColor: tokens.colors.border }]}
|
|
98
|
+
>
|
|
99
|
+
<BottomSheetView style={styles.contentContainer}>
|
|
100
|
+
{children}
|
|
101
|
+
</BottomSheetView>
|
|
102
|
+
</GorhomBottomSheet>
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
BottomSheet.displayName = 'BottomSheet';
|
|
107
|
+
|
|
108
|
+
const styles = StyleSheet.create({
|
|
109
|
+
background: {
|
|
110
|
+
borderTopLeftRadius: 16,
|
|
111
|
+
borderTopRightRadius: 16,
|
|
112
|
+
},
|
|
113
|
+
handleIndicator: {
|
|
114
|
+
width: 40,
|
|
115
|
+
height: 4,
|
|
116
|
+
borderRadius: 2,
|
|
117
|
+
},
|
|
118
|
+
contentContainer: {
|
|
119
|
+
flex: 1,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useMemo, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
BottomSheetModal as GorhomBottomSheetModal,
|
|
5
|
+
BottomSheetView,
|
|
6
|
+
BottomSheetBackdrop,
|
|
7
|
+
type BottomSheetBackdropProps,
|
|
8
|
+
} from '@gorhom/bottom-sheet';
|
|
9
|
+
import { useAppDesignTokens } from '../../../theme';
|
|
10
|
+
import type {
|
|
11
|
+
BottomSheetConfig,
|
|
12
|
+
BottomSheetModalRef,
|
|
13
|
+
BottomSheetModalProps,
|
|
14
|
+
} from '../types/BottomSheet';
|
|
15
|
+
import { BottomSheetUtils } from '../types/BottomSheet';
|
|
16
|
+
|
|
17
|
+
export const BottomSheetModal = forwardRef<BottomSheetModalRef, BottomSheetModalProps>((props, ref) => {
|
|
18
|
+
const {
|
|
19
|
+
children,
|
|
20
|
+
preset = 'medium',
|
|
21
|
+
snapPoints: customSnapPoints,
|
|
22
|
+
initialIndex,
|
|
23
|
+
enableBackdrop = true,
|
|
24
|
+
backdropAppearsOnIndex,
|
|
25
|
+
backdropDisappearsOnIndex,
|
|
26
|
+
keyboardBehavior = 'interactive',
|
|
27
|
+
enableHandleIndicator = true,
|
|
28
|
+
enablePanDownToClose = true,
|
|
29
|
+
enableDynamicSizing = false,
|
|
30
|
+
onChange,
|
|
31
|
+
onDismiss,
|
|
32
|
+
backgroundColor,
|
|
33
|
+
} = props;
|
|
34
|
+
|
|
35
|
+
const tokens = useAppDesignTokens();
|
|
36
|
+
const modalRef = useRef<GorhomBottomSheetModal>(null);
|
|
37
|
+
|
|
38
|
+
const config: BottomSheetConfig = useMemo(() => {
|
|
39
|
+
if (customSnapPoints) {
|
|
40
|
+
return BottomSheetUtils.createConfig({
|
|
41
|
+
snapPoints: customSnapPoints,
|
|
42
|
+
initialIndex,
|
|
43
|
+
enableBackdrop,
|
|
44
|
+
backdropAppearsOnIndex,
|
|
45
|
+
backdropDisappearsOnIndex,
|
|
46
|
+
keyboardBehavior,
|
|
47
|
+
enableHandleIndicator,
|
|
48
|
+
enablePanDownToClose,
|
|
49
|
+
enableDynamicSizing,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return BottomSheetUtils.getPreset(preset);
|
|
53
|
+
}, [preset, customSnapPoints, initialIndex, enableBackdrop, backdropAppearsOnIndex, backdropDisappearsOnIndex, keyboardBehavior, enableHandleIndicator, enablePanDownToClose, enableDynamicSizing]);
|
|
54
|
+
|
|
55
|
+
const renderBackdrop = useCallback(
|
|
56
|
+
(backdropProps: BottomSheetBackdropProps) =>
|
|
57
|
+
enableBackdrop ? (
|
|
58
|
+
<BottomSheetBackdrop
|
|
59
|
+
{...backdropProps}
|
|
60
|
+
appearsOnIndex={config.backdropAppearsOnIndex ?? 0}
|
|
61
|
+
disappearsOnIndex={config.backdropDisappearsOnIndex ?? -1}
|
|
62
|
+
opacity={0.5}
|
|
63
|
+
pressBehavior="close"
|
|
64
|
+
/>
|
|
65
|
+
) : null,
|
|
66
|
+
[enableBackdrop, config.backdropAppearsOnIndex, config.backdropDisappearsOnIndex]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const handleSheetChange = useCallback(
|
|
70
|
+
(index: number) => {
|
|
71
|
+
onChange?.(index);
|
|
72
|
+
if (index === -1) onDismiss?.();
|
|
73
|
+
},
|
|
74
|
+
[onChange, onDismiss]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
useImperativeHandle(ref, () => ({
|
|
78
|
+
present: () => modalRef.current?.present(),
|
|
79
|
+
dismiss: () => modalRef.current?.dismiss(),
|
|
80
|
+
snapToIndex: (index: number) => modalRef.current?.snapToIndex(index),
|
|
81
|
+
snapToPosition: (pos: string | number) => modalRef.current?.snapToPosition(pos),
|
|
82
|
+
expand: () => modalRef.current?.expand(),
|
|
83
|
+
collapse: () => modalRef.current?.collapse(),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<GorhomBottomSheetModal
|
|
88
|
+
ref={modalRef}
|
|
89
|
+
index={-1}
|
|
90
|
+
snapPoints={config.snapPoints}
|
|
91
|
+
enableDynamicSizing={config.enableDynamicSizing}
|
|
92
|
+
backdropComponent={renderBackdrop}
|
|
93
|
+
keyboardBehavior={config.keyboardBehavior}
|
|
94
|
+
enableHandlePanningGesture={config.enableHandleIndicator}
|
|
95
|
+
enablePanDownToClose={config.enablePanDownToClose}
|
|
96
|
+
onChange={handleSheetChange}
|
|
97
|
+
onDismiss={onDismiss}
|
|
98
|
+
backgroundStyle={[styles.background, { backgroundColor: backgroundColor || tokens.colors.surface }]}
|
|
99
|
+
handleIndicatorStyle={[styles.handleIndicator, { backgroundColor: tokens.colors.border }]}
|
|
100
|
+
>
|
|
101
|
+
<BottomSheetView style={styles.contentContainer}>
|
|
102
|
+
{children}
|
|
103
|
+
</BottomSheetView>
|
|
104
|
+
</GorhomBottomSheetModal>
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
BottomSheetModal.displayName = 'BottomSheetModal';
|
|
109
|
+
|
|
110
|
+
const styles = StyleSheet.create({
|
|
111
|
+
background: {
|
|
112
|
+
borderTopLeftRadius: 16,
|
|
113
|
+
borderTopRightRadius: 16,
|
|
114
|
+
},
|
|
115
|
+
handleIndicator: {
|
|
116
|
+
width: 40,
|
|
117
|
+
height: 4,
|
|
118
|
+
borderRadius: 2,
|
|
119
|
+
},
|
|
120
|
+
contentContainer: {
|
|
121
|
+
flex: 1,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react';
|
|
2
|
+
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
|
|
3
|
+
|
|
4
|
+
interface SafeBottomSheetModalProviderProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const SafeBottomSheetModalProvider = ({ children }: SafeBottomSheetModalProviderProps) => (
|
|
9
|
+
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
|
10
|
+
);
|
|
11
|
+
|
|
@@ -0,0 +1,168 @@
|
|
|
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 '../../types/BottomSheet';
|
|
11
|
+
import { AtomicText, AtomicIcon, AtomicButton } from '../../../atoms';
|
|
12
|
+
import { useAppDesignTokens } from '../../../../theme';
|
|
13
|
+
import type { FilterOption, FilterCategory } from '../../types/Filter';
|
|
14
|
+
import { FilterUtils } from '../../types/Filter';
|
|
15
|
+
|
|
16
|
+
export interface FilterBottomSheetProps {
|
|
17
|
+
readonly categories: FilterCategory[];
|
|
18
|
+
readonly selectedIds: string[];
|
|
19
|
+
readonly onFilterPress: (id: string, categoryId: string) => void;
|
|
20
|
+
readonly onClearFilters: () => void;
|
|
21
|
+
readonly onDismiss?: () => void;
|
|
22
|
+
readonly title?: string;
|
|
23
|
+
readonly clearLabel?: string;
|
|
24
|
+
readonly applyLabel?: string;
|
|
25
|
+
readonly defaultId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const FilterBottomSheet = forwardRef<BottomSheetModalRef, FilterBottomSheetProps>(({
|
|
29
|
+
categories,
|
|
30
|
+
selectedIds,
|
|
31
|
+
onFilterPress,
|
|
32
|
+
onClearFilters,
|
|
33
|
+
onDismiss,
|
|
34
|
+
title,
|
|
35
|
+
clearLabel = 'Clear',
|
|
36
|
+
applyLabel = 'Apply',
|
|
37
|
+
defaultId = 'all'
|
|
38
|
+
}, ref) => {
|
|
39
|
+
const tokens = useAppDesignTokens();
|
|
40
|
+
|
|
41
|
+
const styles = React.useMemo(() => StyleSheet.create({
|
|
42
|
+
container: {
|
|
43
|
+
flex: 1,
|
|
44
|
+
padding: 16,
|
|
45
|
+
},
|
|
46
|
+
header: {
|
|
47
|
+
flexDirection: 'row',
|
|
48
|
+
justifyContent: 'space-between',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
marginBottom: 20,
|
|
51
|
+
},
|
|
52
|
+
category: {
|
|
53
|
+
marginBottom: 24,
|
|
54
|
+
},
|
|
55
|
+
categoryTitle: {
|
|
56
|
+
marginBottom: 12,
|
|
57
|
+
opacity: 0.7,
|
|
58
|
+
},
|
|
59
|
+
optionsGrid: {
|
|
60
|
+
flexDirection: 'row',
|
|
61
|
+
flexWrap: 'wrap',
|
|
62
|
+
gap: 8,
|
|
63
|
+
},
|
|
64
|
+
option: {
|
|
65
|
+
flexDirection: 'row',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
paddingHorizontal: 12,
|
|
68
|
+
paddingVertical: 8,
|
|
69
|
+
borderRadius: 20,
|
|
70
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
71
|
+
gap: 6,
|
|
72
|
+
borderWidth: 1,
|
|
73
|
+
borderColor: 'transparent',
|
|
74
|
+
},
|
|
75
|
+
optionLeft: {
|
|
76
|
+
flexDirection: 'row',
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
gap: 6,
|
|
79
|
+
},
|
|
80
|
+
footer: {
|
|
81
|
+
marginTop: 16,
|
|
82
|
+
paddingBottom: 8,
|
|
83
|
+
}
|
|
84
|
+
}), [tokens]);
|
|
85
|
+
|
|
86
|
+
const renderOption = useCallback((option: FilterOption, categoryId: string) => {
|
|
87
|
+
const isSelected = selectedIds.includes(option.id);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<TouchableOpacity
|
|
91
|
+
key={option.id}
|
|
92
|
+
style={[
|
|
93
|
+
styles.option,
|
|
94
|
+
isSelected && { backgroundColor: tokens.colors.primary + '15' }
|
|
95
|
+
]}
|
|
96
|
+
onPress={() => onFilterPress(option.id, categoryId)}
|
|
97
|
+
>
|
|
98
|
+
<View style={styles.optionLeft}>
|
|
99
|
+
{option.icon && (
|
|
100
|
+
<AtomicIcon
|
|
101
|
+
name={option.icon as any}
|
|
102
|
+
size="sm"
|
|
103
|
+
color={isSelected ? 'primary' : 'secondary'}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
<AtomicText
|
|
107
|
+
type="bodyMedium"
|
|
108
|
+
style={[isSelected && { color: tokens.colors.primary, fontWeight: '600' }]}
|
|
109
|
+
>
|
|
110
|
+
{option.label}
|
|
111
|
+
</AtomicText>
|
|
112
|
+
</View>
|
|
113
|
+
{isSelected && (
|
|
114
|
+
<AtomicIcon name="checkmark" size="sm" color="primary" />
|
|
115
|
+
)}
|
|
116
|
+
</TouchableOpacity>
|
|
117
|
+
);
|
|
118
|
+
}, [selectedIds, tokens, onFilterPress]);
|
|
119
|
+
|
|
120
|
+
const renderCategory = useCallback((category: FilterCategory) => (
|
|
121
|
+
<View key={category.id} style={styles.category}>
|
|
122
|
+
<AtomicText type="labelLarge" style={styles.categoryTitle}>
|
|
123
|
+
{category.title}
|
|
124
|
+
</AtomicText>
|
|
125
|
+
<View style={styles.optionsGrid}>
|
|
126
|
+
{category.options.map(option => renderOption(option, category.id))}
|
|
127
|
+
</View>
|
|
128
|
+
</View>
|
|
129
|
+
), [renderOption, styles]);
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
const hasActiveFilters = FilterUtils.hasActiveFilter(selectedIds, defaultId);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<BottomSheetModal
|
|
136
|
+
ref={ref}
|
|
137
|
+
preset="medium"
|
|
138
|
+
onDismiss={onDismiss}
|
|
139
|
+
backgroundColor={tokens.colors.surface}
|
|
140
|
+
>
|
|
141
|
+
<View style={styles.container}>
|
|
142
|
+
<View style={styles.header}>
|
|
143
|
+
<AtomicText type="headlineSmall">{title || 'Filter'}</AtomicText>
|
|
144
|
+
{hasActiveFilters && (
|
|
145
|
+
<TouchableOpacity onPress={onClearFilters}>
|
|
146
|
+
<AtomicText type="labelLarge" color="error">{clearLabel}</AtomicText>
|
|
147
|
+
</TouchableOpacity>
|
|
148
|
+
)}
|
|
149
|
+
</View>
|
|
150
|
+
|
|
151
|
+
<ScrollView showsVerticalScrollIndicator={false}>
|
|
152
|
+
{categories.map(renderCategory)}
|
|
153
|
+
</ScrollView>
|
|
154
|
+
|
|
155
|
+
<View style={styles.footer}>
|
|
156
|
+
<AtomicButton
|
|
157
|
+
onPress={() => (ref as any).current?.dismiss()}
|
|
158
|
+
fullWidth
|
|
159
|
+
>
|
|
160
|
+
{applyLabel}
|
|
161
|
+
</AtomicButton>
|
|
162
|
+
</View>
|
|
163
|
+
</View>
|
|
164
|
+
</BottomSheetModal>
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
FilterBottomSheet.displayName = 'FilterBottomSheet';
|
|
@@ -0,0 +1,116 @@
|
|
|
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 } from "../../../atoms";
|
|
5
|
+
import { useAppDesignTokens } from "../../../../theme";
|
|
6
|
+
import type { FilterOption } from "../../types/Filter";
|
|
7
|
+
import { FilterUtils } from "../../types/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
|
+
clearLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const FilterSheet: React.FC<FilterSheetProps> = ({
|
|
24
|
+
visible,
|
|
25
|
+
options,
|
|
26
|
+
selectedIds,
|
|
27
|
+
onFilterPress,
|
|
28
|
+
onClearFilters,
|
|
29
|
+
onClose,
|
|
30
|
+
defaultFilterId = "all",
|
|
31
|
+
title,
|
|
32
|
+
clearLabel = "Clear"
|
|
33
|
+
}) => {
|
|
34
|
+
const tokens = useAppDesignTokens();
|
|
35
|
+
const insets = useSafeAreaInsets();
|
|
36
|
+
|
|
37
|
+
const hasActiveFilter = FilterUtils.hasActiveFilter(selectedIds, defaultFilterId);
|
|
38
|
+
|
|
39
|
+
const handleFilterPressWithClose = useCallback((id: string) => {
|
|
40
|
+
onFilterPress(id);
|
|
41
|
+
onClose?.();
|
|
42
|
+
}, [onFilterPress, onClose]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
|
46
|
+
<Pressable style={styles.backdrop} onPress={onClose}>
|
|
47
|
+
<Pressable
|
|
48
|
+
style={[styles.sheet, { backgroundColor: tokens.colors.surface, paddingBottom: insets.bottom }]}
|
|
49
|
+
onPress={(e) => e.stopPropagation()}
|
|
50
|
+
>
|
|
51
|
+
<View style={[styles.handle, { backgroundColor: tokens.colors.border }]} />
|
|
52
|
+
|
|
53
|
+
<FilterSheetHeader
|
|
54
|
+
title={title || "Filter"}
|
|
55
|
+
onClose={() => onClose?.()}
|
|
56
|
+
tokens={tokens}
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<ScrollView style={styles.optionsList} showsVerticalScrollIndicator={false}>
|
|
60
|
+
{options.map((option) => (
|
|
61
|
+
<FilterSheetOption
|
|
62
|
+
key={option.id}
|
|
63
|
+
option={option}
|
|
64
|
+
isSelected={selectedIds.includes(option.id)}
|
|
65
|
+
onPress={handleFilterPressWithClose}
|
|
66
|
+
tokens={tokens}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
</ScrollView>
|
|
70
|
+
|
|
71
|
+
{hasActiveFilter && (
|
|
72
|
+
<View style={[styles.footer, { borderTopColor: tokens.colors.border, borderTopWidth: tokens.borders.width.thin }]}>
|
|
73
|
+
<AtomicButton variant="outline" onPress={onClearFilters} fullWidth>
|
|
74
|
+
{clearLabel}
|
|
75
|
+
</AtomicButton>
|
|
76
|
+
</View>
|
|
77
|
+
)}
|
|
78
|
+
</Pressable>
|
|
79
|
+
</Pressable>
|
|
80
|
+
</Modal>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
FilterSheet.displayName = "FilterSheet";
|
|
85
|
+
|
|
86
|
+
const styles = StyleSheet.create({
|
|
87
|
+
backdrop: {
|
|
88
|
+
flex: 1,
|
|
89
|
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
90
|
+
justifyContent: "flex-end",
|
|
91
|
+
},
|
|
92
|
+
sheet: {
|
|
93
|
+
borderTopLeftRadius: 16,
|
|
94
|
+
borderTopRightRadius: 16,
|
|
95
|
+
maxHeight: "80%",
|
|
96
|
+
},
|
|
97
|
+
handle: {
|
|
98
|
+
width: 40,
|
|
99
|
+
height: 4,
|
|
100
|
+
borderRadius: 2,
|
|
101
|
+
alignSelf: "center",
|
|
102
|
+
marginTop: 8,
|
|
103
|
+
marginBottom: 8,
|
|
104
|
+
},
|
|
105
|
+
optionsList: {
|
|
106
|
+
maxHeight: 400,
|
|
107
|
+
paddingVertical: 8,
|
|
108
|
+
},
|
|
109
|
+
footer: {
|
|
110
|
+
paddingHorizontal: 20,
|
|
111
|
+
paddingTop: 16,
|
|
112
|
+
paddingBottom: 8,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
|
package/src/molecules/bottom-sheet/components/filter/FilterSheetComponents/FilterSheetHeader.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { AtomicText, AtomicIcon } from '../../../../../atoms';
|
|
4
|
+
import type { useAppDesignTokens } from '../../../../../theme';
|
|
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
|
+
});
|
package/src/molecules/bottom-sheet/components/filter/FilterSheetComponents/FilterSheetOption.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { AtomicText, AtomicIcon } from '../../../../../atoms';
|
|
4
|
+
import type { useAppDesignTokens } from '../../../../../theme';
|
|
5
|
+
import type { FilterOption } from '../../../types/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,16 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react';
|
|
2
|
+
import type { BottomSheetRef, UseBottomSheetReturn } from '../types/BottomSheet';
|
|
3
|
+
|
|
4
|
+
export const useBottomSheet = (): UseBottomSheetReturn => {
|
|
5
|
+
const sheetRef = useRef<BottomSheetRef>(null);
|
|
6
|
+
|
|
7
|
+
const open = useCallback(() => sheetRef.current?.snapToIndex(0), []);
|
|
8
|
+
const close = useCallback(() => sheetRef.current?.close(), []);
|
|
9
|
+
const expand = useCallback(() => sheetRef.current?.expand(), []);
|
|
10
|
+
const collapse = useCallback(() => sheetRef.current?.collapse(), []);
|
|
11
|
+
const snapToIndex = useCallback((index: number) => sheetRef.current?.snapToIndex(index), []);
|
|
12
|
+
const snapToPosition = useCallback((pos: string | number) => sheetRef.current?.snapToPosition(pos), []);
|
|
13
|
+
|
|
14
|
+
return { sheetRef, open, close, expand, collapse, snapToIndex, snapToPosition };
|
|
15
|
+
};
|
|
16
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react';
|
|
2
|
+
import type { BottomSheetModalRef, UseBottomSheetModalReturn } from '../types/BottomSheet';
|
|
3
|
+
|
|
4
|
+
export const useBottomSheetModal = (): UseBottomSheetModalReturn => {
|
|
5
|
+
const modalRef = useRef<BottomSheetModalRef>(null);
|
|
6
|
+
|
|
7
|
+
const present = useCallback(() => modalRef.current?.present(), []);
|
|
8
|
+
const dismiss = useCallback(() => modalRef.current?.dismiss(), []);
|
|
9
|
+
const expand = useCallback(() => modalRef.current?.expand(), []);
|
|
10
|
+
const collapse = useCallback(() => modalRef.current?.collapse(), []);
|
|
11
|
+
const snapToIndex = useCallback((index: number) => modalRef.current?.snapToIndex(index), []);
|
|
12
|
+
const snapToPosition = useCallback((pos: string | number) => modalRef.current?.snapToPosition(pos), []);
|
|
13
|
+
|
|
14
|
+
return { modalRef, present, dismiss, expand, collapse, snapToIndex, snapToPosition };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
|