Add button and link components

This commit is contained in:
Eric Bailey
2024-01-09 14:23:18 -06:00
parent 6ee1aedf80
commit 3e055025c7
3 changed files with 390 additions and 124 deletions
+156 -81
View File
@@ -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<PressableProps, 'children'> &
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 (
<Pressable
role="button"
{...rest}
style={state => [
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' ? (
<ButtonText type={type} size={size}>
<ButtonText type={type} size={size} disabled={disabled || false}>
{children}
</ButtonText>
) : 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 (
<Text
+162
View File
@@ -0,0 +1,162 @@
import React from 'react'
import {
Text,
TextStyle,
StyleProp,
GestureResponderEvent,
Linking,
} from 'react-native'
import {
useLinkProps,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {isWeb} from '#/platform/detection'
import {useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/view/com/Button'
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {
convertBskyAppUrlIfNeeded,
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {router} from '#/routes'
export type LinkProps = Omit<ButtonProps, 'style' | 'onPress' | 'disabled'> & {
style?: StyleProp<TextStyle> // only accept text styles on element itself
warnOnMismatchingLabel?: boolean
action?: 'push' | 'replace' | 'navigate'
} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
/**
* A interactive element that renders as a `<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<NavigationProp>()
const {href, accessibilityRole} = useLinkProps<AllNavigatorParams>({
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 (
<Button
role="link"
accessibilityRole={accessibilityRole}
{...rest}
href={href}
onPress={onPress}
{...web({
target: isExternal ? '_blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
dataSet: {
// default to no underline, apply this ourselves
noUnderline: '1',
},
})}>
{typeof children === 'string'
? ({state}) => (
<Text
style={[
style,
{color: t.palette.primary},
state.hovered && {
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary,
},
]}>
{children as string}
</Text>
)
: children}
</Button>
)
}
+72 -43
View File
@@ -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 (
<View style={[a.gap_md, a.align_start]}>
<Button>Unstyled button</Button>
<Button>
{({state}) => (
<View style={[a.p_md, a.rounded_full, t.atoms.bg_contrast_300]}>
<Text>Entirely custom button, state: {JSON.stringify(state)}</Text>
</View>
)}
</Button>
<Button type="primary" size="large">
Default button
</Button>
<Button type="primary" size="large" disabled>
Default button (disabled)
</Button>
<Button type="primary" size="large">
{({props}) => (
<>
<FontAwesomeIcon icon={['fas', 'plus']} size={12} />
<ButtonText {...props}>Default with an icon</ButtonText>
</>
)}
</Button>
<Button type="secondary" size="small">
Small button
</Button>
<Button type="secondary" size="small" disabled>
Small button (disabled)
</Button>
<Link
to="https://blueskyweb.xyz"
warnOnMismatchingLabel
style={[a.text_md]}>
External
</Link>
<Link to="https://blueskyweb.xyz" style={[a.text_md]}>
<H3>External with custom children</H3>
</Link>
<Link
to="https://blueskyweb.xyz"
warnOnMismatchingLabel
style={[a.text_md]}>
https://blueskyweb.xyz
</Link>
<Link
to="https://bsky.app/profile/bsky.app"
warnOnMismatchingLabel
style={[a.text_md]}>
Internal
</Link>
<Link type="primary" size="large" to="https://bsky.app/profile/bsky.app">
{({props}) => <ButtonText {...props}>Link as a button</ButtonText>}
</Link>
</View>
)
}
export function DebugScreen() {
const t = useTheme()
@@ -151,6 +221,8 @@ export function DebugScreen() {
<View style={[a.p_xl, a.gap_xxl, {paddingBottom: 200}]}>
<ThemeSelector />
<Buttons />
<Alf theme="light">
<ThemedSection />
</Alf>
@@ -173,49 +245,6 @@ export function DebugScreen() {
<Text style={[a.text_xs]}>atoms.text_xs</Text>
<Text style={[a.text_xxs]}>atoms.text_xxs</Text>
<View style={[a.gap_md, a.align_start]}>
<Button>
{({state}) => (
<View style={[a.p_md, a.rounded_full, t.atoms.bg_contrast_300]}>
<Text>Unstyled button, state: {JSON.stringify(state)}</Text>
</View>
)}
</Button>
<Button type="primary" size="small">
Button
</Button>
<Button type="secondary" size="small">
Button
</Button>
<Button type="primary" size="large">
Button
</Button>
<Button type="secondary" size="large">
Button
</Button>
<Button type="secondary" size="small">
{({type, size}) => (
<>
<FontAwesomeIcon icon={['fas', 'plus']} size={12} />
<ButtonText type={type} size={size}>
With an icon
</ButtonText>
</>
)}
</Button>
<Button type="primary" size="large">
{({state: _state, ...rest}) => (
<>
<FontAwesomeIcon icon={['fas', 'plus']} />
<ButtonText {...rest}>With an icon</ButtonText>
</>
)}
</Button>
</View>
<View style={[a.gap_md]}>
<View style={[a.flex_row, a.gap_md]}>
<View