Button context, icons

This commit is contained in:
Eric Bailey
2024-01-18 15:12:42 -06:00
parent 1514ffd6e9
commit d6925a00d7
4 changed files with 172 additions and 121 deletions
+91 -86
View File
@@ -6,10 +6,14 @@ import {
TextProps, TextProps,
ViewStyle, ViewStyle,
AccessibilityProps, AccessibilityProps,
View,
TextStyle,
StyleSheet,
} from 'react-native' } from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {useTheme, atoms, tokens, web, native} from '#/alf' import {useTheme, atoms as a, tokens, web, native} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor = export type ButtonColor =
@@ -38,26 +42,33 @@ export type VariantProps = {
size?: ButtonSize size?: ButtonSize
} }
export type ButtonProps = Pick<PressableProps, 'disabled' | 'onPress'> & export type ButtonProps = React.PropsWithChildren<
AccessibilityProps & Pick<PressableProps, 'disabled' | 'onPress'> &
VariantProps & { AccessibilityProps &
children: VariantProps & {
| ((props: { label: string
state: { }
pressed: boolean >
hovered: boolean
focused: boolean
}
props: VariantProps & {
disabled?: boolean
}
}) => React.ReactNode)
| React.ReactNode
| string
label: string
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext<
VariantProps & {
hovered: boolean
focused: boolean
pressed: boolean
disabled: boolean
}
>({
hovered: false,
focused: false,
pressed: false,
disabled: false,
})
export function useButtonContext() {
return React.useContext(Context)
}
export function Button({ export function Button({
children, children,
variant, variant,
@@ -131,21 +142,21 @@ export function Button({
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
baseStyles.push(atoms.border, t.atoms.bg, { baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1, borderWidth: 1,
}) })
if (!disabled) { if (!disabled) {
baseStyles.push(atoms.border, { baseStyles.push(a.border, {
borderColor: tokens.color.blue_500, borderColor: tokens.color.blue_500,
}) })
hoverStyles.push(atoms.border, { hoverStyles.push(a.border, {
backgroundColor: light backgroundColor: light
? t.palette.primary_100 ? t.palette.primary_100
: t.palette.primary_900, : t.palette.primary_900,
}) })
} else { } else {
baseStyles.push(atoms.border, { baseStyles.push(a.border, {
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900, borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
}) })
} }
@@ -180,17 +191,17 @@ export function Button({
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
baseStyles.push(atoms.border, t.atoms.bg, { baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1, borderWidth: 1,
}) })
if (!disabled) { if (!disabled) {
baseStyles.push(atoms.border, { baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500, borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
}) })
hoverStyles.push(atoms.border, t.atoms.bg_contrast_50) hoverStyles.push(a.border, t.atoms.bg_contrast_50)
} else { } else {
baseStyles.push(atoms.border, { baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800, borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
}) })
} }
@@ -219,19 +230,19 @@ export function Button({
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
baseStyles.push(atoms.border, t.atoms.bg, { baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1, borderWidth: 1,
}) })
if (!disabled) { if (!disabled) {
baseStyles.push(atoms.border, { baseStyles.push(a.border, {
borderColor: t.palette.negative_600, borderColor: t.palette.negative_600,
}) })
hoverStyles.push(atoms.border, { hoverStyles.push(a.border, {
backgroundColor: light ? t.palette.negative_50 : '#2D0614', // darker red backgroundColor: light ? t.palette.negative_50 : '#2D0614', // darker red
}) })
} else { } else {
baseStyles.push(atoms.border, { baseStyles.push(a.border, {
borderColor: light borderColor: light
? t.palette.negative_200 ? t.palette.negative_200
: t.palette.negative_900, : t.palette.negative_900,
@@ -248,19 +259,9 @@ export function Button({
} }
if (size === 'large') { if (size === 'large') {
baseStyles.push( baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm)
{paddingVertical: 15},
atoms.px_2xl,
atoms.rounded_sm,
atoms.gap_sm,
)
} else if (size === 'small') { } else if (size === 'small') {
baseStyles.push( baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm)
{paddingVertical: 9},
atoms.px_md,
atoms.rounded_sm,
atoms.gap_xs,
)
} }
return { return {
@@ -305,15 +306,13 @@ export function Button({
} }
}, [variant, color]) }, [variant, color])
const childProps = React.useMemo( const context = React.useMemo(
() => ({ () => ({
state, ...state,
props: { variant,
variant, color,
color, size,
size, disabled: disabled || false,
disabled: disabled || false,
},
}), }),
[state, variant, color, size, disabled], [state, variant, color, size, disabled],
) )
@@ -331,9 +330,9 @@ export function Button({
disabled: disabled || false, disabled: disabled || false,
}} }}
style={[ style={[
atoms.flex_row, a.flex_row,
atoms.align_center, a.align_center,
atoms.overflow_hidden, a.overflow_hidden,
...baseStyles, ...baseStyles,
...(state.hovered || state.pressed ? hoverStyles : []), ...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []), ...(state.focused ? focusStyles : []),
@@ -354,39 +353,25 @@ export function Button({
locations={gradientLocations} locations={gradientLocations}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}
end={{x: 1, y: 1}} end={{x: 1, y: 1}}
style={[atoms.absolute, atoms.inset_0]} style={[a.absolute, a.inset_0]}
/> />
)} )}
{typeof children === 'string' ? ( <Context.Provider value={context}>
<ButtonText {typeof children === 'string' ? (
variant={variant} <ButtonText>{children}</ButtonText>
color={color} ) : (
size={size} children
disabled={disabled || false}> )}
{children} </Context.Provider>
</ButtonText>
) : typeof children === 'function' ? (
children(childProps)
) : (
children
)}
</Pressable> </Pressable>
) )
} }
export function ButtonText({ export function useSharedButtonTextStyles() {
children,
style,
variant,
color,
size,
disabled,
...rest
}: ButtonTextProps) {
const t = useTheme() const t = useTheme()
const {color, variant, disabled, size} = useButtonContext()
const textStyles = React.useMemo(() => { return React.useMemo(() => {
const baseStyles = [] const baseStyles: TextStyle[] = []
const light = t.name === 'light' const light = t.name === 'light'
if (color === 'primary') { if (color === 'primary') {
@@ -473,26 +458,46 @@ export function ButtonText({
if (size === 'large') { if (size === 'large') {
baseStyles.push( baseStyles.push(
atoms.text_md, a.text_md,
web({paddingBottom: 1}), web({paddingBottom: 1}),
native({marginTop: 2}), native({marginTop: 2}),
) )
} else { } else {
baseStyles.push( baseStyles.push(
atoms.text_md, a.text_md,
web({paddingBottom: 1}), web({paddingBottom: 1}),
native({marginTop: 2}), native({marginTop: 2}),
) )
} }
return baseStyles return StyleSheet.flatten(baseStyles)
}, [t, variant, color, size, disabled]) }, [t, variant, color, size, disabled])
}
export function ButtonText({children, style, ...rest}: ButtonTextProps) {
const textStyles = useSharedButtonTextStyles()
return ( return (
<Text <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
{...rest}
style={[atoms.font_bold, atoms.text_center, ...textStyles, style]}>
{children} {children}
</Text> </Text>
) )
} }
export function ButtonIcon({
icon: Comp,
}: {
icon: React.ComponentType<SVGIconProps>
}) {
const {size} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
return (
<View style={[a.z_20]}>
<Comp
size={size === 'large' ? 'md' : 'sm'}
style={[{color: textStyles.color, pointerEvents: 'none'}]}
/>
</View>
)
}
+41 -24
View File
@@ -14,8 +14,8 @@ import {
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useTheme, web} from '#/alf' import {useTheme, web, flatten} from '#/alf'
import {Button, ButtonProps} from '#/components/Button' import {Button, ButtonProps, useButtonContext} from '#/components/Button'
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
@@ -25,7 +25,10 @@ import {
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {router} from '#/routes' import {router} from '#/routes'
export type LinkProps = Omit<ButtonProps, 'style' | 'onPress' | 'disabled'> & { export type LinkProps = Omit<
ButtonProps,
'style' | 'onPress' | 'disabled' | 'label'
> & {
/** /**
* `TextStyle` to apply to the anchor element itself. Does not apply to any children. * `TextStyle` to apply to the anchor element itself. Does not apply to any children.
*/ */
@@ -39,6 +42,7 @@ export type LinkProps = Omit<ButtonProps, 'style' | 'onPress' | 'disabled'> & {
* works for Links with children that are strings i.e. text links. * works for Links with children that are strings i.e. text links.
*/ */
warnOnMismatchingTextChild?: boolean warnOnMismatchingTextChild?: boolean
label?: ButtonProps['label']
} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'> } & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
/** /**
@@ -52,12 +56,11 @@ export type LinkProps = Omit<ButtonProps, 'style' | 'onPress' | 'disabled'> & {
export function Link({ export function Link({
children, children,
to, to,
style,
action = 'push', action = 'push',
warnOnMismatchingTextChild, warnOnMismatchingTextChild,
style,
...rest ...rest
}: LinkProps) { }: LinkProps) {
const t = useTheme()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {href} = useLinkProps<AllNavigatorParams>({ const {href} = useLinkProps<AllNavigatorParams>({
to: to:
@@ -67,12 +70,12 @@ export function Link({
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const onPress = React.useCallback( const onPress = React.useCallback(
(e: GestureResponderEvent) => { (e: GestureResponderEvent) => {
const label = typeof children === 'string' ? children : '' const stringChildren = typeof children === 'string' ? children : ''
const requiresWarning = Boolean( const requiresWarning = Boolean(
warnOnMismatchingTextChild && warnOnMismatchingTextChild &&
label && stringChildren &&
isExternal && isExternal &&
linkRequiresWarning(href, label), linkRequiresWarning(href, stringChildren),
) )
if (requiresWarning) { if (requiresWarning) {
@@ -80,7 +83,7 @@ export function Link({
openModal({ openModal({
name: 'link-warning', name: 'link-warning',
text: label, text: stringChildren,
href: href, href: href,
}) })
} else { } else {
@@ -139,6 +142,7 @@ export function Link({
return ( return (
<Button <Button
label={href}
{...rest} {...rest}
role="link" role="link"
accessibilityRole="link" accessibilityRole="link"
@@ -154,21 +158,34 @@ export function Link({
noUnderline: '1', noUnderline: '1',
}, },
})}> })}>
{typeof children === 'string' {typeof children === 'string' ? (
? ({state}) => ( <LinkText style={style}>{children}</LinkText>
<Text ) : (
style={[ children
style, )}
{color: t.palette.primary_500},
state.hovered && {
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
]}>
{children as string}
</Text>
)
: children}
</Button> </Button>
) )
} }
function LinkText({
children,
style,
}: React.PropsWithChildren<{
style?: StyleProp<TextStyle>
}>) {
const t = useTheme()
const {hovered} = useButtonContext()
return (
<Text
style={[
{color: t.palette.primary_500},
hovered && {
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
flatten(style),
]}>
{children as string}
</Text>
)
}
+36 -1
View File
@@ -2,8 +2,16 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonVariant, ButtonColor} from '#/components/Button' import {
Button,
ButtonVariant,
ButtonColor,
ButtonIcon,
ButtonText,
} from '#/components/Button'
import {H1} from '#/components/Typography' import {H1} from '#/components/Typography'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
export function Buttons() { export function Buttons() {
return ( return (
@@ -83,6 +91,33 @@ export function Buttons() {
)} )}
</View> </View>
</View> </View>
<Button
variant="gradient"
color="gradient_sky"
size="large"
label="Link out">
<ButtonText>Link out</ButtonText>
<ButtonIcon icon={ArrowTopRight} />
</Button>
<Button
variant="gradient"
color="gradient_sky"
size="small"
label="Link out">
<ButtonText>Link out</ButtonText>
<ButtonIcon icon={ArrowTopRight} />
</Button>
<Button
variant="gradient"
color="gradient_sky"
size="small"
label="Link out">
<ButtonIcon icon={Globe} />
<ButtonText>See the world</ButtonText>
</Button>
</View> </View>
</View> </View>
) )
+4 -10
View File
@@ -13,27 +13,21 @@ export function Links() {
<View style={[a.gap_md, a.align_start]}> <View style={[a.gap_md, a.align_start]}>
<Link <Link
label="Click here"
to="https://blueskyweb.xyz" to="https://blueskyweb.xyz"
warnOnMismatchingTextChild warnOnMismatchingTextChild
style={[a.text_md]}> style={[a.text_md]}>
External External
</Link> </Link>
<Link <Link to="https://blueskyweb.xyz" style={[a.text_md]}>
label="Click here"
to="https://blueskyweb.xyz"
style={[a.text_md]}>
<H3>External with custom children</H3> <H3>External with custom children</H3>
</Link> </Link>
<Link <Link
label="Click here"
to="https://blueskyweb.xyz" to="https://blueskyweb.xyz"
warnOnMismatchingTextChild warnOnMismatchingTextChild
style={[a.text_md]}> style={[a.text_lg]}>
https://blueskyweb.xyz https://blueskyweb.xyz
</Link> </Link>
<Link <Link
label="Click here"
to="https://bsky.app/profile/bsky.app" to="https://bsky.app/profile/bsky.app"
warnOnMismatchingTextChild warnOnMismatchingTextChild
style={[a.text_md]}> style={[a.text_md]}>
@@ -41,12 +35,12 @@ export function Links() {
</Link> </Link>
<Link <Link
label="Click here"
variant="solid" variant="solid"
color="primary" color="primary"
size="large" size="large"
label="View @bsky.app's profile"
to="https://bsky.app/profile/bsky.app"> to="https://bsky.app/profile/bsky.app">
{({props}) => <ButtonText {...props}>Link as a button</ButtonText>} <ButtonText>Link as a button</ButtonText>
</Link> </Link>
</View> </View>
</View> </View>