import { getFontSize } from '@tamagui/font-size'
import { getButtonSized } from '@tamagui/get-button-sized'
import { getIcon, useCurrentColor } from '@tamagui/helpers-tamagui'
import { ButtonNestingContext, getElevation, themeableVariants } from '@tamagui/stacks'
import { SizableText, wrapChildrenInText } from '@tamagui/text'
import type { ColorTokens, GetProps, SizeTokens, Token } from '@tamagui/web'
import {
createStyledContext,
getTokenValue,
styled,
useProps,
View,
withStaticProperties,
} from '@tamagui/web'
import type { FunctionComponent, JSX } from 'react'
import { useContext } from 'react'
type ButtonVariant = 'outlined'
const context = createStyledContext<{
size?: SizeTokens
variant?: ButtonVariant
color?: ColorTokens | string
elevation?: SizeTokens | number
}>({
size: undefined,
variant: undefined,
color: undefined,
elevation: undefined,
})
const Frame = styled(View, {
context,
name: 'Button',
role: 'button',
render: ,
tabIndex: 0,
variants: {
unstyled: {
false: {
size: '$true',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'nowrap',
flexDirection: 'row',
cursor: 'pointer',
backgroundColor: '$background',
borderWidth: 1,
borderColor: 'transparent',
hoverStyle: {
backgroundColor: '$backgroundHover',
borderColor: '$borderColorHover',
},
pressStyle: {
backgroundColor: '$backgroundPress',
borderColor: '$borderColorHover',
},
focusVisibleStyle: {
outlineColor: '$outlineColor',
outlineStyle: 'solid',
outlineWidth: 2,
},
},
},
variant: {
outlined:
process.env.TAMAGUI_HEADLESS === '1'
? {}
: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '$borderColor',
hoverStyle: {
backgroundColor: 'transparent',
borderColor: '$borderColorHover',
},
pressStyle: {
backgroundColor: 'transparent',
borderColor: '$borderColorPress',
},
},
},
circular: themeableVariants.circular,
chromeless: themeableVariants.chromeless,
size: {
'...size': (val, extras) => {
const buttonStyle = getButtonSized(val, extras)
const gap = getTokenValue(val as Token)
return {
...buttonStyle,
gap,
}
},
':number': (val, extras) => {
const buttonStyle = getButtonSized(val, extras)
const gap = val * 0.4
return {
...buttonStyle,
gap,
}
},
},
elevation: {
'...size': getElevation,
':number': getElevation,
},
disabled: {
true: {
pointerEvents: 'none',
// @ts-ignore
'aria-disabled': true,
},
},
} as const,
defaultVariants: {
unstyled: process.env.TAMAGUI_HEADLESS === '1',
},
})
const Text = styled(SizableText, {
context,
variants: {
unstyled: {
false: {
userSelect: 'none',
cursor: 'pointer',
// flexGrow 1 leads to inconsistent native style where text pushes to start of view
flexGrow: 0,
flexShrink: 1,
ellipsis: true,
color: '$color',
},
},
} as const,
defaultVariants: {
unstyled: process.env.TAMAGUI_HEADLESS === '1',
},
})
const Icon = (props: {
children: React.ReactNode
scaleIcon?: number
size?: SizeTokens
}) => {
const { children, scaleIcon = 1, size } = props
const styledContext = context.useStyledContext()
if (!styledContext) {
throw new Error('Button.Icon must be used within a Button')
}
const sizeToken = size ?? styledContext.size
const iconColor = useCurrentColor(styledContext.color)
const iconSize =
(typeof sizeToken === 'number' ? sizeToken * 0.5 : getFontSize(sizeToken as Token)) *
scaleIcon
return getIcon(children, {
size: iconSize,
color: iconColor,
})
}
export const ButtonContext = createStyledContext<{
size?: SizeTokens
variant?: ButtonVariant
color?: ColorTokens | string
}>({
size: undefined,
variant: undefined,
color: undefined,
})
type IconProp = JSX.Element | FunctionComponent<{ color?: any; size?: any }> | null
type ButtonExtraProps = {
icon?: IconProp
iconAfter?: IconProp
scaleIcon?: number
iconSize?: SizeTokens
// native button html props
type?: 'submit' | 'reset' | 'button'
form?: string
formAction?: string
formEncType?: string
formMethod?: string
formNoValidate?: boolean
formTarget?: string
name?: string
value?: string | readonly string[] | number
}
const ButtonComponent = Frame.styleable((propsIn, ref) => {
const isNested = useContext(ButtonNestingContext)
// Process props through useProps to expand shorthands (like br -> borderRadius)
const processedProps = useProps(propsIn, {
noNormalize: true,
noExpand: true,
})
const { children, iconSize, icon, iconAfter, scaleIcon = 1, ...props } = processedProps
const size = propsIn.size || (propsIn.unstyled ? undefined : '$true')
const styledContext = context.useStyledContext()
const iconColor = useCurrentColor(styledContext?.color)
const finalSize = iconSize ?? size ?? styledContext?.size
const iconSizeNumber =
(typeof finalSize === 'number' ? finalSize * 0.5 : getFontSize(finalSize as Token)) *
scaleIcon
const [themedIcon, themedIconAfter] = [icon, iconAfter].map((icon) => {
if (!icon) return null
return getIcon(icon, {
size: iconSizeNumber,
color: iconColor,
// No marginLeft or marginRight needed - spacing is handled by the gap property in Frame's size variants
})
})
const wrappedChildren = wrapChildrenInText(
Text,
{ children },
{
unstyled: process.env.TAMAGUI_HEADLESS === '1',
size: finalSize ?? styledContext?.size,
}
)
return (
{themedIcon}
{wrappedChildren}
{themedIconAfter}
)
})
export const Button = withStaticProperties(ButtonComponent, {
Apply: context.Provider,
Frame,
Text,
Icon,
})
export type ButtonProps = GetProps