Bluesky app fork with some witchin' additions 馃挮
at readme-update 567 lines 13 kB view raw
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