import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
import React, { isValidElement } from "react";
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
export const styles = sortCx({
common: {
root: [
"group relative inline-flex h-max cursor-pointer items-center justify-center whitespace-nowrap outline-brand transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2",
// When button is used within `InputGroup`
"in-data-input-wrapper:shadow-xs in-data-input-wrapper:focus:!z-50 in-data-input-wrapper:in-data-leading:-mr-px in-data-input-wrapper:in-data-leading:rounded-r-none in-data-input-wrapper:in-data-leading:before:rounded-r-none in-data-input-wrapper:in-data-trailing:-ml-px in-data-input-wrapper:in-data-trailing:rounded-l-none in-data-input-wrapper:in-data-trailing:before:rounded-l-none",
// Disabled styles
"disabled:cursor-not-allowed disabled:text-fg-disabled",
// Icon styles
"disabled:*:data-icon:text-fg-disabled_subtle",
// Same as `icon` but for SSR icons that cannot be passed to the client as functions.
"*:data-icon:pointer-events-none *:data-icon:size-5 *:data-icon:shrink-0 *:data-icon:transition-inherit-all",
].join(" "),
icon: "pointer-events-none size-5 shrink-0 transition-inherit-all",
},
sizes: {
sm: {
root: [
"gap-1 rounded-lg px-3 py-2 text-sm font-semibold before:rounded-[7px] data-icon-only:p-2",
"in-data-input-wrapper:px-3.5 in-data-input-wrapper:py-2.5 in-data-input-wrapper:data-icon-only:p-2.5",
].join(" "),
linkRoot: "gap-1",
},
md: {
root: [
"gap-1 rounded-lg px-3.5 py-2.5 text-sm font-semibold before:rounded-[7px] data-icon-only:p-2.5",
"in-data-input-wrapper:gap-1.5 in-data-input-wrapper:px-4 in-data-input-wrapper:text-md in-data-input-wrapper:data-icon-only:p-3",
].join(" "),
linkRoot: "gap-1",
},
lg: {
root: "gap-1.5 rounded-lg px-4 py-2.5 text-md font-semibold before:rounded-[7px] data-icon-only:p-3",
linkRoot: "gap-1.5",
},
xl: {
root: "gap-1.5 rounded-lg px-4.5 py-3 text-md font-semibold before:rounded-[7px] data-icon-only:p-3.5",
linkRoot: "gap-1.5",
},
},
colors: {
primary: {
root: [
"bg-brand-solid text-white shadow-xs-skeumorphic ring-1 ring-transparent ring-inset hover:bg-brand-solid_hover data-loading:bg-brand-solid_hover",
// Inner border gradient
"before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0%",
// Disabled styles
"disabled:bg-disabled disabled:shadow-xs disabled:ring-disabled_subtle",
// Icon styles
"*:data-icon:text-button-primary-icon hover:*:data-icon:text-button-primary-icon_hover",
].join(" "),
},
secondary: {
root: [
"bg-primary text-secondary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-secondary_hover data-loading:bg-primary_hover",
// Disabled styles
"disabled:shadow-xs disabled:ring-disabled_subtle",
// Icon styles
"*:data-icon:text-fg-quaternary hover:*:data-icon:text-fg-quaternary_hover",
].join(" "),
},
tertiary: {
root: [
"text-tertiary hover:bg-primary_hover hover:text-tertiary_hover data-loading:bg-primary_hover",
// Icon styles
"*:data-icon:text-fg-quaternary hover:*:data-icon:text-fg-quaternary_hover",
].join(" "),
},
"link-gray": {
root: [
"justify-normal rounded p-0! text-tertiary hover:text-tertiary_hover",
// Inner text underline
"*:data-text:underline *:data-text:decoration-transparent *:data-text:underline-offset-2 hover:*:data-text:decoration-current",
// Icon styles
"*:data-icon:text-fg-quaternary hover:*:data-icon:text-fg-quaternary_hover",
].join(" "),
},
"link-color": {
root: [
"justify-normal rounded p-0! text-brand-secondary hover:text-brand-secondary_hover",
// Inner text underline
"*:data-text:underline *:data-text:decoration-transparent *:data-text:underline-offset-2 hover:*:data-text:decoration-current",
// Icon styles
"*:data-icon:text-fg-brand-secondary_alt hover:*:data-icon:text-fg-brand-secondary_hover",
].join(" "),
},
"primary-destructive": {
root: [
"bg-error-solid text-white shadow-xs-skeumorphic ring-1 ring-transparent outline-error ring-inset hover:bg-error-solid_hover data-loading:bg-error-solid_hover",
// Inner border gradient
"before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0%",
// Disabled styles
"disabled:bg-disabled disabled:shadow-xs disabled:ring-disabled_subtle",
// Icon styles
"*:data-icon:text-button-destructive-primary-icon hover:*:data-icon:text-button-destructive-primary-icon_hover",
].join(" "),
},
"secondary-destructive": {
root: [
"bg-primary text-error-primary shadow-xs-skeumorphic ring-1 ring-error_subtle outline-error ring-inset hover:bg-error-primary hover:text-error-primary_hover data-loading:bg-error-primary",
// Disabled styles
"disabled:bg-primary disabled:shadow-xs disabled:ring-disabled_subtle",
// Icon styles
"*:data-icon:text-fg-error-secondary hover:*:data-icon:text-fg-error-primary",
].join(" "),
},
"tertiary-destructive": {
root: [
"text-error-primary outline-error hover:bg-error-primary hover:text-error-primary_hover data-loading:bg-error-primary",
// Icon styles
"*:data-icon:text-fg-error-secondary hover:*:data-icon:text-fg-error-primary",
].join(" "),
},
"link-destructive": {
root: [
"justify-normal rounded p-0! text-error-primary outline-error hover:text-error-primary_hover",
// Inner text underline
"*:data-text:underline *:data-text:decoration-transparent *:data-text:underline-offset-2 hover:*:data-text:decoration-current",
// Icon styles
"*:data-icon:text-fg-error-secondary hover:*:data-icon:text-fg-error-primary",
].join(" "),
},
},
});
/**
* Common props shared between button and anchor variants
*/
export interface CommonProps {
/** Disables the button and shows a disabled state */
isDisabled?: boolean;
/** Shows a loading spinner and disables the button */
isLoading?: boolean;
/** The size variant of the button */
size?: keyof typeof styles.sizes;
/** The color variant of the button */
color?: keyof typeof styles.colors;
/** Icon component or element to show before the text */
iconLeading?: FC<{ className?: string }> | ReactNode;
/** Icon component or element to show after the text */
iconTrailing?: FC<{ className?: string }> | ReactNode;
/** Removes horizontal padding from the text content */
noTextPadding?: boolean;
/** When true, keeps the text visible during loading state */
showTextWhileLoading?: boolean;
}
/**
* Props for the button variant (non-link)
*/
export interface ButtonProps extends CommonProps, DetailedHTMLProps, "color" | "slot">, HTMLButtonElement> {
/** Slot name for react-aria component */
slot?: AriaButtonProps["slot"];
}
/**
* Props for the link variant (anchor tag)
*/
interface LinkProps extends CommonProps, DetailedHTMLProps, "color">, HTMLAnchorElement> {
/** Options for the configured client side router. */
routerOptions?: AriaLinkProps["routerOptions"];
}
/** Union type of button and link props */
export type Props = ButtonProps | LinkProps;
export const Button = ({
size = "sm",
color = "primary",
children,
className,
noTextPadding,
iconLeading: IconLeading,
iconTrailing: IconTrailing,
isDisabled: disabled,
isLoading: loading,
showTextWhileLoading,
...otherProps
}: Props) => {
const href = "href" in otherProps ? otherProps.href : undefined;
const Component = href ? AriaLink : AriaButton;
const isIcon = (IconLeading || IconTrailing) && !children;
const isLinkType = ["link-gray", "link-color", "link-destructive"].includes(color);
noTextPadding = isLinkType || noTextPadding;
let props = {};
if (href) {
props = {
...otherProps,
href: disabled ? undefined : href,
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
}
return (
*:not([data-icon=loading]):not([data-text])]:hidden" : "[&>*:not([data-icon=loading])]:invisible"),
className,
)}
>
{/* Leading icon */}
{isValidElement(IconLeading) && IconLeading}
{isReactComponent(IconLeading) && }
{loading && (
)}
{children && (
{children}
)}
{/* Trailing icon */}
{isValidElement(IconTrailing) && IconTrailing}
{isReactComponent(IconTrailing) && }
);
};