diff options
author | Eric Bailey <git@esb.lol> | 2024-01-18 20:28:04 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-01-18 20:28:04 -0600 |
commit | 66b8774ecb9c5d465987909577ddad3dd4a3ab8e (patch) | |
tree | b1874c6cedd0111eca41db237e606f8e50739d55 /src/components/Button.tsx | |
parent | 9cbd3c0937d22e8dccbd9c086d3a3a24dbd27b3a (diff) | |
download | voidsky-66b8774ecb9c5d465987909577ddad3dd4a3ab8e.tar.zst |
New component library based on ALF (#2459)
* Install on native as well * Add button and link components * Comments * Use new prop * Add some form elements * Add labels to input * Fix line height, add suffix * Date inputs * Autofill styles * Clean up InputDate types * Improve types for InputText, value handling * Enforce a11y props on buttons * Add Dialog, Portal * Dialog contents * Native dialog * Clean up * Fix animations * Improvements to web modal, exiting still broken * Clean up dialog types * Add Prompt, Dialog refinement, mobile refinement * Integrate new design tokens, reorg storybook * Button colors * Dim mode * Reorg * Some styles * Toggles * Improve a11y * Autosize dialog, handle max height, Dialog.ScrolLView not working * Try to use BottomSheet's own APIs * Scrollable dialogs * Add web shadow * Handle overscroll * Styles * Dialog text input * Shadows * Button focus states * Button pressed states * Gradient poc * Gradient colors and hovers * Add hrefAttrs to Link * Some more a11y * Toggle invalid states * Update dialog descriptions for demo * Icons * WIP Toggle cleanup * Refactor toggle to not rely on immediate children * Make Toggle controlled * Clean up Toggles storybook * ToggleButton styles * Improve a11y labels * ToggleButton hover darkmode * Some i18n * Refactor input * Allow extension of input * Remove old input * Improve icons, add CalendarDays * Refactor DateField, web done * Add label example * Clean up old InputDate, DateField android, text area example * Consistent imports * Button context, icons * Add todo * Add closeAllDialogs control * Alignment * Expand color palette * Hitslops, add shortcut to Storybook in dev * Fix multiline on ios * Mark dialog close button as unused
Diffstat (limited to 'src/components/Button.tsx')
-rw-r--r-- | src/components/Button.tsx | 507 |
1 files changed, 507 insertions, 0 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 000000000..d2100f0b4 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,507 @@ +import React from 'react' +import { + Pressable, + Text, + PressableProps, + TextProps, + ViewStyle, + AccessibilityProps, + View, + TextStyle, + StyleSheet, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' + +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 ButtonColor = + | 'primary' + | 'secondary' + | 'negative' + | 'gradient_sky' + | 'gradient_midnight' + | 'gradient_sunrise' + | 'gradient_sunset' + | 'gradient_nordic' + | 'gradient_bonfire' +export type ButtonSize = 'small' | 'large' +export type VariantProps = { + /** + * The style variation of the button + */ + variant?: ButtonVariant + /** + * The color of the button + */ + color?: ButtonColor + /** + * The size of the button + */ + size?: ButtonSize +} + +export type ButtonProps = React.PropsWithChildren< + Pick<PressableProps, 'disabled' | 'onPress'> & + AccessibilityProps & + VariantProps & { + label: string + } +> +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({ + children, + variant, + color, + size, + label, + disabled = false, + ...rest +}: ButtonProps) { + const t = useTheme() + const [state, setState] = React.useState({ + pressed: false, + hovered: false, + focused: false, + }) + + const onPressIn = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: true, + })) + }, [setState]) + const onPressOut = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: false, + })) + }, [setState]) + const onHoverIn = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: true, + })) + }, [setState]) + const onHoverOut = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: false, + })) + }, [setState]) + const onFocus = React.useCallback(() => { + setState(s => ({ + ...s, + focused: true, + })) + }, [setState]) + const onBlur = React.useCallback(() => { + setState(s => ({ + ...s, + focused: false, + })) + }, [setState]) + + const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => { + const baseStyles: ViewStyle[] = [] + const hoverStyles: ViewStyle[] = [] + const light = t.name === 'light' + + if (color === 'primary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.primary_500, + }) + hoverStyles.push({ + backgroundColor: t.palette.primary_600, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.primary_700, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: tokens.color.blue_500, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.primary_50 + : t.palette.primary_950, + }) + } else { + baseStyles.push(a.border, { + borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? t.palette.primary_100 + : t.palette.primary_900, + }) + } + } + } else if (color === 'secondary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: light + ? tokens.color.gray_100 + : tokens.color.gray_900, + }) + hoverStyles.push({ + backgroundColor: light + ? tokens.color.gray_200 + : tokens.color.gray_950, + }) + } else { + baseStyles.push({ + backgroundColor: light + ? tokens.color.gray_300 + : tokens.color.gray_950, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500, + }) + hoverStyles.push(a.border, t.atoms.bg_contrast_50) + } else { + baseStyles.push(a.border, { + borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? tokens.color.gray_100 + : tokens.color.gray_900, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.negative_400, + }) + hoverStyles.push({ + backgroundColor: t.palette.negative_500, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.negative_600, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.negative_400, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.negative_50 + : t.palette.negative_975, + }) + } else { + baseStyles.push(a.border, { + borderColor: light + ? t.palette.negative_200 + : t.palette.negative_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? t.palette.negative_100 + : t.palette.negative_950, + }) + } + } + } + + if (size === 'large') { + baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm) + } else if (size === 'small') { + baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm) + } + + return { + baseStyles, + hoverStyles, + focusStyles: [ + ...hoverStyles, + { + outline: 0, + } as ViewStyle, + ], + } + }, [t, variant, color, size, disabled]) + + const {gradientColors, gradientHoverColors, gradientLocations} = + React.useMemo(() => { + const colors: string[] = [] + const hoverColors: string[] = [] + const locations: number[] = [] + const gradient = { + primary: tokens.gradients.sky, + secondary: tokens.gradients.sky, + negative: tokens.gradients.sky, + gradient_sky: tokens.gradients.sky, + gradient_midnight: tokens.gradients.midnight, + gradient_sunrise: tokens.gradients.sunrise, + gradient_sunset: tokens.gradients.sunset, + gradient_nordic: tokens.gradients.nordic, + gradient_bonfire: tokens.gradients.bonfire, + }[color || 'primary'] + + if (variant === 'gradient') { + colors.push(...gradient.values.map(([_, color]) => color)) + hoverColors.push(...gradient.values.map(_ => gradient.hover_value)) + locations.push(...gradient.values.map(([location, _]) => location)) + } + + return { + gradientColors: colors, + gradientHoverColors: hoverColors, + gradientLocations: locations, + } + }, [variant, color]) + + const context = React.useMemo( + () => ({ + ...state, + variant, + color, + size, + disabled: disabled || false, + }), + [state, variant, color, size, disabled], + ) + + return ( + <Pressable + role="button" + accessibilityHint={undefined} // optional + {...rest} + aria-label={label} + aria-pressed={state.pressed} + accessibilityLabel={label} + disabled={disabled || false} + accessibilityState={{ + disabled: disabled || false, + }} + style={[ + a.flex_row, + a.align_center, + a.overflow_hidden, + ...baseStyles, + ...(state.hovered || state.pressed ? hoverStyles : []), + ...(state.focused ? focusStyles : []), + ]} + onPressIn={onPressIn} + onPressOut={onPressOut} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} + onFocus={onFocus} + onBlur={onBlur}> + {variant === 'gradient' && ( + <LinearGradient + colors={ + state.hovered || state.pressed || state.focused + ? gradientHoverColors + : gradientColors + } + locations={gradientLocations} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[a.absolute, a.inset_0]} + /> + )} + <Context.Provider value={context}> + {typeof children === 'string' ? ( + <ButtonText>{children}</ButtonText> + ) : ( + children + )} + </Context.Provider> + </Pressable> + ) +} + +export function useSharedButtonTextStyles() { + const t = useTheme() + const {color, variant, disabled, size} = useButtonContext() + return React.useMemo(() => { + const baseStyles: TextStyle[] = [] + const light = t.name === 'light' + + if (color === 'primary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({ + color: light ? t.palette.primary_600 : t.palette.primary_500, + }) + } 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 === 'solid' || variant === 'gradient') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_700 : tokens.color.gray_100, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_700, + }) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_600 : tokens.color.gray_300, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_700, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_600 : tokens.color.gray_300, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_600, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid' || variant === 'gradient') { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } else 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 (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } + + if (size === 'large') { + baseStyles.push( + a.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } else { + baseStyles.push( + a.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } + + return StyleSheet.flatten(baseStyles) + }, [t, variant, color, size, disabled]) +} + +export function ButtonText({children, style, ...rest}: ButtonTextProps) { + const textStyles = useSharedButtonTextStyles() + + return ( + <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> + {children} + </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> + ) +} |