forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {createContext, useCallback, useContext, useMemo} from 'react'
2import {
3 Pressable,
4 type PressableProps,
5 type StyleProp,
6 View,
7 type ViewStyle,
8} from 'react-native'
9import Animated, {Easing, LinearTransition} from 'react-native-reanimated'
10
11import {HITSLOP_10} from '#/lib/constants'
12import {useHaptics} from '#/lib/haptics'
13import {
14 atoms as a,
15 native,
16 platform,
17 type TextStyleProp,
18 useTheme,
19 type ViewStyleProp,
20} from '#/alf'
21import {useInteractionState} from '#/components/hooks/useInteractionState'
22import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
23import {Text} from '#/components/Typography'
24import {IS_NATIVE} from '#/env'
25
26export * from './Panel'
27
28export type ItemState = {
29 name: string
30 selected: boolean
31 disabled: boolean
32 isInvalid: boolean
33 hovered: boolean
34 pressed: boolean
35 focused: boolean
36}
37
38const ItemContext = createContext<ItemState>({
39 name: '',
40 selected: false,
41 disabled: false,
42 isInvalid: false,
43 hovered: false,
44 pressed: false,
45 focused: false,
46})
47ItemContext.displayName = 'ToggleItemContext'
48
49const GroupContext = createContext<{
50 values: string[]
51 disabled: boolean
52 type: 'radio' | 'checkbox'
53 maxSelectionsReached: boolean
54 setFieldValue: (props: {name: string; value: boolean}) => void
55}>({
56 type: 'checkbox',
57 values: [],
58 disabled: false,
59 maxSelectionsReached: false,
60 setFieldValue: () => {},
61})
62GroupContext.displayName = 'ToggleGroupContext'
63
64export type GroupProps = React.PropsWithChildren<{
65 type?: 'radio' | 'checkbox'
66 values: string[]
67 maxSelections?: number
68 disabled?: boolean
69 onChange: (value: string[]) => void
70 label: string
71 style?: StyleProp<ViewStyle>
72}>
73
74export type ItemProps = ViewStyleProp & {
75 type?: 'radio' | 'checkbox'
76 name: string
77 label: string
78 value?: boolean
79 disabled?: boolean
80 onChange?: (selected: boolean) => void
81 isInvalid?: boolean
82 children: ((props: ItemState) => React.ReactNode) | React.ReactNode
83 hitSlop?: PressableProps['hitSlop']
84}
85
86export function useItemContext() {
87 return useContext(ItemContext)
88}
89
90export function Group({
91 children,
92 values: providedValues,
93 onChange,
94 disabled = false,
95 type = 'checkbox',
96 maxSelections,
97 label,
98 style,
99}: GroupProps) {
100 const groupRole = type === 'radio' ? 'radiogroup' : undefined
101 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
102
103 const setFieldValue = useCallback<
104 (props: {name: string; value: boolean}) => void
105 >(
106 ({name, value}) => {
107 if (type === 'checkbox') {
108 const pruned = values.filter(v => v !== name)
109 const next = value ? pruned.concat(name) : pruned
110 onChange(next)
111 } else {
112 onChange([name])
113 }
114 },
115 [type, onChange, values],
116 )
117
118 const maxReached = !!(
119 type === 'checkbox' &&
120 maxSelections &&
121 values.length >= maxSelections
122 )
123
124 const context = useMemo(
125 () => ({
126 values,
127 type,
128 disabled,
129 maxSelectionsReached: maxReached,
130 setFieldValue,
131 }),
132 [values, disabled, type, maxReached, setFieldValue],
133 )
134
135 return (
136 <GroupContext.Provider value={context}>
137 <View
138 style={[a.w_full, style]}
139 role={groupRole}
140 {...(groupRole === 'radiogroup'
141 ? {
142 'aria-label': label,
143 accessibilityLabel: label,
144 accessibilityRole: groupRole,
145 }
146 : {})}>
147 {children}
148 </View>
149 </GroupContext.Provider>
150 )
151}
152
153export function Item({
154 children,
155 name,
156 value = false,
157 disabled: itemDisabled = false,
158 onChange,
159 isInvalid,
160 style,
161 type = 'checkbox',
162 label,
163 ...rest
164}: ItemProps) {
165 const {
166 values: selectedValues,
167 type: groupType,
168 disabled: groupDisabled,
169 setFieldValue,
170 maxSelectionsReached,
171 } = useContext(GroupContext)
172 const {
173 state: hovered,
174 onIn: onHoverIn,
175 onOut: onHoverOut,
176 } = useInteractionState()
177 const {
178 state: pressed,
179 onIn: onPressIn,
180 onOut: onPressOut,
181 } = useInteractionState()
182 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
183 const playHaptic = useHaptics()
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 = useCallback(() => {
191 playHaptic('Light')
192 const next = !selected
193 setFieldValue({name, value: next})
194 onChange?.(next)
195 }, [playHaptic, name, selected, onChange, setFieldValue])
196
197 const state = useMemo(
198 () => ({
199 name,
200 selected,
201 disabled: disabled ?? false,
202 isInvalid: isInvalid ?? false,
203 hovered,
204 pressed,
205 focused,
206 }),
207 [name, selected, disabled, hovered, pressed, focused, isInvalid],
208 )
209
210 return (
211 <ItemContext.Provider value={state}>
212 <Pressable
213 accessibilityHint={undefined} // optional
214 hitSlop={HITSLOP_10}
215 {...rest}
216 disabled={disabled}
217 aria-disabled={disabled ?? false}
218 aria-checked={selected}
219 aria-invalid={isInvalid}
220 aria-label={label}
221 role={role}
222 accessibilityRole={role}
223 accessibilityState={{
224 disabled: disabled ?? false,
225 selected: selected,
226 }}
227 accessibilityLabel={label}
228 onPress={onPress}
229 onHoverIn={onHoverIn}
230 onHoverOut={onHoverOut}
231 onPressIn={onPressIn}
232 onPressOut={onPressOut}
233 onFocus={onFocus}
234 onBlur={onBlur}
235 style={[a.flex_row, a.align_center, a.gap_sm, style]}>
236 {typeof children === 'function' ? children(state) : children}
237 </Pressable>
238 </ItemContext.Provider>
239 )
240}
241
242export function LabelText({
243 children,
244 style,
245}: React.PropsWithChildren<TextStyleProp>) {
246 const t = useTheme()
247 const {disabled} = useItemContext()
248 return (
249 <Text
250 style={[
251 a.font_semi_bold,
252 a.leading_tight,
253 a.user_select_none,
254 {
255 color: disabled
256 ? t.atoms.text_contrast_low.color
257 : t.atoms.text_contrast_high.color,
258 },
259 native({
260 paddingTop: 2,
261 }),
262 style,
263 ]}>
264 {children}
265 </Text>
266 )
267}
268
269// TODO(eric) refactor to memoize styles without knowledge of state
270export function createSharedToggleStyles({
271 theme: t,
272 hovered,
273 selected,
274 disabled,
275 isInvalid,
276}: {
277 theme: ReturnType<typeof useTheme>
278 selected: boolean
279 hovered: boolean
280 focused: boolean
281 disabled: boolean
282 isInvalid: boolean
283}) {
284 const base: ViewStyle[] = []
285 const baseHover: ViewStyle[] = []
286 const indicator: ViewStyle[] = []
287
288 if (selected) {
289 base.push({
290 backgroundColor: t.palette.primary_500,
291 borderColor: t.palette.primary_500,
292 })
293
294 if (hovered) {
295 baseHover.push({
296 backgroundColor: t.palette.primary_400,
297 borderColor: t.palette.primary_400,
298 })
299 }
300 } else {
301 base.push({
302 backgroundColor: t.palette.contrast_25,
303 borderColor: t.palette.contrast_100,
304 })
305
306 if (hovered) {
307 baseHover.push({
308 backgroundColor: t.palette.contrast_50,
309 borderColor: t.palette.contrast_200,
310 })
311 }
312 }
313
314 if (isInvalid) {
315 base.push({
316 backgroundColor: t.palette.negative_25,
317 borderColor: t.palette.negative_300,
318 })
319
320 if (hovered) {
321 baseHover.push({
322 backgroundColor: t.palette.negative_25,
323 borderColor: t.palette.negative_600,
324 })
325 }
326
327 if (selected) {
328 base.push({
329 backgroundColor: t.palette.negative_500,
330 borderColor: t.palette.negative_500,
331 })
332
333 if (hovered) {
334 baseHover.push({
335 backgroundColor: t.palette.negative_400,
336 borderColor: t.palette.negative_400,
337 })
338 }
339 }
340 }
341
342 if (disabled) {
343 base.push({
344 backgroundColor: t.palette.contrast_100,
345 borderColor: t.palette.contrast_400,
346 })
347
348 if (selected) {
349 base.push({
350 backgroundColor: t.palette.primary_100,
351 borderColor: t.palette.contrast_400,
352 })
353 }
354 }
355
356 return {
357 baseStyles: base,
358 baseHoverStyles: disabled ? [] : baseHover,
359 indicatorStyles: indicator,
360 }
361}
362
363export function Checkbox() {
364 const t = useTheme()
365 const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
366 const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
367 theme: t,
368 hovered,
369 focused,
370 selected,
371 disabled,
372 isInvalid,
373 })
374 return (
375 <View
376 style={[
377 a.justify_center,
378 a.align_center,
379 t.atoms.border_contrast_high,
380 a.transition_color,
381 {
382 borderWidth: 1,
383 height: 24,
384 width: 24,
385 borderRadius: 6,
386 },
387 baseStyles,
388 hovered ? baseHoverStyles : {},
389 ]}>
390 {selected && <Checkmark width={14} fill={t.palette.white} />}
391 </View>
392 )
393}
394
395export function Switch() {
396 const t = useTheme()
397 const {selected, hovered, disabled, isInvalid} = useItemContext()
398 const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => {
399 const base: ViewStyle[] = []
400 const baseHover: ViewStyle[] = []
401 const indicator: ViewStyle[] = []
402
403 if (selected) {
404 base.push({
405 backgroundColor: t.palette.primary_500,
406 })
407
408 if (hovered) {
409 baseHover.push({
410 backgroundColor: t.palette.primary_400,
411 })
412 }
413 } else {
414 base.push({
415 backgroundColor: t.palette.contrast_200,
416 })
417
418 if (hovered) {
419 baseHover.push({
420 backgroundColor: t.palette.contrast_100,
421 })
422 }
423 }
424
425 if (isInvalid) {
426 base.push({
427 backgroundColor: t.palette.negative_200,
428 })
429
430 if (hovered) {
431 baseHover.push({
432 backgroundColor: t.palette.negative_100,
433 })
434 }
435
436 if (selected) {
437 base.push({
438 backgroundColor: t.palette.negative_500,
439 })
440
441 if (hovered) {
442 baseHover.push({
443 backgroundColor: t.palette.negative_400,
444 })
445 }
446 }
447 }
448
449 if (disabled) {
450 base.push({
451 backgroundColor: t.palette.contrast_50,
452 })
453
454 if (selected) {
455 base.push({
456 backgroundColor: t.palette.primary_100,
457 })
458 }
459 }
460
461 return {
462 baseStyles: base,
463 baseHoverStyles: disabled ? [] : baseHover,
464 indicatorStyles: indicator,
465 }
466 }, [t, hovered, disabled, selected, isInvalid])
467
468 return (
469 <View
470 style={[
471 a.relative,
472 a.rounded_full,
473 t.atoms.bg,
474 {
475 height: 28,
476 width: 48,
477 padding: 3,
478 },
479 a.transition_color,
480 baseStyles,
481 hovered ? baseHoverStyles : {},
482 ]}>
483 <Animated.View
484 layout={LinearTransition.duration(
485 platform({
486 web: 100,
487 default: 200,
488 }),
489 ).easing(Easing.inOut(Easing.cubic))}
490 style={[
491 a.rounded_full,
492 {
493 backgroundColor: t.palette.white,
494 height: 22,
495 width: 22,
496 },
497 selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'},
498 indicatorStyles,
499 ]}
500 />
501 </View>
502 )
503}
504
505export function Radio() {
506 const props = useContext(ItemContext)
507
508 return <BaseRadio {...props} />
509}
510
511export function BaseRadio({
512 hovered,
513 focused,
514 selected,
515 disabled,
516 isInvalid,
517}: Pick<
518 ItemState,
519 'hovered' | 'focused' | 'selected' | 'disabled' | 'isInvalid'
520>) {
521 const t = useTheme()
522
523 const {baseStyles, baseHoverStyles, indicatorStyles} =
524 createSharedToggleStyles({
525 theme: t,
526 hovered,
527 focused,
528 selected,
529 disabled,
530 isInvalid,
531 })
532
533 return (
534 <View
535 style={[
536 a.justify_center,
537 a.align_center,
538 a.rounded_full,
539 t.atoms.border_contrast_high,
540 a.transition_color,
541 {
542 borderWidth: 1,
543 height: 25,
544 width: 25,
545 margin: -1,
546 },
547 baseStyles,
548 hovered ? baseHoverStyles : {},
549 ]}>
550 {selected && (
551 <View
552 style={[
553 a.absolute,
554 a.rounded_full,
555 {height: 12, width: 12},
556 {backgroundColor: t.palette.white},
557 indicatorStyles,
558 ]}
559 />
560 )}
561 </View>
562 )
563}
564
565export const Platform = IS_NATIVE ? Switch : Checkbox