forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 createContext,
3 useCallback,
4 useContext,
5 useLayoutEffect,
6 useMemo,
7 useState,
8} from 'react'
9import {View} from 'react-native'
10import {msg, Trans} from '@lingui/macro'
11import {useLingui} from '@lingui/react'
12
13import {useTheme} from '#/alf'
14import {atoms as a} from '#/alf'
15import {Button, ButtonIcon, ButtonText} from '#/components/Button'
16import * as Dialog from '#/components/Dialog'
17import {useInteractionState} from '#/components/hooks/useInteractionState'
18import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
19import {ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon} from '#/components/icons/Chevron'
20import {Text} from '#/components/Typography'
21import {
22 type ContentProps,
23 type IconProps,
24 type ItemIndicatorProps,
25 type ItemProps,
26 type ItemTextProps,
27 type RootProps,
28 type TriggerProps,
29 type ValueProps,
30} from './types'
31
32type ContextType = {
33 control: Dialog.DialogControlProps
34} & Pick<RootProps, 'value' | 'onValueChange' | 'disabled'>
35
36const Context = createContext<ContextType | null>(null)
37Context.displayName = 'SelectContext'
38
39const ValueTextContext = createContext<
40 [any, React.Dispatch<React.SetStateAction<any>>]
41>([undefined, () => {}])
42ValueTextContext.displayName = 'ValueTextContext'
43
44function useSelectContext() {
45 const ctx = useContext(Context)
46 if (!ctx) {
47 throw new Error('Select components must must be used within a Select.Root')
48 }
49 return ctx
50}
51
52export function Root({children, value, onValueChange, disabled}: RootProps) {
53 const control = Dialog.useDialogControl()
54 const valueTextCtx = useState<any>()
55
56 const ctx = useMemo(
57 () => ({
58 control,
59 value,
60 onValueChange,
61 disabled,
62 }),
63 [control, value, onValueChange, disabled],
64 )
65 return (
66 <Context.Provider value={ctx}>
67 <ValueTextContext.Provider value={valueTextCtx}>
68 {children}
69 </ValueTextContext.Provider>
70 </Context.Provider>
71 )
72}
73
74export function Trigger({children, label}: TriggerProps) {
75 const {control} = useSelectContext()
76 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
77 const {
78 state: pressed,
79 onIn: onPressIn,
80 onOut: onPressOut,
81 } = useInteractionState()
82
83 if (typeof children === 'function') {
84 return children({
85 isNative: true,
86 control,
87 state: {
88 hovered: false,
89 focused,
90 pressed,
91 },
92 props: {
93 onPress: control.open,
94 onFocus,
95 onBlur,
96 onPressIn,
97 onPressOut,
98 accessibilityLabel: label,
99 },
100 })
101 } else {
102 return (
103 <Button
104 label={label}
105 onPress={control.open}
106 style={[a.flex_1, a.justify_between]}
107 color="secondary"
108 size="small"
109 shape="rectangular">
110 <>{children}</>
111 </Button>
112 )
113 }
114}
115
116export function ValueText({
117 placeholder,
118 children = value => value.label,
119 style,
120}: ValueProps) {
121 const [value] = useContext(ValueTextContext)
122 const t = useTheme()
123
124 let text = value && children(value)
125 if (typeof text !== 'string') text = placeholder
126
127 return (
128 <ButtonText style={[t.atoms.text, a.font_normal, style]}>{text}</ButtonText>
129 )
130}
131
132export function Icon({}: IconProps) {
133 return <ButtonIcon icon={ChevronUpDownIcon} />
134}
135
136export function Content<T>({
137 items,
138 valueExtractor = defaultItemValueExtractor,
139 ...props
140}: ContentProps<T>) {
141 const {control, ...context} = useSelectContext()
142 const [, setValue] = useContext(ValueTextContext)
143
144 useLayoutEffect(() => {
145 const item = items.find(item => valueExtractor(item) === context.value)
146 if (item) {
147 setValue(item)
148 }
149 }, [items, context.value, valueExtractor, setValue])
150
151 return (
152 <Dialog.Outer control={control}>
153 <ContentInner
154 control={control}
155 items={items}
156 valueExtractor={valueExtractor}
157 {...props}
158 {...context}
159 />
160 </Dialog.Outer>
161 )
162}
163
164function ContentInner<T>({
165 items,
166 renderItem,
167 valueExtractor,
168 ...context
169}: ContentProps<T> & ContextType) {
170 const control = Dialog.useDialogContext()
171
172 const {_} = useLingui()
173 const [headerHeight, setHeaderHeight] = useState(50)
174
175 const render = useCallback(
176 ({item, index}: {item: T; index: number}) => {
177 return renderItem(item, index, context.value)
178 },
179 [renderItem, context.value],
180 )
181
182 const doneButton = useCallback(
183 () => (
184 <Button
185 label={_(msg`Done`)}
186 onPress={() => control.close()}
187 size="small"
188 color="primary"
189 variant="ghost"
190 style={[a.rounded_full]}>
191 <ButtonText style={[a.text_md]}>
192 <Trans>Done</Trans>
193 </ButtonText>
194 </Button>
195 ),
196 [control, _],
197 )
198
199 return (
200 <Context.Provider value={context}>
201 <Dialog.Header
202 renderRight={doneButton}
203 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
204 style={[a.absolute, a.top_0, a.left_0, a.right_0, a.z_10]}>
205 <Dialog.HeaderText>
206 <Trans>Select an option</Trans>
207 </Dialog.HeaderText>
208 </Dialog.Header>
209 <Dialog.InnerFlatList
210 headerOffset={headerHeight}
211 data={items}
212 renderItem={render}
213 keyExtractor={valueExtractor}
214 />
215 </Context.Provider>
216 )
217}
218
219function defaultItemValueExtractor(item: any) {
220 return item.value
221}
222
223const ItemContext = createContext<{
224 selected: boolean
225 hovered: boolean
226 focused: boolean
227 pressed: boolean
228}>({
229 selected: false,
230 hovered: false,
231 focused: false,
232 pressed: false,
233})
234ItemContext.displayName = 'SelectItemContext'
235
236export function useItemContext() {
237 return useContext(ItemContext)
238}
239
240export function Item({children, value, label, style}: ItemProps) {
241 const t = useTheme()
242 const control = Dialog.useDialogContext()
243 const {value: selected, onValueChange} = useSelectContext()
244
245 return (
246 <Button
247 role="listitem"
248 label={label}
249 style={[a.flex_1]}
250 onPress={() => {
251 control.close(() => {
252 onValueChange?.(value)
253 })
254 }}>
255 {({hovered, focused, pressed}) => (
256 <ItemContext.Provider
257 value={{selected: value === selected, hovered, focused, pressed}}>
258 <View
259 style={[
260 a.flex_1,
261 a.pl_md,
262 (focused || pressed) && t.atoms.bg_contrast_25,
263 a.flex_row,
264 a.align_center,
265 a.gap_sm,
266 style,
267 ]}>
268 {children}
269 </View>
270 </ItemContext.Provider>
271 )}
272 </Button>
273 )
274}
275
276export function ItemText({children}: ItemTextProps) {
277 const {selected} = useItemContext()
278 const t = useTheme()
279
280 // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
281 return (
282 <View style={[a.flex_1, a.py_md, a.border_b, t.atoms.border_contrast_low]}>
283 <Text style={[a.text_md, selected && a.font_semi_bold]}>{children}</Text>
284 </View>
285 )
286}
287
288export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) {
289 const {selected} = useItemContext()
290
291 return <View style={{width: 24}}>{selected && <Icon size="md" />}</View>
292}