mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at thread-bug 466 lines 11 kB view raw
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