diff --git a/src/view/com/Button.tsx b/src/view/com/Button.tsx index d1f70d4aeb..259ae59771 100644 --- a/src/view/com/Button.tsx +++ b/src/view/com/Button.tsx @@ -1,86 +1,47 @@ import React from 'react' -import {Pressable, Text, PressableProps, TextProps} from 'react-native' -import * as tokens from '#/alf/tokens' -import {atoms} from '#/alf' +import { + Pressable, + Text, + PressableProps, + TextProps, + ViewStyle, +} from 'react-native' -export type ButtonType = - | 'primary' - | 'secondary' - | 'tertiary' - | 'positive' - | 'negative' +import {atoms, tokens, web, native} from '#/alf' + +export type ButtonType = 'primary' | 'secondary' | 'negative' export type ButtonSize = 'small' | 'large' export type VariantProps = { type?: ButtonType size?: ButtonSize } -type ButtonState = { - pressed: boolean - hovered: boolean - focused: boolean -} export type ButtonProps = Omit & VariantProps & { children: | ((props: { - state: ButtonState - type?: ButtonType - size?: ButtonSize + state: { + pressed: boolean + hovered: boolean + focused: boolean + } + props: VariantProps & { + disabled?: boolean + } }) => React.ReactNode) | React.ReactNode | string } -export type ButtonTextProps = TextProps & VariantProps - -export function Button({children, style, type, size, ...rest}: ButtonProps) { - const {baseStyles, hoverStyles} = React.useMemo(() => { - const baseStyles = [] - const hoverStyles = [] - - switch (type) { - case 'primary': - baseStyles.push({ - backgroundColor: tokens.color.blue_500, - }) - break - case 'secondary': - baseStyles.push({ - backgroundColor: tokens.color.gray_200, - }) - hoverStyles.push({ - backgroundColor: tokens.color.gray_100, - }) - break - default: - } - - switch (size) { - case 'large': - baseStyles.push( - atoms.py_md, - atoms.px_xl, - atoms.rounded_md, - atoms.gap_sm, - ) - break - case 'small': - baseStyles.push( - atoms.py_sm, - atoms.px_md, - atoms.rounded_sm, - atoms.gap_xs, - ) - break - default: - } - - return { - baseStyles, - hoverStyles, - } - }, [type, size]) +export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} +export function Button({ + children, + style, + type, + size, + disabled = false, + ...rest +}: ButtonProps) { const [state, setState] = React.useState({ pressed: false, hovered: false, @@ -124,10 +85,99 @@ export function Button({children, style, type, size, ...rest}: ButtonProps) { })) }, [setState]) + const {baseStyles, hoverStyles} = React.useMemo(() => { + const baseStyles: ViewStyle[] = [] + const hoverStyles: ViewStyle[] = [] + + switch (type) { + case 'primary': { + if (disabled) { + baseStyles.push({ + backgroundColor: tokens.color.blue_300, + }) + } else { + baseStyles.push({ + backgroundColor: tokens.color.blue_500, + }) + } + break + } + case 'secondary': { + if (disabled) { + baseStyles.push({ + backgroundColor: tokens.color.gray_100, + }) + } else { + baseStyles.push({ + backgroundColor: tokens.color.gray_200, + }) + } + break + } + case 'negative': { + if (disabled) { + baseStyles.push({ + backgroundColor: tokens.color.red_400, + }) + } else { + baseStyles.push({ + backgroundColor: tokens.color.red_500, + }) + } + break + } + default: + } + + switch (size) { + case 'large': { + baseStyles.push( + atoms.py_md, + atoms.px_xl, + atoms.rounded_md, + atoms.gap_sm, + ) + break + } + case 'small': { + baseStyles.push( + atoms.py_sm, + atoms.px_md, + atoms.rounded_sm, + atoms.gap_xs, + ) + break + } + default: + } + + return { + baseStyles, + hoverStyles, + } + }, [type, size, disabled]) + + const childProps = React.useMemo( + () => ({ + state, + props: { + type, + size, + disabled: disabled || false, + }, + }), + [state, type, size, disabled], + ) + return ( [ + disabled={disabled || false} + accessibilityState={{ + disabled: disabled || false, + }} + style={[ atoms.flex_row, atoms.align_center, ...baseStyles, @@ -141,11 +191,11 @@ export function Button({children, style, type, size, ...rest}: ButtonProps) { onFocus={onFocus} onBlur={onBlur}> {typeof children === 'string' ? ( - + {children} ) : typeof children === 'function' ? ( - children({state, type, size}) + children(childProps) ) : ( children )} @@ -158,35 +208,60 @@ export function ButtonText({ style, type, size, + disabled, ...rest }: ButtonTextProps) { const textStyles = React.useMemo(() => { - const base = [] + const baseStyles = [] switch (type) { - case 'primary': - base.push({color: tokens.color.white}) + case 'primary': { + baseStyles.push({color: tokens.color.white}) break - case 'secondary': - base.push({ - color: tokens.color.gray_700, + } + case 'secondary': { + if (disabled) { + baseStyles.push({ + color: tokens.color.gray_500, + }) + } else { + baseStyles.push({ + color: tokens.color.gray_700, + }) + } + break + } + case 'negative': { + baseStyles.push({ + color: tokens.color.white, }) break + } default: } switch (size) { - case 'small': - base.push(atoms.text_sm, {paddingBottom: 1}) + case 'small': { + baseStyles.push( + atoms.text_sm, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) break - case 'large': - base.push(atoms.text_md, {paddingBottom: 1}) + } + case 'large': { + baseStyles.push( + atoms.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) break + } default: } - return base - }, [type, size]) + return baseStyles + }, [type, size, disabled]) return ( & { + style?: StyleProp // only accept text styles on element itself + warnOnMismatchingLabel?: boolean + action?: 'push' | 'replace' | 'navigate' +} & Pick>[0], 'to'> + +/** + * A interactive element that renders as a `` tag on the web. On mobile it + * will translate the `href` to navigator screens and params and dispatch a + * navigation action. + * + * Intended to behave as a web anchor tag. For more complex routing, use a + * `Button`. + */ +export function Link({ + children, + to, + style, + action = 'push', + warnOnMismatchingLabel, + ...rest +}: LinkProps) { + const t = useTheme() + const navigation = useNavigation() + const {href, accessibilityRole} = useLinkProps({ + to: + typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, + }) + const isExternal = isExternalUrl(href) + const {openModal, closeModal} = useModalControls() + const onPress = React.useCallback( + (e: GestureResponderEvent) => { + const label = typeof children === 'string' ? children : '' + const requiresWarning = Boolean( + warnOnMismatchingLabel && + label && + isExternal && + linkRequiresWarning(href, label), + ) + + if (requiresWarning) { + e.preventDefault() + + openModal({ + name: 'link-warning', + text: label, + href: href, + }) + } else { + e.preventDefault() + + if (isExternal) { + Linking.openURL(href) + } else { + /** + * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch + * of @ts-ignore below. + */ + const event = e as any + const isMiddleClick = isWeb && event.button === 1 + const isMetaKey = + isWeb && + (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) + const shouldOpenInNewTab = isMetaKey || isMiddleClick + + if ( + shouldOpenInNewTab || + href.startsWith('http') || + href.startsWith('mailto') + ) { + Linking.openURL(href) + } else { + closeModal() // close any active modals + + if (action === 'push') { + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } else if (action === 'replace') { + navigation.dispatch( + StackActions.replace(...router.matchPath(href)), + ) + } else if (action === 'navigate') { + // @ts-ignore + navigation.navigate(...router.matchPath(href)) + } else { + throw Error('Unsupported navigator action.') + } + } + } + } + }, + [ + href, + isExternal, + warnOnMismatchingLabel, + navigation, + action, + children, + closeModal, + openModal, + ], + ) + + return ( + + ) +} diff --git a/src/view/screens/DebugNew.tsx b/src/view/screens/DebugNew.tsx index 0b7c5f03bc..b687a0999a 100644 --- a/src/view/screens/DebugNew.tsx +++ b/src/view/screens/DebugNew.tsx @@ -7,6 +7,7 @@ import {useSetColorMode} from '#/state/shell' import * as tokens from '#/alf/tokens' import {atoms as a, useTheme, useBreakpoints, ThemeProvider as Alf} from '#/alf' import {Button, ButtonText} from '#/view/com/Button' +import {Link} from '#/view/com/Link' import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography' function ThemeSelector() { @@ -142,6 +143,75 @@ function ThemedSection() { ) } +export function Buttons() { + const t = useTheme() + + return ( + + + + + + + + + + + + + + + + + External + + +

External with custom children

+ + + https://blueskyweb.xyz + + + Internal + + + + {({props}) => Link as a button} + +
+ ) +} + export function DebugScreen() { const t = useTheme() @@ -151,6 +221,8 @@ export function DebugScreen() { + + @@ -173,49 +245,6 @@ export function DebugScreen() { atoms.text_xs atoms.text_xxs - - - - - - - - - - - - -