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 {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
14import {
15 atoms as a,
16 native,
17 platform,
18 type TextStyleProp,
19 useTheme,
20 type ViewStyleProp,
21} from '#/alf'
22import {useInteractionState} from '#/components/hooks/useInteractionState'
23import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
24import {Text} from '#/components/Typography'
25import {IS_NATIVE} from '#/env'
26
27export * from './Panel'
28
29export type ItemState = {
30 name: string
31 selected: boolean
32 disabled: boolean
33 isInvalid: boolean
34 hovered: boolean
35 pressed: boolean
36 focused: boolean
37}
38
39const ItemContext = createContext<ItemState>({
40 name: '',
41 selected: false,
42 disabled: false,
43 isInvalid: false,
44 hovered: false,
45 pressed: false,
46 focused: false,
47})
48ItemContext.displayName = 'ToggleItemContext'
49
50const GroupContext = createContext<{
51 values: string[]
52 disabled: boolean
53 type: 'radio' | 'checkbox'
54 maxSelectionsReached: boolean
55 setFieldValue: (props: {name: string; value: boolean}) => void
56}>({
57 type: 'checkbox',
58 values: [],
59 disabled: false,
60 maxSelectionsReached: false,
61 setFieldValue: () => {},
62})
63GroupContext.displayName = 'ToggleGroupContext'
64
65export type GroupProps = React.PropsWithChildren<{
66 type?: 'radio' | 'checkbox'
67 values: string[]
68 maxSelections?: number
69 disabled?: boolean
70 onChange: (value: string[]) => void
71 label: string
72 style?: StyleProp<ViewStyle>
73}>
74
75export type ItemProps = ViewStyleProp & {
76 type?: 'radio' | 'checkbox'
77 name: string
78 label: string
79 value?: boolean
80 disabled?: boolean
81 onChange?: (selected: boolean) => void
82 isInvalid?: boolean
83 children: ((props: ItemState) => React.ReactNode) | React.ReactNode
84 hitSlop?: PressableProps['hitSlop']
85}
86
87export function useItemContext() {
88 return useContext(ItemContext)
89}
90
91export function Group({
92 children,
93 values: providedValues,
94 onChange,
95 disabled = false,
96 type = 'checkbox',
97 maxSelections,
98 label,
99 style,
100}: GroupProps) {
101 const groupRole = type === 'radio' ? 'radiogroup' : undefined
102 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
103
104 const setFieldValue = useCallback<
105 (props: {name: string; value: boolean}) => void
106 >(
107 ({name, value}) => {
108 if (type === 'checkbox') {
109 const pruned = values.filter(v => v !== name)
110 const next = value ? pruned.concat(name) : pruned
111 onChange(next)
112 } else {
113 onChange([name])
114 }
115 },
116 [type, onChange, values],
117 )
118
119 const maxReached = !!(
120 type === 'checkbox' &&
121 maxSelections &&
122 values.length >= maxSelections
123 )
124
125 const context = 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, style]}
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 } = 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 const playHaptic = useHaptics()
185
186 const role = groupType === 'radio' ? 'radio' : type
187 const selected = selectedValues.includes(name) || !!value
188 const disabled =
189 groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
190
191 const onPress = useCallback(() => {
192 playHaptic('Light')
193 const next = !selected
194 setFieldValue({name, value: next})
195 onChange?.(next)
196 }, [playHaptic, name, selected, onChange, setFieldValue])
197
198 const state = 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, 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_semi_bold,
253 a.leading_tight,
254 a.user_select_none,
255 {
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 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_500,
292 borderColor: t.palette.primary_500,
293 })
294
295 if (hovered) {
296 baseHover.push({
297 backgroundColor: t.palette.primary_400,
298 borderColor: t.palette.primary_400,
299 })
300 }
301 } else {
302 base.push({
303 backgroundColor: t.palette.contrast_25,
304 borderColor: t.palette.contrast_100,
305 })
306
307 if (hovered) {
308 baseHover.push({
309 backgroundColor: t.palette.contrast_50,
310 borderColor: t.palette.contrast_200,
311 })
312 }
313 }
314
315 if (isInvalid) {
316 base.push({
317 backgroundColor: t.palette.negative_25,
318 borderColor: t.palette.negative_300,
319 })
320
321 if (hovered) {
322 baseHover.push({
323 backgroundColor: t.palette.negative_25,
324 borderColor: t.palette.negative_600,
325 })
326 }
327
328 if (selected) {
329 base.push({
330 backgroundColor: t.palette.negative_500,
331 borderColor: t.palette.negative_500,
332 })
333
334 if (hovered) {
335 baseHover.push({
336 backgroundColor: t.palette.negative_400,
337 borderColor: t.palette.negative_400,
338 })
339 }
340 }
341 }
342
343 if (disabled) {
344 base.push({
345 backgroundColor: t.palette.contrast_100,
346 borderColor: t.palette.contrast_400,
347 })
348
349 if (selected) {
350 base.push({
351 backgroundColor: t.palette.primary_100,
352 borderColor: t.palette.contrast_400,
353 })
354 }
355 }
356
357 return {
358 baseStyles: base,
359 baseHoverStyles: disabled ? [] : baseHover,
360 indicatorStyles: indicator,
361 }
362}
363
364export function Checkbox() {
365 const t = useTheme()
366 const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
367 const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
368 theme: t,
369 hovered,
370 focused,
371 selected,
372 disabled,
373 isInvalid,
374 })
375 return (
376 <View
377 style={[
378 a.justify_center,
379 a.align_center,
380 t.atoms.border_contrast_high,
381 a.transition_color,
382 {
383 borderWidth: 1,
384 height: 24,
385 width: 24,
386 borderRadius: 6,
387 },
388 baseStyles,
389 hovered ? baseHoverStyles : {},
390 ]}>
391 {selected && <Checkmark width={14} fill={t.palette.white} />}
392 </View>
393 )
394}
395
396export function Switch() {
397 const t = useTheme()
398 const {selected, hovered, disabled, isInvalid} = useItemContext()
399 const enableSquareButtons = useEnableSquareButtons()
400 const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => {
401 const base: ViewStyle[] = []
402 const baseHover: ViewStyle[] = []
403 const indicator: ViewStyle[] = []
404
405 if (selected) {
406 base.push({
407 backgroundColor: t.palette.primary_500,
408 })
409
410 if (hovered) {
411 baseHover.push({
412 backgroundColor: t.palette.primary_400,
413 })
414 }
415 } else {
416 base.push({
417 backgroundColor: t.palette.contrast_200,
418 })
419
420 if (hovered) {
421 baseHover.push({
422 backgroundColor: t.palette.contrast_100,
423 })
424 }
425 }
426
427 if (isInvalid) {
428 base.push({
429 backgroundColor: t.palette.negative_200,
430 })
431
432 if (hovered) {
433 baseHover.push({
434 backgroundColor: t.palette.negative_100,
435 })
436 }
437
438 if (selected) {
439 base.push({
440 backgroundColor: t.palette.negative_500,
441 })
442
443 if (hovered) {
444 baseHover.push({
445 backgroundColor: t.palette.negative_400,
446 })
447 }
448 }
449 }
450
451 if (disabled) {
452 base.push({
453 backgroundColor: t.palette.contrast_50,
454 })
455
456 if (selected) {
457 base.push({
458 backgroundColor: t.palette.primary_100,
459 })
460 }
461 }
462
463 return {
464 baseStyles: base,
465 baseHoverStyles: disabled ? [] : baseHover,
466 indicatorStyles: indicator,
467 }
468 }, [t, hovered, disabled, selected, isInvalid])
469
470 return (
471 <View
472 style={[
473 a.relative,
474 enableSquareButtons ? a.rounded_sm : a.rounded_full,
475 t.atoms.bg,
476 {
477 height: 28,
478 width: 48,
479 padding: 3,
480 },
481 a.transition_color,
482 baseStyles,
483 hovered ? baseHoverStyles : {},
484 ]}>
485 <Animated.View
486 layout={LinearTransition.duration(
487 platform({
488 web: 100,
489 default: 200,
490 }),
491 ).easing(Easing.inOut(Easing.cubic))}
492 style={[
493 enableSquareButtons ? a.rounded_sm : a.rounded_full,
494 {
495 backgroundColor: t.palette.white,
496 height: 22,
497 width: 22,
498 },
499 selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'},
500 indicatorStyles,
501 ]}
502 />
503 </View>
504 )
505}
506
507export function Radio() {
508 const props = useContext(ItemContext)
509
510 return <BaseRadio {...props} />
511}
512
513export function BaseRadio({
514 hovered,
515 focused,
516 selected,
517 disabled,
518 isInvalid,
519}: Pick<
520 ItemState,
521 'hovered' | 'focused' | 'selected' | 'disabled' | 'isInvalid'
522>) {
523 const t = useTheme()
524 const enableSquareButtons = useEnableSquareButtons()
525 const {baseStyles, baseHoverStyles, indicatorStyles} =
526 createSharedToggleStyles({
527 theme: t,
528 hovered,
529 focused,
530 selected,
531 disabled,
532 isInvalid,
533 })
534
535 return (
536 <View
537 style={[
538 a.justify_center,
539 a.align_center,
540 enableSquareButtons ? a.rounded_sm : a.rounded_full,
541 t.atoms.border_contrast_high,
542 a.transition_color,
543 {
544 borderWidth: 1,
545 height: 25,
546 width: 25,
547 margin: -1,
548 },
549 baseStyles,
550 hovered ? baseHoverStyles : {},
551 ]}>
552 {selected && (
553 <View
554 style={[
555 a.absolute,
556 enableSquareButtons ? a.rounded_sm : a.rounded_full,
557 {height: 12, width: 12},
558 {backgroundColor: t.palette.white},
559 indicatorStyles,
560 ]}
561 />
562 )}
563 </View>
564 )
565}
566
567export const Platform = IS_NATIVE ? Switch : Checkbox