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