mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {Pressable, View, ViewStyle} from 'react-native'
3
4import {HITSLOP_10} from 'lib/constants'
5import {
6 atoms as a,
7 flatten,
8 native,
9 TextStyleProp,
10 useTheme,
11 ViewStyleProp,
12} from '#/alf'
13import {useInteractionState} from '#/components/hooks/useInteractionState'
14import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
15import {Text} from '#/components/Typography'
16
17export type ItemState = {
18 name: string
19 selected: boolean
20 disabled: boolean
21 isInvalid: boolean
22 hovered: boolean
23 pressed: boolean
24 focused: boolean
25}
26
27const ItemContext = React.createContext<ItemState>({
28 name: '',
29 selected: false,
30 disabled: false,
31 isInvalid: false,
32 hovered: false,
33 pressed: false,
34 focused: false,
35})
36
37const GroupContext = React.createContext<{
38 values: string[]
39 disabled: boolean
40 type: 'radio' | 'checkbox'
41 maxSelectionsReached: boolean
42 setFieldValue: (props: {name: string; value: boolean}) => void
43}>({
44 type: 'checkbox',
45 values: [],
46 disabled: false,
47 maxSelectionsReached: false,
48 setFieldValue: () => {},
49})
50
51export type GroupProps = React.PropsWithChildren<{
52 type?: 'radio' | 'checkbox'
53 values: string[]
54 maxSelections?: number
55 disabled?: boolean
56 onChange: (value: string[]) => void
57 label: string
58}>
59
60export type ItemProps = ViewStyleProp & {
61 type?: 'radio' | 'checkbox'
62 name: string
63 label: string
64 value?: boolean
65 disabled?: boolean
66 onChange?: (selected: boolean) => void
67 isInvalid?: boolean
68 children: ((props: ItemState) => React.ReactNode) | React.ReactNode
69}
70
71export function useItemContext() {
72 return React.useContext(ItemContext)
73}
74
75export function Group({
76 children,
77 values: providedValues,
78 onChange,
79 disabled = false,
80 type = 'checkbox',
81 maxSelections,
82 label,
83}: GroupProps) {
84 const groupRole = type === 'radio' ? 'radiogroup' : undefined
85 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
86 const [maxReached, setMaxReached] = React.useState(false)
87
88 const setFieldValue = React.useCallback<
89 (props: {name: string; value: boolean}) => void
90 >(
91 ({name, value}) => {
92 if (type === 'checkbox') {
93 const pruned = values.filter(v => v !== name)
94 const next = value ? pruned.concat(name) : pruned
95 onChange(next)
96 } else {
97 onChange([name])
98 }
99 },
100 [type, onChange, values],
101 )
102
103 React.useEffect(() => {
104 if (type === 'checkbox') {
105 if (
106 maxSelections &&
107 values.length >= maxSelections &&
108 maxReached === false
109 ) {
110 setMaxReached(true)
111 } else if (
112 maxSelections &&
113 values.length < maxSelections &&
114 maxReached === true
115 ) {
116 setMaxReached(false)
117 }
118 }
119 }, [type, values.length, maxSelections, maxReached, setMaxReached])
120
121 const context = React.useMemo(
122 () => ({
123 values,
124 type,
125 disabled,
126 maxSelectionsReached: maxReached,
127 setFieldValue,
128 }),
129 [values, disabled, type, maxReached, setFieldValue],
130 )
131
132 return (
133 <GroupContext.Provider value={context}>
134 <View
135 style={[a.w_full]}
136 role={groupRole}
137 {...(groupRole === 'radiogroup'
138 ? {
139 'aria-label': label,
140 accessibilityLabel: label,
141 accessibilityRole: groupRole,
142 }
143 : {})}>
144 {children}
145 </View>
146 </GroupContext.Provider>
147 )
148}
149
150export function Item({
151 children,
152 name,
153 value = false,
154 disabled: itemDisabled = false,
155 onChange,
156 isInvalid,
157 style,
158 type = 'checkbox',
159 label,
160 ...rest
161}: ItemProps) {
162 const {
163 values: selectedValues,
164 type: groupType,
165 disabled: groupDisabled,
166 setFieldValue,
167 maxSelectionsReached,
168 } = React.useContext(GroupContext)
169 const {
170 state: hovered,
171 onIn: onHoverIn,
172 onOut: onHoverOut,
173 } = useInteractionState()
174 const {
175 state: pressed,
176 onIn: onPressIn,
177 onOut: onPressOut,
178 } = useInteractionState()
179 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
180
181 const role = groupType === 'radio' ? 'radio' : type
182 const selected = selectedValues.includes(name) || !!value
183 const disabled =
184 groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
185
186 const onPress = React.useCallback(() => {
187 const next = !selected
188 setFieldValue({name, value: next})
189 onChange?.(next)
190 }, [name, selected, onChange, setFieldValue])
191
192 const state = React.useMemo(
193 () => ({
194 name,
195 selected,
196 disabled: disabled ?? false,
197 isInvalid: isInvalid ?? false,
198 hovered,
199 pressed,
200 focused,
201 }),
202 [name, selected, disabled, hovered, pressed, focused, isInvalid],
203 )
204
205 return (
206 <ItemContext.Provider value={state}>
207 <Pressable
208 accessibilityHint={undefined} // optional
209 hitSlop={HITSLOP_10}
210 {...rest}
211 disabled={disabled}
212 aria-disabled={disabled ?? false}
213 aria-checked={selected}
214 aria-invalid={isInvalid}
215 aria-label={label}
216 role={role}
217 accessibilityRole={role}
218 accessibilityState={{
219 disabled: disabled ?? false,
220 selected: selected,
221 }}
222 accessibilityLabel={label}
223 onPress={onPress}
224 onHoverIn={onHoverIn}
225 onHoverOut={onHoverOut}
226 onPressIn={onPressIn}
227 onPressOut={onPressOut}
228 onFocus={onFocus}
229 onBlur={onBlur}
230 style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
231 {typeof children === 'function' ? children(state) : children}
232 </Pressable>
233 </ItemContext.Provider>
234 )
235}
236
237export function LabelText({
238 children,
239 style,
240}: React.PropsWithChildren<TextStyleProp>) {
241 const t = useTheme()
242 const {disabled} = useItemContext()
243 return (
244 <Text
245 style={[
246 a.font_bold,
247 {
248 userSelect: 'none',
249 color: disabled
250 ? t.atoms.text_contrast_low.color
251 : t.atoms.text_contrast_high.color,
252 },
253 native({
254 paddingTop: 3,
255 }),
256 flatten(style),
257 ]}>
258 {children}
259 </Text>
260 )
261}
262
263// TODO(eric) refactor to memoize styles without knowledge of state
264export function createSharedToggleStyles({
265 theme: t,
266 hovered,
267 selected,
268 disabled,
269 isInvalid,
270}: {
271 theme: ReturnType<typeof useTheme>
272 selected: boolean
273 hovered: boolean
274 focused: boolean
275 disabled: boolean
276 isInvalid: boolean
277}) {
278 const base: ViewStyle[] = []
279 const baseHover: ViewStyle[] = []
280 const indicator: ViewStyle[] = []
281
282 if (selected) {
283 base.push({
284 backgroundColor:
285 t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
286 borderColor: t.palette.primary_500,
287 })
288
289 if (hovered) {
290 baseHover.push({
291 backgroundColor:
292 t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
293 borderColor:
294 t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
295 })
296 }
297 } else {
298 if (hovered) {
299 baseHover.push({
300 backgroundColor:
301 t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
302 borderColor: t.palette.contrast_500,
303 })
304 }
305 }
306
307 if (isInvalid) {
308 base.push({
309 backgroundColor:
310 t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
311 borderColor:
312 t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
313 })
314
315 if (hovered) {
316 baseHover.push({
317 backgroundColor:
318 t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
319 borderColor: t.palette.negative_600,
320 })
321 }
322 }
323
324 if (disabled) {
325 base.push({
326 backgroundColor: t.palette.contrast_100,
327 borderColor: t.palette.contrast_400,
328 })
329 }
330
331 return {
332 baseStyles: base,
333 baseHoverStyles: disabled ? [] : baseHover,
334 indicatorStyles: indicator,
335 }
336}
337
338export function Checkbox() {
339 const t = useTheme()
340 const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
341 const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
342 theme: t,
343 hovered,
344 focused,
345 selected,
346 disabled,
347 isInvalid,
348 })
349 return (
350 <View
351 style={[
352 a.justify_center,
353 a.align_center,
354 a.border,
355 a.rounded_xs,
356 t.atoms.border_contrast_high,
357 {
358 height: 20,
359 width: 20,
360 },
361 baseStyles,
362 hovered ? baseHoverStyles : {},
363 ]}>
364 {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
365 </View>
366 )
367}
368
369export function Switch() {
370 const t = useTheme()
371 const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
372 const {baseStyles, baseHoverStyles, indicatorStyles} =
373 createSharedToggleStyles({
374 theme: t,
375 hovered,
376 focused,
377 selected,
378 disabled,
379 isInvalid,
380 })
381 return (
382 <View
383 style={[
384 a.relative,
385 a.border,
386 a.rounded_full,
387 t.atoms.bg,
388 t.atoms.border_contrast_high,
389 {
390 height: 20,
391 width: 30,
392 },
393 baseStyles,
394 hovered ? baseHoverStyles : {},
395 ]}>
396 <View
397 style={[
398 a.absolute,
399 a.rounded_full,
400 {
401 height: 12,
402 width: 12,
403 top: 3,
404 left: 3,
405 backgroundColor: t.palette.contrast_400,
406 },
407 selected
408 ? {
409 backgroundColor: t.palette.primary_500,
410 left: 13,
411 }
412 : {},
413 indicatorStyles,
414 ]}
415 />
416 </View>
417 )
418}
419
420export function Radio() {
421 const t = useTheme()
422 const {selected, hovered, focused, disabled, isInvalid} =
423 React.useContext(ItemContext)
424 const {baseStyles, baseHoverStyles, indicatorStyles} =
425 createSharedToggleStyles({
426 theme: t,
427 hovered,
428 focused,
429 selected,
430 disabled,
431 isInvalid,
432 })
433 return (
434 <View
435 style={[
436 a.justify_center,
437 a.align_center,
438 a.border,
439 a.rounded_full,
440 t.atoms.border_contrast_high,
441 {
442 height: 20,
443 width: 20,
444 },
445 baseStyles,
446 hovered ? baseHoverStyles : {},
447 ]}>
448 {selected ? (
449 <View
450 style={[
451 a.absolute,
452 a.rounded_full,
453 {height: 12, width: 12},
454 selected
455 ? {
456 backgroundColor: t.palette.primary_500,
457 }
458 : {},
459 indicatorStyles,
460 ]}
461 />
462 ) : null}
463 </View>
464 )
465}