Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
26 kB · 931 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932import React from 'react'import { type AccessibilityProps, type GestureResponderEvent, type MouseEvent, type NativeSyntheticEvent, Pressable, type PressableProps, type StyleProp, type TargetedEvent, type TextProps, type TextStyle, View, type ViewStyle,} from 'react-native'
import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'import {atoms as a, flatten, select, useTheme} from '#/alf'import {type Props as SVGIconProps} from '#/components/icons/common'import {Text} from '#/components/Typography'
/** * The `Button` component, and some extensions of it like `Link` are intended * to be generic and therefore apply no styles by default. These `VariantProps` * are what control the `Button`'s presentation, and are intended only use cases where the buttons appear as, well, buttons. * * If `Button` or an extension of it are used for other compound components, use this property to avoid misuse of these variant props further down the line. * * @example * type MyComponentProps = Omit<ButtonProps, UninheritableButtonProps> & {...} */export type UninheritableButtonProps = 'variant' | 'color' | 'size' | 'shape'
export type ButtonVariant = 'solid' | 'outline' | 'ghost'export type ButtonColor = | 'primary' | 'secondary' | 'secondary_inverted' | 'negative' | 'primary_subtle' | 'negative_subtle'export type ButtonSize = 'tiny' | 'small' | 'large'export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'export type VariantProps = { /** * The style variation of the button * @deprecated Use `color` instead. */ variant?: ButtonVariant /** * The color of the button */ color?: ButtonColor /** * The size of the button */ size?: ButtonSize /** * The shape of the button * * - `default`: Pill shaped. Most buttons should use this shape. * - `round`: Circular. For icon-only buttons. * - `square`: Square. For icon-only buttons. * - `rectangular`: Rectangular. Matches previous style, use when adjacent to form fields. */ shape?: ButtonShape}
export type ButtonState = { hovered: boolean focused: boolean pressed: boolean disabled: boolean}
export type ButtonContext = VariantProps & ButtonState
type NonTextElements = | React.ReactElement<any> | Iterable<React.ReactElement<any> | 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; emoji?: boolean}
const Context = React.createContext<VariantProps & ButtonState>({ hovered: false, focused: false, pressed: false, disabled: false,})Context.displayName = 'ButtonContext'
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, ) => { /** * The `variant` prop is deprecated in favor of simply specifying `color`. * If a `color` is set, then we want to use the existing codepaths for * "solid" buttons. This is to maintain backwards compatibility. */ if (!variant && color) { variant = 'solid' }
const enableSquareButtons = useEnableSquareButtons()
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[] = []
/* * This is the happy path for new button styles, following the * deprecation of `variant` prop. This redundant `variant` check is here * just to make this handling easier to understand. */ if (variant === 'solid') { if (color === 'primary') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.primary_500, }) hoverStyles.push({ backgroundColor: t.palette.primary_600, }) } else { baseStyles.push({ backgroundColor: t.palette.primary_200, }) } } else if (color === 'secondary') { if (!disabled) { baseStyles.push(t.atoms.bg_contrast_50) hoverStyles.push(t.atoms.bg_contrast_100) } else { baseStyles.push(t.atoms.bg_contrast_50) } } else if (color === 'secondary_inverted') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.contrast_900, }) hoverStyles.push({ backgroundColor: t.palette.contrast_975, }) } else { baseStyles.push({ backgroundColor: t.palette.contrast_600, }) } } else if (color === 'negative') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.negative_500, }) hoverStyles.push({ backgroundColor: t.palette.negative_600, }) } else { baseStyles.push({ backgroundColor: t.palette.negative_700, }) } } else if (color === 'primary_subtle') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.primary_50, }) hoverStyles.push({ backgroundColor: t.palette.primary_100, }) } else { baseStyles.push({ backgroundColor: t.palette.primary_50, }) } } else if (color === 'negative_subtle') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.negative_50, }) hoverStyles.push({ backgroundColor: t.palette.negative_100, }) } else { baseStyles.push({ backgroundColor: t.palette.negative_50, }) } } } else { /* * BEGIN DEPRECATED STYLES */ if (color === 'primary') { 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 === '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_50, }) } } } else if (color === 'secondary_inverted') { 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_50, }) } } } else if (color === 'negative') { 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_subtle') { 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, }) } } } /* * END DEPRECATED STYLES */ }
if (shape === 'default') { if (size === 'large') { baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full, { paddingVertical: 12, paddingHorizontal: 24, gap: 6, }) } else if (size === 'small') { baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full, { paddingVertical: 8, paddingHorizontal: 14, gap: 5, }) } else if (size === 'tiny') { baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full, { paddingVertical: 5, paddingHorizontal: 10, gap: 3, }) } } else if (shape === 'rectangular') { if (size === 'large') { baseStyles.push({ paddingVertical: 12, paddingHorizontal: 25, borderRadius: 10, gap: 3, }) } else if (size === 'small') { baseStyles.push({ paddingVertical: 8, paddingHorizontal: 13, borderRadius: 8, gap: 3, }) } else if (size === 'tiny') { baseStyles.push({ paddingVertical: 5, paddingHorizontal: 9, borderRadius: 6, gap: 2, }) } } else if (shape === 'round' || shape === 'square') { /* * These sizes match the actual rendered size on screen, based on * Chrome's web inspector */ if (size === 'large') { if (shape === 'round') { baseStyles.push({height: 44, width: 44}) } else { baseStyles.push({height: 44, width: 44}) } } else if (size === 'small') { if (shape === 'round') { baseStyles.push({height: 33, width: 33}) } else { baseStyles.push({height: 33, width: 33}) } } else if (size === 'tiny') { if (shape === 'round') { baseStyles.push({height: 25, width: 25}) } else { baseStyles.push({height: 25, width: 25}) } }
if (shape === 'round') { baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full) } else if (shape === 'square') { if (size === 'tiny') { baseStyles.push({ borderRadius: 6, }) } else { baseStyles.push(a.rounded_sm) } } }
return { baseStyles, hoverStyles, } }, [t, variant, color, size, shape, disabled, enableSquareButtons])
const context = React.useMemo<ButtonContext>( () => ({ ...state, variant, color, size, shape, disabled: disabled || false, }), [state, variant, color, size, shape, disabled], )
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, a.curve_continuous, baseStyles, style, ...(state.hovered || state.pressed ? [hoverStyles, hoverStyleProp] : []), ]} onPressIn={onPressIn} onPressOut={onPressOut} onHoverIn={onHoverIn} onHoverOut={onHoverOut} onFocus={onFocus} onBlur={onBlur}> <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[] = []
/* * This is the happy path for new button styles, following the * deprecation of `variant` prop. This redundant `variant` check is here * just to make this handling easier to understand. */ if (variant === 'solid') { if (color === 'primary') { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({ color: select(t.name, { light: t.palette.white, dim: t.atoms.text_inverted.color, dark: t.atoms.text_inverted.color, }), }) } } else if (color === 'secondary') { if (!disabled) { baseStyles.push(t.atoms.text_contrast_medium) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } else if (color === 'secondary_inverted') { if (!disabled) { baseStyles.push(t.atoms.text_inverted) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } else if (color === 'negative') { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({color: t.palette.negative_300}) } } else if (color === 'primary_subtle') { if (!disabled) { baseStyles.push({ color: t.palette.primary_600, }) } else { baseStyles.push({ color: t.palette.primary_200, }) } } else if (color === 'negative_subtle') { if (!disabled) { baseStyles.push({ color: t.palette.negative_600, }) } else { baseStyles.push({ color: t.palette.negative_200, }) } } } else { /* * BEGIN DEPRECATED STYLES */ if (color === 'primary') { 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 === '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 === '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 === '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_subtle') { 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}) } } } /* * END DEPRECATED STYLES */ }
if (size === 'large') { baseStyles.push(a.text_md, a.leading_snug, a.font_medium) } else if (size === 'small') { baseStyles.push(a.text_sm, a.leading_snug, a.font_medium) } else if (size === 'tiny') { baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold) }
return flatten(baseStyles) }, [t, variant, color, size, disabled])}
export function ButtonText({children, style, ...rest}: ButtonTextProps) { const textStyles = useSharedButtonTextStyles()
return ( <Text {...rest} style={[a.text_center, textStyles, style]}> {children} </Text> )}
export function ButtonIcon({ icon: Comp, size,}: { icon: React.ComponentType<SVGIconProps> /** * @deprecated no longer needed */ position?: 'left' | 'right' size?: SVGIconProps['size']}) { const {size: buttonSize, shape: buttonShape} = useButtonContext() const textStyles = useSharedButtonTextStyles() const {iconSize, iconContainerSize, iconNegativeMargin} = React.useMemo(() => { /** * Pre-set icon sizes for different button sizes */ const iconSizeShorthand = size ?? (({ large: 'md', 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: 18, lg: 24, xl: 28, '2xs': 8, '2xl': 32, '3xl': 40, }[iconSizeShorthand]
/* * Goal here is to match rendered text size so that different size icons * don't increase button size */ const iconContainerSize = { large: 20, small: 17, tiny: 15, }[buttonSize || 'small']
/* * The icon needs to be closer to the edge of the button than the text. Therefore * we make the gap slightly too large, and then pull in the sides using negative margins. */ let iconNegativeMargin = 0
if (buttonShape === 'default') { iconNegativeMargin = { large: -2, small: -2, tiny: -1, }[buttonSize || 'small'] }
return { iconSize, iconContainerSize, iconNegativeMargin, } }, [buttonSize, buttonShape, size])
return ( <View style={[ a.z_20, { width: size === '2xs' ? 10 : iconContainerSize, height: iconContainerSize, marginLeft: iconNegativeMargin, marginRight: iconNegativeMargin, }, ]}> <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> )}
export type StackedButtonProps = Omit< ButtonProps, keyof VariantProps | 'children'> & Pick<VariantProps, 'color'> & { children: React.ReactNode icon: React.ComponentType<SVGIconProps> }
export function StackedButton({children, ...props}: StackedButtonProps) { return ( <Button {...props} size="tiny" style={[ a.flex_col, { height: 72, paddingHorizontal: 16, borderRadius: 20, gap: 4, }, props.style, ]}> <StackedButtonInnerText icon={props.icon}> {children} </StackedButtonInnerText> </Button> )}
function StackedButtonInnerText({ children, icon: Icon,}: Pick<StackedButtonProps, 'icon' | 'children'>) { const textStyles = useSharedButtonTextStyles() return ( <> <Icon width={24} fill={textStyles.color} /> <ButtonText>{children}</ButtonText> </> )}