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