@codeleap/mobile 4.0.1 → 4.1.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 +10 -7
- package/src/components/DatePickerModal/index.tsx +3 -2
- package/src/components/FileInput/index.tsx +7 -7
- package/src/components/FileInput/types.ts +1 -1
- package/src/components/NumberIncrement/index.tsx +21 -3
- package/src/components/NumberIncrement/types.ts +1 -0
- package/src/components/Pager/PagerDots.tsx +56 -0
- package/src/components/Pager/index.tsx +85 -173
- package/src/components/Pager/styles.ts +6 -1
- package/src/components/Pager/types.ts +16 -26
- package/src/components/PlacesAutocomplete/index.tsx +152 -0
- package/src/components/PlacesAutocomplete/styles.ts +12 -0
- package/src/components/PlacesAutocomplete/types.ts +42 -0
- package/src/components/Select/index.tsx +9 -13
- package/src/components/SortablePhotos/index.tsx +170 -0
- package/src/components/SortablePhotos/styles.ts +9 -0
- package/src/components/SortablePhotos/types.ts +58 -0
- package/src/components/SortablePhotos/useSortablePhotos.ts +174 -0
- package/src/components/Text/index.tsx +1 -1
- package/src/components/Text/types.ts +3 -2
- package/src/components/TextInput/index.tsx +8 -0
- package/src/components/TextInput/types.ts +2 -0
- package/src/components/components.ts +2 -0
- package/src/utils/misc.ts +41 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, { useCallback } from 'react'
|
|
2
|
+
import { AnyRecord, AppIcon, IJSX, StyledComponentProps, useCompositionStyles } from '@codeleap/styles'
|
|
3
|
+
import { useStylesFor } from '../../hooks'
|
|
4
|
+
import { Text } from '../Text'
|
|
5
|
+
import { View } from '../View'
|
|
6
|
+
import { PlaceItem, PlacesAutocompleteProps } from './types'
|
|
7
|
+
import { MobileStyleRegistry } from '../../Registry'
|
|
8
|
+
import { TextInput } from '../TextInput'
|
|
9
|
+
import { List } from '../List'
|
|
10
|
+
import { Touchable } from '../Touchable'
|
|
11
|
+
import { EmptyPlaceholder } from '../EmptyPlaceholder'
|
|
12
|
+
import { ActivityIndicator } from '../ActivityIndicator'
|
|
13
|
+
import { usePlacesAutocompleteUtils } from '@codeleap/common'
|
|
14
|
+
|
|
15
|
+
export * from './styles'
|
|
16
|
+
export * from './types'
|
|
17
|
+
|
|
18
|
+
const DefaultPlaceRow: PlacesAutocompleteProps['renderPlaceRow'] = (props) => {
|
|
19
|
+
const { item, onPress, styles } = props
|
|
20
|
+
|
|
21
|
+
if (item?.content) {
|
|
22
|
+
return item?.content
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isLatLng = !!item?.formatted_address
|
|
26
|
+
|
|
27
|
+
const mainTitle = isLatLng ? item?.formatted_address : item?.description
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Touchable onPress={() => onPress(mainTitle, item)} debugName={`PlaceRow ${item?.place_id}`} style={styles.placeRowWrapper}>
|
|
31
|
+
<Text text={`${mainTitle}`} style={styles.placeRowText} />
|
|
32
|
+
</Touchable>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const PlacesAutocomplete = (props: PlacesAutocompleteProps) => {
|
|
37
|
+
const {
|
|
38
|
+
style,
|
|
39
|
+
itemRow,
|
|
40
|
+
data = [],
|
|
41
|
+
customData = [],
|
|
42
|
+
onPress,
|
|
43
|
+
onValueChange,
|
|
44
|
+
showClearIcon,
|
|
45
|
+
showEmptyPlaceholder,
|
|
46
|
+
clearIcon,
|
|
47
|
+
textInputProps,
|
|
48
|
+
listProps,
|
|
49
|
+
emptyPlaceholderProps,
|
|
50
|
+
placeRow = null,
|
|
51
|
+
renderPlaceRow: PlaceRow,
|
|
52
|
+
activityIndicatorProps,
|
|
53
|
+
debounce,
|
|
54
|
+
isLoading,
|
|
55
|
+
persistResultsOnBlur,
|
|
56
|
+
...rest
|
|
57
|
+
} = props
|
|
58
|
+
|
|
59
|
+
const [isFocused, setIsFocused] = React.useState(false)
|
|
60
|
+
|
|
61
|
+
const styles = useStylesFor(PlacesAutocomplete.styleRegistryName, style)
|
|
62
|
+
const compositionStyles = useCompositionStyles(['input', 'list', 'loader'], styles)
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
handleChangeAddress,
|
|
66
|
+
handlePressAddress,
|
|
67
|
+
handleClearAddress,
|
|
68
|
+
address,
|
|
69
|
+
isTyping,
|
|
70
|
+
setIsTyping,
|
|
71
|
+
} = usePlacesAutocompleteUtils<PlaceItem>({
|
|
72
|
+
onValueChange,
|
|
73
|
+
onPress,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const _showEmptyPlaceholder = !!address && !isTyping && showEmptyPlaceholder && !isLoading
|
|
77
|
+
|
|
78
|
+
const showResults = isFocused || persistResultsOnBlur
|
|
79
|
+
|
|
80
|
+
const _showClearIcon = showClearIcon && !!address?.trim?.()
|
|
81
|
+
|
|
82
|
+
const rightIcon = _showClearIcon ? {
|
|
83
|
+
name: clearIcon,
|
|
84
|
+
onPress: handleClearAddress,
|
|
85
|
+
} : textInputProps?.rightIcon
|
|
86
|
+
|
|
87
|
+
const _data = customData?.length > 0 && address ? [...customData, ...data] : data
|
|
88
|
+
const hasCustomValue = !!textInputProps?.value
|
|
89
|
+
|
|
90
|
+
const renderItem = useCallback((props) => {
|
|
91
|
+
return (
|
|
92
|
+
placeRow ? placeRow : <PlaceRow onPress={handlePressAddress} styles={styles} {...props} />
|
|
93
|
+
)
|
|
94
|
+
}, [placeRow])
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<View style={styles.wrapper} {...rest}>
|
|
98
|
+
<TextInput
|
|
99
|
+
style={compositionStyles.input}
|
|
100
|
+
onChangeText={(value) => {
|
|
101
|
+
setIsTyping(true)
|
|
102
|
+
handleChangeAddress(value)
|
|
103
|
+
}}
|
|
104
|
+
onBlur={() => {
|
|
105
|
+
setIsFocused(false)
|
|
106
|
+
}}
|
|
107
|
+
onFocus={() => {
|
|
108
|
+
setIsFocused(true)
|
|
109
|
+
}}
|
|
110
|
+
{...textInputProps}
|
|
111
|
+
value={hasCustomValue ? textInputProps?.value : address}
|
|
112
|
+
rightIcon={rightIcon}
|
|
113
|
+
/>
|
|
114
|
+
{isTyping ? (
|
|
115
|
+
<View style={styles.loadingWrapper}>
|
|
116
|
+
<ActivityIndicator style={compositionStyles.loader} {...activityIndicatorProps} />
|
|
117
|
+
</View>
|
|
118
|
+
) : (
|
|
119
|
+
showResults ? (
|
|
120
|
+
<List
|
|
121
|
+
data={_data}
|
|
122
|
+
renderItem={renderItem}
|
|
123
|
+
ListEmptyComponent={
|
|
124
|
+
_showEmptyPlaceholder ? <EmptyPlaceholder {...emptyPlaceholderProps} /> : null
|
|
125
|
+
}
|
|
126
|
+
style={compositionStyles.list}
|
|
127
|
+
separators
|
|
128
|
+
{...listProps}
|
|
129
|
+
/>
|
|
130
|
+
) : null
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
</View>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
PlacesAutocomplete.styleRegistryName = 'PlacesAutocomplete'
|
|
138
|
+
PlacesAutocomplete.elements = ['wrapper', 'input', 'list', 'loader', 'placeRow', 'loadingWrapper']
|
|
139
|
+
PlacesAutocomplete.withVariantTypes = <S extends AnyRecord>(styles: S) => {
|
|
140
|
+
return PlacesAutocomplete as (props: StyledComponentProps<PlacesAutocompleteProps, typeof styles>) => IJSX
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
PlacesAutocomplete.defaultProps = {
|
|
144
|
+
showClearIcon: false,
|
|
145
|
+
showEmptyPlaceholder: true,
|
|
146
|
+
clearIcon: 'x' as AppIcon,
|
|
147
|
+
placeRowComponent: DefaultPlaceRow,
|
|
148
|
+
renderPlaceRow: DefaultPlaceRow,
|
|
149
|
+
debounce: 250,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
MobileStyleRegistry.registerComponent(PlacesAutocomplete)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ActivityIndicatorComposition } from '../ActivityIndicator'
|
|
2
|
+
import { ListComposition } from '../List'
|
|
3
|
+
import { TextInputComposition } from '../TextInput'
|
|
4
|
+
|
|
5
|
+
export type PlacesAutocompleteComposition =
|
|
6
|
+
`input${Capitalize<TextInputComposition>}` |
|
|
7
|
+
`list${Capitalize<ListComposition>}` |
|
|
8
|
+
`loader${Capitalize<ActivityIndicatorComposition>}` |
|
|
9
|
+
'placeRowWrapper' |
|
|
10
|
+
'placeRowText' |
|
|
11
|
+
'wrapper' |
|
|
12
|
+
'loadingWrapper'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AppIcon, ICSS, StyledProp } from '@codeleap/styles'
|
|
2
|
+
import { PlacesAutocompleteComposition } from './styles'
|
|
3
|
+
import { TextInputProps } from '../TextInput'
|
|
4
|
+
import { FlatListProps } from '../List'
|
|
5
|
+
import { EmptyPlaceholderProps } from '../EmptyPlaceholder'
|
|
6
|
+
import { ActivityIndicatorProps } from '../ActivityIndicator'
|
|
7
|
+
import { PlaceAddress, PlaceLatLng } from '@codeleap/common'
|
|
8
|
+
|
|
9
|
+
export type CustomData = {
|
|
10
|
+
item?: Partial<PlaceAddress> & Partial<PlaceLatLng>
|
|
11
|
+
content?: JSX.Element
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PlaceItem = PlaceAddress & PlaceLatLng & { content?: JSX.Element }
|
|
15
|
+
|
|
16
|
+
export type PlaceRowProps = {
|
|
17
|
+
item?: PlaceItem
|
|
18
|
+
styles?: Record<PlacesAutocompleteComposition, ICSS>
|
|
19
|
+
onPress?: PlacesAutocompleteProps['onPress']
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PlacesAutocompleteProps = {
|
|
23
|
+
style?: StyledProp<PlacesAutocompleteComposition>
|
|
24
|
+
itemRow?: (props: any) => JSX.Element
|
|
25
|
+
textInputProps?: TextInputProps
|
|
26
|
+
emptyPlaceholderProps?: EmptyPlaceholderProps
|
|
27
|
+
listProps?: FlatListProps
|
|
28
|
+
data: PlaceAddress[] | PlaceLatLng[]
|
|
29
|
+
customData?: CustomData[]
|
|
30
|
+
onPress?: (address: string, place: PlaceItem) => void
|
|
31
|
+
onValueChange?: (address: string) => void
|
|
32
|
+
showClearIcon?: boolean
|
|
33
|
+
showEmptyPlaceholder?: boolean
|
|
34
|
+
clearIcon?: AppIcon
|
|
35
|
+
placeRowComponent?: React.ComponentType<PlaceRowProps>
|
|
36
|
+
renderPlaceRow?: (props: PlaceRowProps) => React.ReactElement
|
|
37
|
+
placeRow?: React.ReactElement
|
|
38
|
+
debounce?: number
|
|
39
|
+
activityIndicatorProps?: ActivityIndicatorProps
|
|
40
|
+
persistResultsOnBlur?: boolean
|
|
41
|
+
isLoading?: boolean
|
|
42
|
+
}
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
onUpdate,
|
|
7
7
|
usePrevious,
|
|
8
8
|
useSearch,
|
|
9
|
-
|
|
9
|
+
useConditionalState,
|
|
10
10
|
} from '@codeleap/common'
|
|
11
11
|
import React, { useCallback, useMemo } from 'react'
|
|
12
12
|
import { List } from '../List'
|
|
@@ -35,10 +35,11 @@ const defaultFilterFunction = (search: string, options: FormTypes.Options<any>)
|
|
|
35
35
|
|
|
36
36
|
const defaultGetLabel = (option) => {
|
|
37
37
|
if (TypeGuards.isArray(option)) {
|
|
38
|
-
if (option
|
|
39
|
-
|
|
40
|
-
return option.map(o => o.label).join(', ')
|
|
38
|
+
if (option?.length === 0) return null
|
|
41
39
|
|
|
40
|
+
const labels = option?.map(option => option?.label)?.filter(value => !!value)
|
|
41
|
+
|
|
42
|
+
return labels?.join(', ')
|
|
42
43
|
} else {
|
|
43
44
|
if (!option) return null
|
|
44
45
|
return option?.label
|
|
@@ -140,7 +141,7 @@ export const Select = <T extends string | number = string, Multi extends boolean
|
|
|
140
141
|
onLoadOptionsError,
|
|
141
142
|
})
|
|
142
143
|
|
|
143
|
-
const [visible, toggle] =
|
|
144
|
+
const [visible, toggle] = useConditionalState(_visible, _toggle, { initialValue: false, isBooleanToggle: true })
|
|
144
145
|
|
|
145
146
|
const currentValueLabel = useMemo(() => {
|
|
146
147
|
const _options = (multiple ? labelOptions : labelOptions?.[0]) as Multi extends true ? FormTypes.Options<T> : FormTypes.Options<T>[number]
|
|
@@ -174,19 +175,15 @@ export const Select = <T extends string | number = string, Multi extends boolean
|
|
|
174
175
|
|
|
175
176
|
const select = useCallback((selectedValue) => {
|
|
176
177
|
let newValue = null
|
|
177
|
-
|
|
178
178
|
let newOption = null
|
|
179
179
|
let removedIndex = null
|
|
180
180
|
|
|
181
181
|
if (multiple && isValueArray) {
|
|
182
|
-
|
|
183
182
|
if (value.includes(selectedValue)) {
|
|
184
183
|
removedIndex = value.findIndex(v => v === selectedValue)
|
|
185
184
|
|
|
186
185
|
newValue = value.filter((v, i) => i !== removedIndex)
|
|
187
|
-
|
|
188
186
|
} else {
|
|
189
|
-
|
|
190
187
|
if (TypeGuards.isNumber(limit) && value.length >= limit) {
|
|
191
188
|
return
|
|
192
189
|
}
|
|
@@ -195,7 +192,6 @@ export const Select = <T extends string | number = string, Multi extends boolean
|
|
|
195
192
|
|
|
196
193
|
newValue = [...value, selectedValue]
|
|
197
194
|
}
|
|
198
|
-
|
|
199
195
|
} else {
|
|
200
196
|
newValue = selectedValue
|
|
201
197
|
newOption = currentOptions.find(o => o.value === selectedValue)
|
|
@@ -209,7 +205,8 @@ export const Select = <T extends string | number = string, Multi extends boolean
|
|
|
209
205
|
newOptions.splice(removedIndex, 1)
|
|
210
206
|
setLabelOptions(newOptions)
|
|
211
207
|
} else {
|
|
212
|
-
|
|
208
|
+
const newLabels = [...labelOptions, newOption]
|
|
209
|
+
setLabelOptions(newLabels)
|
|
213
210
|
}
|
|
214
211
|
} else {
|
|
215
212
|
setLabelOptions([newOption])
|
|
@@ -218,7 +215,7 @@ export const Select = <T extends string | number = string, Multi extends boolean
|
|
|
218
215
|
if (closeOnSelect) {
|
|
219
216
|
close?.()
|
|
220
217
|
}
|
|
221
|
-
}, [isValueArray, (isValueArray ? value : [value]), limit, multiple])
|
|
218
|
+
}, [isValueArray, (isValueArray ? value : [value]), limit, multiple, labelOptions, currentOptions])
|
|
222
219
|
|
|
223
220
|
const renderListItem = useCallback(({ item, index }) => {
|
|
224
221
|
let selected = false
|
|
@@ -335,7 +332,6 @@ export const Select = <T extends string | number = string, Multi extends boolean
|
|
|
335
332
|
</>
|
|
336
333
|
}
|
|
337
334
|
|
|
338
|
-
|
|
339
335
|
Select.styleRegistryName = 'Select'
|
|
340
336
|
Select.elements = [...Modal.elements, 'input', 'list', 'item', 'searchInput']
|
|
341
337
|
Select.rootElement = 'inputWrapper'
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { AnyRecord, IJSX, StyledComponentProps, useNestedStylesByKey } from '@codeleap/styles'
|
|
2
|
+
import { FileInput } from '../FileInput'
|
|
3
|
+
import { Icon } from '../Icon'
|
|
4
|
+
import { Image } from '../Image'
|
|
5
|
+
import { Dimensions, View } from 'react-native'
|
|
6
|
+
import { DragSortableView } from 'react-native-drag-sort'
|
|
7
|
+
import { SortableItemProps, SortablePhoto, SortablePhotosProps } from './types'
|
|
8
|
+
import { useSortablePhotos } from './useSortablePhotos'
|
|
9
|
+
import { useStylesFor } from '../../hooks'
|
|
10
|
+
import { MobileStyleRegistry } from '../../Registry'
|
|
11
|
+
import { ActivityIndicator } from '../ActivityIndicator'
|
|
12
|
+
|
|
13
|
+
export * from './styles'
|
|
14
|
+
export * from './types'
|
|
15
|
+
|
|
16
|
+
const DefaultItem = <T extends SortablePhoto>(props: SortableItemProps<T>) => {
|
|
17
|
+
const { photo, width, height, styles, emptyIcon } = props
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<View style={[{ width, height }, styles.photoWrapper]}>
|
|
21
|
+
{
|
|
22
|
+
!!photo?.filename
|
|
23
|
+
? <Image resizeMode='cover' source={{ uri: photo?.file }} style={styles.photoImage} />
|
|
24
|
+
: <Icon name={emptyIcon} style={styles.photoEmptyIcon} />
|
|
25
|
+
}
|
|
26
|
+
</View>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const defaultGetFilename = (file: string) => {
|
|
31
|
+
if (!file) return null
|
|
32
|
+
|
|
33
|
+
const filenameWithExtension = file?.split?.('/').pop()
|
|
34
|
+
|
|
35
|
+
if (filenameWithExtension) {
|
|
36
|
+
return filenameWithExtension?.split('.').slice(0, -1).join('.')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Date().toISOString()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const screenWidth = Dimensions.get('screen').width
|
|
43
|
+
|
|
44
|
+
export const SortablePhotos = <T extends SortablePhoto>(props: SortablePhotosProps<T>) => {
|
|
45
|
+
const allProps = {
|
|
46
|
+
...SortablePhotos.defaultProps,
|
|
47
|
+
...props,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const {
|
|
51
|
+
numColumns,
|
|
52
|
+
renderPhoto: RenderItem,
|
|
53
|
+
gap,
|
|
54
|
+
multiple,
|
|
55
|
+
pickerConfig,
|
|
56
|
+
emptyIcon,
|
|
57
|
+
disableDragDropEmptyItems,
|
|
58
|
+
itemWidth: _itemWidth,
|
|
59
|
+
itemHeight: _itemHeight,
|
|
60
|
+
width: _parentWidth,
|
|
61
|
+
style,
|
|
62
|
+
loading,
|
|
63
|
+
...rest
|
|
64
|
+
} = allProps
|
|
65
|
+
|
|
66
|
+
const styles = useStylesFor(SortablePhotos.styleRegistryName, style)
|
|
67
|
+
|
|
68
|
+
const loaderStyles = useNestedStylesByKey('loader', styles)
|
|
69
|
+
|
|
70
|
+
const {
|
|
71
|
+
input,
|
|
72
|
+
handlePressPhoto,
|
|
73
|
+
numberPhotosMissing,
|
|
74
|
+
emptyIndexes,
|
|
75
|
+
onChangePhotosOrder,
|
|
76
|
+
data,
|
|
77
|
+
} = useSortablePhotos<T>(allProps)
|
|
78
|
+
|
|
79
|
+
const defaultParentWidth = screenWidth - (gap * 2)
|
|
80
|
+
const defaultItemWidth = (defaultParentWidth / numColumns) - gap
|
|
81
|
+
|
|
82
|
+
const itemWidth = _itemWidth ?? defaultItemWidth
|
|
83
|
+
const itemHeight = _itemHeight ?? itemWidth
|
|
84
|
+
const parentWidth = _parentWidth ?? defaultParentWidth
|
|
85
|
+
|
|
86
|
+
const childrenMargin = gap / 2
|
|
87
|
+
|
|
88
|
+
const fileInputPickerOptions = {
|
|
89
|
+
...SortablePhotos.defaultProps.pickerConfig,
|
|
90
|
+
...pickerConfig,
|
|
91
|
+
multiple,
|
|
92
|
+
maxFiles: numberPhotosMissing,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (loading) {
|
|
96
|
+
return (
|
|
97
|
+
<View style={[styles.wrapper, styles['wrapper:loading']]}>
|
|
98
|
+
<ActivityIndicator style={loaderStyles} />
|
|
99
|
+
</View>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View style={styles.wrapper}>
|
|
105
|
+
<FileInput
|
|
106
|
+
mode='hidden'
|
|
107
|
+
ref={input.ref}
|
|
108
|
+
pickerOptions={fileInputPickerOptions}
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<DragSortableView
|
|
112
|
+
dataSource={data}
|
|
113
|
+
childrenHeight={itemHeight}
|
|
114
|
+
childrenWidth={itemWidth}
|
|
115
|
+
parentWidth={parentWidth}
|
|
116
|
+
marginChildrenBottom={childrenMargin}
|
|
117
|
+
marginChildrenLeft={childrenMargin}
|
|
118
|
+
marginChildrenRight={childrenMargin}
|
|
119
|
+
marginChildrenTop={childrenMargin}
|
|
120
|
+
onDataChange={onChangePhotosOrder}
|
|
121
|
+
onClickItem={handlePressPhoto}
|
|
122
|
+
fixedItems={disableDragDropEmptyItems ? emptyIndexes : undefined}
|
|
123
|
+
{...rest}
|
|
124
|
+
renderItem={(item, order) => (
|
|
125
|
+
<RenderItem
|
|
126
|
+
width={itemWidth}
|
|
127
|
+
height={itemHeight}
|
|
128
|
+
photo={item}
|
|
129
|
+
order={order}
|
|
130
|
+
styles={styles}
|
|
131
|
+
emptyIcon={emptyIcon}
|
|
132
|
+
/>
|
|
133
|
+
)}
|
|
134
|
+
/>
|
|
135
|
+
</View>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
SortablePhotos.styleRegistryName = 'SortablePhotos'
|
|
140
|
+
SortablePhotos.elements = ['wrapper', 'photo', 'loader']
|
|
141
|
+
SortablePhotos.rootElement = 'wrapper'
|
|
142
|
+
|
|
143
|
+
SortablePhotos.withVariantTypes = <S extends AnyRecord>(styles: S) => {
|
|
144
|
+
return SortablePhotos as <T extends SortablePhoto>(props: StyledComponentProps<SortablePhotosProps<T>, typeof styles>) => IJSX
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
SortablePhotos.defaultProps = {
|
|
148
|
+
numPhotos: 9,
|
|
149
|
+
numColumns: 3,
|
|
150
|
+
renderPhoto: DefaultItem,
|
|
151
|
+
multiple: true,
|
|
152
|
+
disableDragDropEmptyItems: true,
|
|
153
|
+
gap: 16,
|
|
154
|
+
emptyIcon: 'plus',
|
|
155
|
+
modalTitle: 'Photos',
|
|
156
|
+
modalBody: null,
|
|
157
|
+
modalLibraryText: 'Choose from gallery',
|
|
158
|
+
modalCameraText: 'Take a photo',
|
|
159
|
+
modalDeleteText: 'Remove photo',
|
|
160
|
+
getFilename: defaultGetFilename,
|
|
161
|
+
pickerConfig: {
|
|
162
|
+
cropping: true,
|
|
163
|
+
showCropFrame: true,
|
|
164
|
+
compressImageMaxHeight: 1700,
|
|
165
|
+
compressImageMaxWidth: 1700,
|
|
166
|
+
compressImageQuality: 0.8,
|
|
167
|
+
},
|
|
168
|
+
} as Partial<SortablePhotosProps<any>>
|
|
169
|
+
|
|
170
|
+
MobileStyleRegistry.registerComponent(SortablePhotos)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AppIcon, ICSS, StyledProp } from '@codeleap/styles'
|
|
2
|
+
import { ReactElement } from 'react'
|
|
3
|
+
import { SortablePhotosComposition } from './styles'
|
|
4
|
+
|
|
5
|
+
export type SortablePhoto = {
|
|
6
|
+
filename: string | null
|
|
7
|
+
file: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SortableItemProps<T extends SortablePhoto> = {
|
|
11
|
+
photo: T
|
|
12
|
+
order: number
|
|
13
|
+
height: number
|
|
14
|
+
width: number
|
|
15
|
+
styles: Record<SortablePhotosComposition, ICSS>
|
|
16
|
+
emptyIcon: AppIcon
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SortablePhotosPickerConfig = {
|
|
20
|
+
cropping?: boolean
|
|
21
|
+
compressImageMaxHeight?: number
|
|
22
|
+
compressImageMaxWidth?: number
|
|
23
|
+
compressImageQuality?: number
|
|
24
|
+
showCropFrame?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SortablePhotosProps<T extends SortablePhoto> = {
|
|
28
|
+
numColumns?: number
|
|
29
|
+
numPhotos?: number
|
|
30
|
+
renderPhoto?: (props: SortableItemProps<T>) => ReactElement
|
|
31
|
+
photos: T[]
|
|
32
|
+
onChangePhotos: (newPhotos: T[]) => void
|
|
33
|
+
gap?: number
|
|
34
|
+
itemHeight?: number
|
|
35
|
+
itemWidth?: number
|
|
36
|
+
width?: number
|
|
37
|
+
onPressPhoto?: (data: T[], photo: T, order: number) => void
|
|
38
|
+
onDragStart?: (fromIndex: number) => void
|
|
39
|
+
onDragEnd?: (fromIndex: number, toIndex: number) => void
|
|
40
|
+
keyExtractor?: (photo: T, order: number) => any
|
|
41
|
+
delayLongPress?: number
|
|
42
|
+
pickerConfig?: SortablePhotosPickerConfig
|
|
43
|
+
multiple?: boolean
|
|
44
|
+
maxScale?: number
|
|
45
|
+
minOpacity?: number
|
|
46
|
+
scaleDuration?: number
|
|
47
|
+
slideDuration?: number
|
|
48
|
+
emptyIcon?: AppIcon
|
|
49
|
+
disableDragDropEmptyItems?: boolean
|
|
50
|
+
style?: StyledProp<SortablePhotosComposition>
|
|
51
|
+
modalTitle?: string
|
|
52
|
+
modalBody?: string
|
|
53
|
+
modalLibraryText?: string
|
|
54
|
+
modalCameraText?: string
|
|
55
|
+
modalDeleteText?: string
|
|
56
|
+
getFilename?: (file: string) => string
|
|
57
|
+
loading?: boolean
|
|
58
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { CreateOSAlert, useEffect, useGlobalContext, useMemo, useState } from '@codeleap/common'
|
|
2
|
+
import { FileInputImageSource, useFileInput } from '../FileInput'
|
|
3
|
+
import { SortablePhoto, SortablePhotosProps } from './types'
|
|
4
|
+
|
|
5
|
+
const SortableAlert = CreateOSAlert()
|
|
6
|
+
|
|
7
|
+
export const useSortablePhotos = <T extends SortablePhoto>(props: SortablePhotosProps<T>) => {
|
|
8
|
+
const {
|
|
9
|
+
onChangePhotos,
|
|
10
|
+
onPressPhoto,
|
|
11
|
+
modalBody,
|
|
12
|
+
modalTitle,
|
|
13
|
+
modalCameraText,
|
|
14
|
+
modalDeleteText,
|
|
15
|
+
modalLibraryText,
|
|
16
|
+
getFilename,
|
|
17
|
+
numPhotos,
|
|
18
|
+
loading,
|
|
19
|
+
photos: currentPhotos
|
|
20
|
+
} = props
|
|
21
|
+
|
|
22
|
+
const input = useFileInput()
|
|
23
|
+
const { logger } = useGlobalContext()
|
|
24
|
+
|
|
25
|
+
const [data, setData] = useState<T[]>([])
|
|
26
|
+
|
|
27
|
+
const onChange = (photos: T[]) => {
|
|
28
|
+
const { newPhotos, sortedPhotos } = sortPhotos(photos)
|
|
29
|
+
|
|
30
|
+
setData(sortedPhotos)
|
|
31
|
+
onChangePhotos(newPhotos)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!loading && data?.length < numPhotos) {
|
|
36
|
+
const currentLength = currentPhotos?.length ?? 0
|
|
37
|
+
const length = Math.abs(numPhotos - currentLength)
|
|
38
|
+
const fillPhotos = Array(length).fill({ filename: null, file: null }) as T[]
|
|
39
|
+
|
|
40
|
+
const newPhotos = currentPhotos.concat(fillPhotos)
|
|
41
|
+
|
|
42
|
+
setData(newPhotos)
|
|
43
|
+
onChangePhotos(currentPhotos)
|
|
44
|
+
}
|
|
45
|
+
}, [loading])
|
|
46
|
+
|
|
47
|
+
const { emptyIndexes, numberPhotosMissing } = useMemo(() => {
|
|
48
|
+
const copyPhotos = [...data]
|
|
49
|
+
|
|
50
|
+
const emptyIndexes = copyPhotos.reduce((indexes, photo, index) => {
|
|
51
|
+
if (!photo?.filename) {
|
|
52
|
+
indexes.push(index)
|
|
53
|
+
}
|
|
54
|
+
return indexes
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const numberPhotosMissing = emptyIndexes?.length
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
emptyIndexes,
|
|
61
|
+
numberPhotosMissing,
|
|
62
|
+
}
|
|
63
|
+
}, [JSON.stringify(data)])
|
|
64
|
+
|
|
65
|
+
const sortPhotos = (_unorderedPhotos: T[]) => {
|
|
66
|
+
const unorderedPhotos = [..._unorderedPhotos]
|
|
67
|
+
|
|
68
|
+
const [newPhotos, emptyPhotos] = unorderedPhotos.reduce(
|
|
69
|
+
([newPhotos, emptyPhotos], photo) => {
|
|
70
|
+
!!photo?.filename ? newPhotos.push(photo) : emptyPhotos.push(photo)
|
|
71
|
+
return [newPhotos, emptyPhotos]
|
|
72
|
+
},
|
|
73
|
+
[[], []] as [T[], T[]]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const sortedPhotos: T[] = newPhotos.concat(emptyPhotos)
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
sortedPhotos,
|
|
80
|
+
newPhotos,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handleOpenPicker = async (pickerType: FileInputImageSource, photo: T, order: number) => {
|
|
85
|
+
let files = []
|
|
86
|
+
|
|
87
|
+
const isEdit = !!photo?.filename
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
files = await input?.openFilePicker(pickerType, {
|
|
91
|
+
multiple: isEdit ? false : props?.multiple
|
|
92
|
+
})
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.error('Error opening file picker:', error)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (files?.length <= 0) return null
|
|
98
|
+
|
|
99
|
+
const isMultiple = files?.length > 1 && !isEdit
|
|
100
|
+
|
|
101
|
+
const newPhotos = [...data]
|
|
102
|
+
|
|
103
|
+
if (isMultiple) {
|
|
104
|
+
for (const fileIndex in files) {
|
|
105
|
+
const file = files?.[fileIndex]
|
|
106
|
+
const order = emptyIndexes[fileIndex]
|
|
107
|
+
const uri = file?.file?.uri
|
|
108
|
+
const filename = getFilename(uri)
|
|
109
|
+
|
|
110
|
+
newPhotos[order] = {
|
|
111
|
+
...newPhotos[order],
|
|
112
|
+
filename,
|
|
113
|
+
file: uri,
|
|
114
|
+
} as T
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
const file = files?.[0]
|
|
118
|
+
const uri = file?.file?.uri
|
|
119
|
+
const filename = getFilename(uri)
|
|
120
|
+
|
|
121
|
+
newPhotos[order] = {
|
|
122
|
+
...newPhotos[order],
|
|
123
|
+
filename,
|
|
124
|
+
file: uri,
|
|
125
|
+
} as T
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
onChange(newPhotos)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handleDeletePhoto = (photo: T, order: number) => {
|
|
132
|
+
const newPhotos = [...data]
|
|
133
|
+
|
|
134
|
+
newPhotos[order] = {
|
|
135
|
+
...newPhotos[order],
|
|
136
|
+
filename: null,
|
|
137
|
+
file: null,
|
|
138
|
+
} as T
|
|
139
|
+
|
|
140
|
+
onChange(newPhotos)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const handlePressPhoto = (currentData: T[], photo: T, order: number) => {
|
|
144
|
+
SortableAlert.custom({
|
|
145
|
+
title: modalTitle,
|
|
146
|
+
body: modalBody,
|
|
147
|
+
options: [
|
|
148
|
+
{ text: modalLibraryText, onPress: () => handleOpenPicker('library', photo, order) },
|
|
149
|
+
{ text: modalCameraText, onPress: () => handleOpenPicker('camera', photo, order) },
|
|
150
|
+
!!photo?.filename && { text: modalDeleteText, onPress: () => handleDeletePhoto(photo, order) },
|
|
151
|
+
],
|
|
152
|
+
// @ts-expect-error
|
|
153
|
+
isRow: false,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
onPressPhoto?.(currentData, photo, order)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const onChangePhotosOrder = (newData: T[]) => {
|
|
160
|
+
onChange(newData)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
input,
|
|
165
|
+
handlePressPhoto,
|
|
166
|
+
handleDeletePhoto,
|
|
167
|
+
handleOpenPicker,
|
|
168
|
+
sortPhotos,
|
|
169
|
+
numberPhotosMissing,
|
|
170
|
+
onChangePhotosOrder,
|
|
171
|
+
emptyIndexes,
|
|
172
|
+
data,
|
|
173
|
+
}
|
|
174
|
+
}
|