@patreon/studio
Version:
Patreon Studio Design System
149 lines (148 loc) • 7.11 kB
JSX
'use client';
import cx from 'classnames';
import React, { useMemo } from 'react';
import { LoadingSpinner } from '~/components/LoadingSpinner';
import { useLogger } from '~/hooks/useLogger';
import { classNameForBodyText } from '~/styles/classNameForBodyText';
import devWarn from '~/utilities/dev-warn';
import { mapResponsive, wrapResponsive } from '~/utilities/opaque-responsive';
import { classNameForResponsiveValue, createResponsiveClassNameLookup } from '~/utilities/responsive-style';
import { getIconSize, getSpinnerColor } from './theme';
import styles from './Button.module.css';
const classNameFluidLookup = createResponsiveClassNameLookup(styles, {
true: 'fluidSet',
false: 'fluidUnset',
});
const useButtonClassList = ({ variant, size, unfilled, isLoading, disabled, textAlign, weight, padding, corners, fluid, className, hasChildren, hasIcon, hasDisclosureIcon, }) => {
return useMemo(() => {
const responsiveFluid = mapResponsive(wrapResponsive(fluid), (value) => (value ? 'true' : 'false'));
return cx(styles.root, {
[styles.themePrimary]: variant === 'primary',
[styles.themeSecondary]: variant === 'secondary',
[styles.themeTertiary]: variant === 'tertiary',
[styles.themeCritical]: variant === 'critical',
[styles.themeFloating]: variant === 'floating',
[styles.themeInsetWhite]: variant === 'insetWhite',
[styles.themeInsetBlack]: variant === 'insetBlack',
[styles.sizeXs]: size === 'xs',
[styles.sizeSm]: size === 'sm',
[styles.sizeMd]: size === 'md',
[styles.sizeLg]: size === 'lg',
[styles.cornersPill]: corners === 'pill',
[styles.hasDimensions]: padding === 'default',
[styles.variantIconOnly]: hasIcon && !hasDisclosureIcon && !hasChildren,
[styles.variantLabelOnly]: !hasIcon && !hasDisclosureIcon && hasChildren,
[styles.variantLabelAndIcon]: !hasDisclosureIcon && hasChildren && hasIcon,
[styles.variantDisclosureAndLabel]: !hasIcon && hasDisclosureIcon && hasChildren,
[styles.variantIconAndDisclosureOnly]: hasIcon && hasDisclosureIcon && !hasChildren,
[styles.variantIconLabelAndDisclosure]: hasIcon && hasDisclosureIcon && hasChildren,
[styles.hasTextAlign]: textAlign !== undefined,
[styles.textAlignLeft]: textAlign === 'left',
[styles.textAlignRight]: textAlign === 'right',
[styles.isFilled]: !unfilled,
[styles.isUnfilled]: unfilled,
[styles.isActive]: !(disabled || isLoading),
[styles.isInactive]: disabled || isLoading,
[styles.isLoading]: isLoading,
}, classNameForResponsiveValue(responsiveFluid, classNameFluidLookup), classNameForBodyText({ size: size === 'xs' ? 'sm' : size, weight }), className);
}, [
fluid,
variant,
size,
corners,
padding,
hasIcon,
hasDisclosureIcon,
hasChildren,
textAlign,
unfilled,
disabled,
isLoading,
weight,
className,
]);
};
export const Button = React.forwardRef(function Button({ variant = 'secondary', unfilled = false, children, size = 'md', disabled = false, onClick, icon, fluid = false, iconProps, isLoading = false, disclosureIcon, href, target, type = 'button', corners = 'rounded', padding: userPadding, textAlign, weight = 'bold', className, style, loggerId, loggerProps, id, 'data-tag': dataTag, ...props // aria props
}, ref) {
const log = useLogger();
const hasChildren = !!children;
const hasIcon = !!icon;
const hasDisclosureIcon = !!disclosureIcon;
const iconSize = getIconSize(size);
const LeftIcon = icon;
const RightIcon = disclosureIcon;
// we're not setting a default value for padding in props because we want to
// check if the user has passed in a value for padding with a button using
// a label. If they have, we want to warn them that they should not use
// padding with a button using a label.
const iconOnly = hasIcon && !hasDisclosureIcon && !hasChildren;
const padding = iconOnly ? (userPadding ?? 'default') : 'default';
const classList = useButtonClassList({
children,
icon,
size,
unfilled,
variant,
isLoading,
disabled,
textAlign,
weight,
corners,
padding,
fluid,
className,
hasChildren,
hasIcon,
hasDisclosureIcon,
});
// Ideally we would catch both of these warnings in the type definition, but we extend the
// ButtonProps type which means we can't add a intersection type easily. We should revisit
// this in the future and consider adding a `IconButton` component for icon only buttons
// instead of using allowing button to have so many responsibilities.
if (iconOnly && !props['aria-label']) {
devWarn('`aria-label` must be defined in icon only buttons for accessibility.');
}
const content = (<>
<div className={styles.contentContainer}>
{LeftIcon && <LeftIcon size={iconSize} color="currentColor" {...iconProps}/>}
{children && <div className={styles.labelContainer}>{children}</div>}
{RightIcon && <RightIcon size={iconSize} color="currentColor" {...iconProps}/>}
</div>
{isLoading && (<div className={styles.loadingWrapper}>
<LoadingSpinner color={getSpinnerColor({ variant, unfilled })} size={size === 'lg' ? 'sm' : 'xs'}/>
</div>)}
</>);
if (href || target) {
const computedHref = isLoading || disabled ? undefined : (href ?? '#');
return (<a onClick={(e) => {
if (disabled) {
e.preventDefault();
return;
}
if (computedHref?.length && e.target instanceof HTMLElement) {
e.target.style.cursor = 'default';
}
log('buttonClick', loggerId, loggerProps);
onClick?.(e);
}} onMouseDown={(e) => {
if (computedHref?.length && e.target instanceof HTMLElement) {
e.target.style.cursor = 'default';
}
e.preventDefault();
}} aria-disabled={disabled} ref={ref} rel={target === '_blank' ? 'noopener noreferrer' : undefined} role={disabled ? 'link' : undefined} href={computedHref} target={target} className={classList} style={style} id={id} data-tag={dataTag} {...props}>
{content}
</a>);
}
const onClickOverride = (e) => {
if (isLoading || disabled) {
e.preventDefault();
return;
}
log('buttonClick', loggerId, loggerProps);
onClick?.(e);
};
return (<button onClick={onClickOverride} aria-disabled={disabled} type={type} ref={ref} className={classList} style={style} id={id} data-tag={dataTag} {...props}>
{content}
</button>);
});
//# sourceMappingURL=index.jsx.map