mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {Pressable, type StyleProp, 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 style?: StyleProp<ViewStyle>
63}>
64
65export type ItemProps = ViewStyleProp & {
66 type?: 'radio' | 'checkbox'
67 name: string
68 label: string
69 value?: boolean
70 disabled?: boolean
71 onChange?: (selected: boolean) => void
72 isInvalid?: boolean
73 children: ((props: ItemState) => React.ReactNode) | React.ReactNode
74}
75
76export function useItemContext() {
77 return React.useContext(ItemContext)
78}
79
80export function Group({
81 children,
82 values: providedValues,
83 onChange,
84 disabled = false,
85 type = 'checkbox',
86 maxSelections,
87 label,
88 style,
89}: GroupProps) {
90 const groupRole = type === 'radio' ? 'radiogroup' : undefined
91 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
92 const [maxReached, setMaxReached] = React.useState(false)
93
94 const setFieldValue = React.useCallback<
95 (props: {name: string; value: boolean}) => void
96 >(
97 ({name, value}) => {
98 if (type === 'checkbox') {
99 const pruned = values.filter(v => v !== name)
100 const next = value ? pruned.concat(name) : pruned
101 onChange(next)
102 } else {
103 onChange([name])
104 }
105 },
106 [type, onChange, values],
107 )
108
109 React.useEffect(() => {
110 if (type === 'checkbox') {
111 if (
112 maxSelections &&
113 values.length >= maxSelections &&
114 maxReached === false
115 ) {
116 setMaxReached(true)
117 } else if (
118 maxSelections &&
119 values.length < maxSelections &&
120 maxReached === true
121 ) {
122 setMaxReached(false)
123 }
124 }
125 }, [type, values.length, maxSelections, maxReached, setMaxReached])
126
127 const context = React.useMemo(
128 () => ({
129 values,
130 type,
131 disabled,
132 maxSelectionsReached: maxReached,
133 setFieldValue,
134 }),
135 [values, disabled, type, maxReached, setFieldValue],
136 )
137
138 return (
139 <GroupContext.Provider value={context}>
140 <View
141 style={[a.w_full, style]}
142 role={groupRole}
143 {...(groupRole === 'radiogroup'
144 ? {
145 'aria-label': label,
146 accessibilityLabel: label,
147 accessibilityRole: groupRole,
148 }
149 : {})}>
150 {children}
151 </View>
152 </GroupContext.Provider>
153 )
154}
155
156export function Item({
157 children,
158 name,
159 value = false,
160 disabled: itemDisabled = false,
161 onChange,
162 isInvalid,
163 style,
164 type = 'checkbox',
165 label,
166 ...rest
167}: ItemProps) {
168 const {
169 values: selectedValues,
170 type: groupType,
171 disabled: groupDisabled,
172 setFieldValue,
173 maxSelectionsReached,
174 } = React.useContext(GroupContext)
175 const {
176 state: hovered,
177 onIn: onHoverIn,
178 onOut: onHoverOut,
179 } = useInteractionState()
180 const {
181 state: pressed,
182 onIn: onPressIn,
183 onOut: onPressOut,
184 } = useInteractionState()
185 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
186
187 const role = groupType === 'radio' ? 'radio' : type
188 const selected = selectedValues.includes(name) || !!value
189 const disabled =
190 groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
191
192 const onPress = React.useCallback(() => {
193 const next = !selected
194 setFieldValue({name, value: next})
195 onChange?.(next)
196 }, [name, selected, onChange, setFieldValue])
197
198 const state = React.useMemo(
199 () => ({
200 name,
201 selected,
202 disabled: disabled ?? false,
203 isInvalid: isInvalid ?? false,
204 hovered,
205 pressed,
206 focused,
207 }),
208 [name, selected, disabled, hovered, pressed, focused, isInvalid],
209 )
210
211 return (
212 <ItemContext.Provider value={state}>
213 <Pressable
214 accessibilityHint={undefined} // optional
215 hitSlop={HITSLOP_10}
216 {...rest}
217 disabled={disabled}
218 aria-disabled={disabled ?? false}
219 aria-checked={selected}
220 aria-invalid={isInvalid}
221 aria-label={label}
222 role={role}
223 accessibilityRole={role}
224 accessibilityState={{
225 disabled: disabled ?? false,
226 selected: selected,
227 }}
228 accessibilityLabel={label}
229 onPress={onPress}
230 onHoverIn={onHoverIn}
231 onHoverOut={onHoverOut}
232 onPressIn={onPressIn}
233 onPressOut={onPressOut}
234 onFocus={onFocus}
235 onBlur={onBlur}
236 style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
237 {typeof children === 'function' ? children(state) : children}
238 </Pressable>
239 </ItemContext.Provider>
240 )
241}
242
243export function LabelText({
244 children,
245 style,
246}: React.PropsWithChildren<TextStyleProp>) {
247 const t = useTheme()
248 const {disabled} = useItemContext()
249 return (
250 <Text
251 style={[
252 a.font_bold,
253 a.leading_tight,
254 {
255 userSelect: 'none',
256 color: disabled
257 ? t.atoms.text_contrast_low.color
258 : t.atoms.text_contrast_high.color,
259 },
260 native({
261 paddingTop: 2,
262 }),
263 flatten(style),
264 ]}>
265 {children}
266 </Text>
267 )
268}
269
270// TODO(eric) refactor to memoize styles without knowledge of state
271export function createSharedToggleStyles({
272 theme: t,
273 hovered,
274 selected,
275 disabled,
276 isInvalid,
277}: {
278 theme: ReturnType<typeof useTheme>
279 selected: boolean
280 hovered: boolean
281 focused: boolean
282 disabled: boolean
283 isInvalid: boolean
284}) {
285 const base: ViewStyle[] = []
286 const baseHover: ViewStyle[] = []
287 const indicator: ViewStyle[] = []
288
289 if (selected) {
290 base.push({
291 backgroundColor: t.palette.primary_25,
292 borderColor: t.palette.primary_500,
293 })
294
295 if (hovered) {
296 baseHover.push({
297 backgroundColor: t.palette.primary_100,
298 borderColor: t.palette.primary_600,
299 })
300 }
301 } else {
302 if (hovered) {
303 baseHover.push({
304 backgroundColor: t.palette.contrast_50,
305 borderColor: t.palette.contrast_500,
306 })
307 }
308 }
309
310 if (isInvalid) {
311 base.push({
312 backgroundColor: t.palette.negative_25,
313 borderColor: t.palette.negative_300,
314 })
315
316 if (hovered) {
317 baseHover.push({
318 backgroundColor: t.palette.negative_25,
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.rounded_xs,
355 t.atoms.border_contrast_high,
356 {
357 borderWidth: 1,
358 height: 24,
359 width: 24,
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.rounded_full,
386 t.atoms.bg,
387 t.atoms.border_contrast_high,
388 {
389 borderWidth: 1,
390 height: 24,
391 width: 36,
392 padding: 3,
393 },
394 baseStyles,
395 hovered ? baseHoverStyles : {},
396 ]}>
397 <Animated.View
398 layout={LinearTransition.duration(100)}
399 style={[
400 a.rounded_full,
401 {
402 height: 16,
403 width: 16,
404 },
405 selected
406 ? {
407 backgroundColor: t.palette.primary_500,
408 alignSelf: 'flex-end',
409 }
410 : {
411 backgroundColor: t.palette.contrast_400,
412 alignSelf: 'flex-start',
413 },
414 indicatorStyles,
415 ]}
416 />
417 </View>
418 )
419}
420
421export function Radio() {
422 const t = useTheme()
423 const {selected, hovered, focused, disabled, isInvalid} =
424 React.useContext(ItemContext)
425 const {baseStyles, baseHoverStyles, indicatorStyles} =
426 createSharedToggleStyles({
427 theme: t,
428 hovered,
429 focused,
430 selected,
431 disabled,
432 isInvalid,
433 })
434 return (
435 <View
436 style={[
437 a.justify_center,
438 a.align_center,
439 a.rounded_full,
440 t.atoms.border_contrast_high,
441 {
442 borderWidth: 1,
443 height: 24,
444 width: 24,
445 },
446 baseStyles,
447 hovered ? baseHoverStyles : {},
448 ]}>
449 {selected ? (
450 <View
451 style={[
452 a.absolute,
453 a.rounded_full,
454 {height: 16, width: 16},
455 selected
456 ? {
457 backgroundColor: t.palette.primary_500,
458 }
459 : {},
460 indicatorStyles,
461 ]}
462 />
463 ) : null}
464 </View>
465 )
466}
467
468export const Platform = isNative ? Switch : Checkbox