Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at ece6dc251cdb7eaf260819a4005b3a3e3e74ac8b 565 lines 12 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 { 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