Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
24 kB · 861 lines
TSX
at commit 8ce9bdb2
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862import React from 'react'import { type AccessibilityProps, type GestureResponderEvent, type MouseEvent, type NativeSyntheticEvent, Pressable, type PressableProps, type StyleProp, StyleSheet, type TargetedEvent, type TextProps, type TextStyle, View, type ViewStyle,} from 'react-native'import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, flatten, select, tokens, useTheme} from '#/alf'import {type Props as SVGIconProps} from '#/components/icons/common'import {Text} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'export type ButtonColor = | 'primary' | 'secondary' | 'secondary_inverted' | 'negative' | 'negative_secondary' | 'gradient_primary' | 'gradient_sky' | 'gradient_midnight' | 'gradient_sunrise' | 'gradient_sunset' | 'gradient_nordic' | 'gradient_bonfire'export type ButtonSize = 'tiny' | 'small' | 'large'export type ButtonShape = 'round' | 'square' | 'default'export type VariantProps = { /** * The style variation of the button */ variant?: ButtonVariant /** * The color of the button */ color?: ButtonColor /** * The size of the button */ size?: ButtonSize /** * The shape of the button */ shape?: ButtonShape}
export type ButtonState = { hovered: boolean focused: boolean pressed: boolean disabled: boolean}
export type ButtonContext = VariantProps & ButtonState
type NonTextElements = | React.ReactElement | Iterable<React.ReactElement | null | undefined | boolean>
export type ButtonProps = Pick< PressableProps, | 'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop' | 'onHoverIn' | 'onHoverOut' | 'onPressIn' | 'onPressOut' | 'onFocus' | 'onBlur'> & AccessibilityProps & VariantProps & { testID?: string /** * For a11y, try to make this descriptive and clear */ label: string style?: StyleProp<ViewStyle> hoverStyle?: StyleProp<ViewStyle> children: NonTextElements | ((context: ButtonContext) => NonTextElements) PressableComponent?: React.ComponentType<PressableProps> }
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext<VariantProps & ButtonState>({ hovered: false, focused: false, pressed: false, disabled: false,})
export function useButtonContext() { return React.useContext(Context)}
export const Button = React.forwardRef<View, ButtonProps>( ( { children, variant, color, size, shape = 'default', label, disabled = false, style, hoverStyle: hoverStyleProp, PressableComponent = Pressable, onPressIn: onPressInOuter, onPressOut: onPressOutOuter, onHoverIn: onHoverInOuter, onHoverOut: onHoverOutOuter, onFocus: onFocusOuter, onBlur: onBlurOuter, ...rest }, ref, ) => { const t = useTheme() const [state, setState] = React.useState({ pressed: false, hovered: false, focused: false, })
const onPressIn = React.useCallback( (e: GestureResponderEvent) => { setState(s => ({ ...s, pressed: true, })) onPressInOuter?.(e) }, [setState, onPressInOuter], ) const onPressOut = React.useCallback( (e: GestureResponderEvent) => { setState(s => ({ ...s, pressed: false, })) onPressOutOuter?.(e) }, [setState, onPressOutOuter], ) const onHoverIn = React.useCallback( (e: MouseEvent) => { setState(s => ({ ...s, hovered: true, })) onHoverInOuter?.(e) }, [setState, onHoverInOuter], ) const onHoverOut = React.useCallback( (e: MouseEvent) => { setState(s => ({ ...s, hovered: false, })) onHoverOutOuter?.(e) }, [setState, onHoverOutOuter], ) const onFocus = React.useCallback( (e: NativeSyntheticEvent<TargetedEvent>) => { setState(s => ({ ...s, focused: true, })) onFocusOuter?.(e) }, [setState, onFocusOuter], ) const onBlur = React.useCallback( (e: NativeSyntheticEvent<TargetedEvent>) => { setState(s => ({ ...s, focused: false, })) onBlurOuter?.(e) }, [setState, onBlurOuter], )
const {baseStyles, hoverStyles} = React.useMemo(() => { const baseStyles: ViewStyle[] = [] const hoverStyles: ViewStyle[] = []
if (color === 'primary') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.primary_500, }) hoverStyles.push({ backgroundColor: t.palette.primary_600, }) } else { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.primary_700, dim: t.palette.primary_300, dark: t.palette.primary_300, }), }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, })
if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.primary_500, }) hoverStyles.push(a.border, { backgroundColor: t.palette.primary_50, }) } else { baseStyles.push(a.border, { borderColor: t.palette.primary_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.primary_100, }) } } } else if (color === 'secondary') { if (variant === 'solid') { if (!disabled) { baseStyles.push(t.atoms.bg_contrast_25) hoverStyles.push(t.atoms.bg_contrast_50) } else { baseStyles.push(t.atoms.bg_contrast_100) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, })
if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.contrast_300, }) hoverStyles.push(t.atoms.bg_contrast_50) } else { baseStyles.push(a.border, { borderColor: t.palette.contrast_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.contrast_25, }) } } } else if (color === 'secondary_inverted') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.contrast_900, }) hoverStyles.push({ backgroundColor: t.palette.contrast_950, }) } else { baseStyles.push({ backgroundColor: t.palette.contrast_600, }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, })
if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.contrast_300, }) hoverStyles.push(t.atoms.bg_contrast_50) } else { baseStyles.push(a.border, { borderColor: t.palette.contrast_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.contrast_25, }) } } } else if (color === 'negative') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.negative_500, }) hoverStyles.push({ backgroundColor: t.palette.negative_600, }) } else { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_700, dim: t.palette.negative_300, dark: t.palette.negative_300, }), }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, })
if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.negative_500, }) hoverStyles.push(a.border, { backgroundColor: t.palette.negative_50, }) } else { baseStyles.push(a.border, { borderColor: t.palette.negative_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.negative_100, }) } } } else if (color === 'negative_secondary') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_50, dim: t.palette.negative_100, dark: t.palette.negative_100, }), }) hoverStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_100, dim: t.palette.negative_200, dark: t.palette.negative_200, }), }) } else { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_100, dim: t.palette.negative_50, dark: t.palette.negative_50, }), }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, })
if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.negative_500, }) hoverStyles.push(a.border, { backgroundColor: t.palette.negative_50, }) } else { baseStyles.push(a.border, { borderColor: t.palette.negative_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.negative_100, }) } } }
if (shape === 'default') { if (size === 'large') { baseStyles.push({ paddingVertical: 13, paddingHorizontal: 20, borderRadius: 8, gap: 8, }) } else if (size === 'small') { baseStyles.push({ paddingVertical: 9, paddingHorizontal: 12, borderRadius: 6, gap: 6, }) } else if (size === 'tiny') { baseStyles.push({ paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4, gap: 4, }) } } else if (shape === 'round' || shape === 'square') { if (size === 'large') { if (shape === 'round') { baseStyles.push({height: 46, width: 46}) } else { baseStyles.push({height: 44, width: 44}) } } else if (size === 'small') { if (shape === 'round') { baseStyles.push({height: 34, width: 34}) } else { baseStyles.push({height: 34, width: 34}) } } else if (size === 'tiny') { if (shape === 'round') { baseStyles.push({height: 22, width: 22}) } else { baseStyles.push({height: 21, width: 21}) } }
if (shape === 'round') { baseStyles.push(a.rounded_full) } else if (shape === 'square') { if (size === 'tiny') { baseStyles.push(a.rounded_xs) } else { baseStyles.push(a.rounded_sm) } } }
return { baseStyles, hoverStyles, } }, [t, variant, color, size, shape, disabled])
const gradientValues = React.useMemo(() => { const gradient = { primary: tokens.gradients.sky, secondary: tokens.gradients.sky, secondary_inverted: tokens.gradients.sky, negative: tokens.gradients.sky, negative_secondary: tokens.gradients.sky, gradient_primary: tokens.gradients.primary, gradient_sky: tokens.gradients.sky, gradient_midnight: tokens.gradients.midnight, gradient_sunrise: tokens.gradients.sunrise, gradient_sunset: tokens.gradients.sunset, gradient_nordic: tokens.gradients.nordic, gradient_bonfire: tokens.gradients.bonfire, }[color || 'primary']
if (variant === 'gradient') { if (gradient.values.length < 2) { throw new Error( 'Gradient buttons must have at least two colors in the gradient', ) }
return { colors: gradient.values.map(([_, color]) => color) as [ string, string, ...string[], ], hoverColors: gradient.values.map(_ => gradient.hover_value) as [ string, string, ...string[], ], locations: gradient.values.map(([location, _]) => location) as [ number, number, ...number[], ], } } }, [variant, color])
const context = React.useMemo<ButtonContext>( () => ({ ...state, variant, color, size, disabled: disabled || false, }), [state, variant, color, size, disabled], )
const flattenedBaseStyles = flatten([baseStyles, style])
return ( <PressableComponent role="button" accessibilityHint={undefined} // optional {...rest} // @ts-ignore - this will always be a pressable ref={ref} aria-label={label} aria-pressed={state.pressed} accessibilityLabel={label} disabled={disabled || false} accessibilityState={{ disabled: disabled || false, }} style={[ a.flex_row, a.align_center, a.justify_center, flattenedBaseStyles, ...(state.hovered || state.pressed ? [hoverStyles, flatten(hoverStyleProp)] : []), ]} onPressIn={onPressIn} onPressOut={onPressOut} onHoverIn={onHoverIn} onHoverOut={onHoverOut} onFocus={onFocus} onBlur={onBlur}> {variant === 'gradient' && gradientValues && ( <View style={[ a.absolute, a.inset_0, a.overflow_hidden, {borderRadius: flattenedBaseStyles.borderRadius}, ]}> <LinearGradient colors={ state.hovered || state.pressed ? gradientValues.hoverColors : gradientValues.colors } locations={gradientValues.locations} start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={[a.absolute, a.inset_0]} /> </View> )} <Context.Provider value={context}> {typeof children === 'function' ? children(context) : children} </Context.Provider> </PressableComponent> ) },)Button.displayName = 'Button'
export function useSharedButtonTextStyles() { const t = useTheme() const {color, variant, disabled, size} = useButtonContext() return React.useMemo(() => { const baseStyles: TextStyle[] = []
if (color === 'primary') { if (variant === 'solid') { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({color: t.palette.white, opacity: 0.5}) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({ color: t.palette.primary_600, }) } else { baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({color: t.palette.primary_600}) } else { baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) } } } else if (color === 'secondary') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_700, }) } else { baseStyles.push({ color: t.palette.contrast_400, }) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } } else if (color === 'secondary_inverted') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_50, }) } else { baseStyles.push({ color: t.palette.contrast_400, }) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } } else if (color === 'negative') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({color: t.palette.white, opacity: 0.5}) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } } else if (color === 'negative_secondary') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({ color: select(t.name, { light: t.palette.negative_500, dim: t.palette.negative_950, dark: t.palette.negative_900, }), }) } else { baseStyles.push({ color: select(t.name, { light: t.palette.negative_500, dim: t.palette.negative_700, dark: t.palette.negative_700, }), opacity: 0.5, }) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } } else { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({color: t.palette.white, opacity: 0.5}) } }
if (size === 'large') { baseStyles.push(a.text_md, a.leading_tight) } else if (size === 'small') { baseStyles.push(a.text_sm, a.leading_tight) } else if (size === 'tiny') { baseStyles.push(a.text_xs, a.leading_tight) }
return StyleSheet.flatten(baseStyles) }, [t, variant, color, size, disabled])}
export function ButtonText({children, style, ...rest}: ButtonTextProps) { const textStyles = useSharedButtonTextStyles()
return ( <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> {children} </Text> )}
export function ButtonIcon({ icon: Comp, position, size,}: { icon: React.ComponentType<SVGIconProps> position?: 'left' | 'right' size?: SVGIconProps['size']}) { const {size: buttonSize, disabled} = useButtonContext() const textStyles = useSharedButtonTextStyles() const {iconSize, iconContainerSize} = React.useMemo(() => { /** * Pre-set icon sizes for different button sizes */ const iconSizeShorthand = size ?? (({ large: 'sm', small: 'sm', tiny: 'xs', }[buttonSize || 'small'] || 'sm') as Exclude< SVGIconProps['size'], undefined >)
/* * Copied here from icons/common.tsx so we can tweak if we need to, but * also so that we can calculate transforms. */ const iconSize = { xs: 12, sm: 16, md: 20, lg: 24, xl: 28, '2xl': 32, }[iconSizeShorthand]
/* * Goal here is to match rendered text size so that different size icons * don't increase button size */ const iconContainerSize = { large: 18, small: 16, tiny: 13, }[buttonSize || 'small']
return { iconSize, iconContainerSize, } }, [buttonSize, size])
return ( <View style={[ a.z_20, { width: iconContainerSize, height: iconContainerSize, opacity: disabled ? 0.7 : 1, marginLeft: position === 'left' ? -2 : 0, marginRight: position === 'right' ? -2 : 0, }, ]}> <View style={[ a.absolute, { width: iconSize, height: iconSize, top: '50%', left: '50%', transform: [ { translateX: (iconSize / 2) * -1, }, { translateY: (iconSize / 2) * -1, }, ], }, ]}> <Comp width={iconSize} style={[ { color: textStyles.color, pointerEvents: 'none', }, ]} /> </View> </View> )}