Skip to content

Commit

Permalink
Merge pull request #1526 from Shelf-nu/fix-button-typescript
Browse files Browse the repository at this point in the history
fix: Button component TS
  • Loading branch information
DonKoko authored Dec 17, 2024
2 parents c83e957 + 388cb1c commit e6bce28
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 137 deletions.
8 changes: 7 additions & 1 deletion app/components/layout/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useFetcher } from "@remix-run/react";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";

import { MenuIcon } from "lucide-react";
import Input from "~/components/forms/input";
import { SwitchIcon } from "~/components/icons/library";
import { Button } from "~/components/shared/button";
Expand Down Expand Up @@ -306,7 +307,12 @@ const SidebarTrigger = forwardRef<
}}
{...props}
>
<SwitchIcon className={tw("size-4 text-gray-500", iconClassName)} />
<SwitchIcon
className={tw("hidden size-5 text-gray-500 md:block", iconClassName)}
/>
<MenuIcon
className={tw("block size-6 text-gray-500 md:hidden", iconClassName)}
/>
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
Expand Down
305 changes: 180 additions & 125 deletions app/components/shared/button.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React from "react";
import { ExternalLinkIcon } from "@radix-ui/react-icons";
import { Link } from "@remix-run/react";
import { Link, type LinkProps } from "@remix-run/react";
import { tw } from "~/utils/tw";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./hover-card";
import type { IconType } from "./icons-map";
import Icon from "../icons/icon";
import type { ButtonVariant, ButtonWidth } from "../layout/header/types";

/**
* Type for the disabled prop
* We export it as its being used in other places as well
* Type for the disabled prop that can either be a boolean or an object with additional info
*/
export type DisabledProp =
| boolean
Expand All @@ -18,26 +17,134 @@ export type DisabledProp =
reason: React.ReactNode | string;
};

export interface ButtonProps {
as?: React.ComponentType<any> | string;
/**
* Defines valid button size options
*/
export type ButtonSize = "xs" | "sm" | "md";
/**
* Common button props that all button types share
*/
export interface CommonButtonProps {
className?: string;
variant?: ButtonVariant;
width?: ButtonWidth;
size?: "xs" | "sm" | "md";
size?: ButtonSize;
icon?: IconType;
/** Disabled can be a boolean */
disabled?: DisabledProp;

attachToInput?: boolean;
onlyIconOnMobile?: boolean;
title?: string;
error?: string;
hideErrorText?: boolean;
children?: React.ReactNode;
disabled?: DisabledProp;
label?: string; // Add label here since it's used in BookLink
id?: string; // Add id as an optional prop since some buttons might need it
}

/**
* Props specific to HTML button elements
*/
export interface HTMLButtonProps
extends Omit<CommonButtonProps, "disabled" | "title">,
Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
keyof CommonButtonProps | "disabled"
> {
as?: "button";
to?: never;
disabled?: DisabledProp;
}

/**
* Props specific to Link components
*/
export interface LinkButtonProps
extends CommonButtonProps,
Omit<LinkProps, keyof CommonButtonProps | "disabled"> {
as?: typeof Link;
to: string;
target?: string;
prefetch?: "none" | "intent" | "render" | "viewport";
}

/**
* Props for custom component buttons
*/
export interface CustomComponentButtonProps extends CommonButtonProps {
as: React.ComponentType<any>;
[key: string]: any;
}

/**
* Union type of all possible button prop combinations
*/
export type ButtonProps =
| HTMLButtonProps
| LinkButtonProps
| CustomComponentButtonProps;
/**
* Type guard to check if props are for a Link button
*/
function isLinkProps(props: ButtonProps): props is LinkButtonProps {
return "to" in props;
}

/**
* Style mappings for button variants
*/
const variants: Record<ButtonVariant, string> = {
primary: tw(
`border-primary-400 bg-primary-500 text-white focus:ring-2`,
"disabled:border-primary-300 disabled:bg-primary-300",
"enabled:hover:bg-primary-400"
),
secondary: tw(
`border-gray-300 bg-white text-gray-700`,
"disabled:text-gray-500",
"enabled:hover:bg-gray-50"
),
tertiary: tw(
`border-b border-primary/10 pb-1 leading-none`,
"disabled:text-gray-300"
),
link: tw(
`border-none p-0 text-text-sm font-semibold text-primary-700 hover:text-primary-800`
),
"block-link": tw(
"-mt-1 border-none px-2 py-1 text-[14px] font-normal hover:bg-primary-50 hover:text-primary-600"
),
"block-link-gray": tw(
"-mt-1 border-none px-2 py-1 text-[14px] font-normal hover:bg-gray-50 hover:text-gray-600"
),
danger: tw(
`border-error-600 bg-error-600 text-white focus:ring-2`,
"disabled:border-error-300 disabled:bg-error-300",
"enabled:hover:bg-error-800"
),
};

/**
* Style mappings for button sizes
*/
const sizes: Record<ButtonSize, string> = {
xs: tw("px-2 py-[6px] text-xs"),
sm: tw("px-[14px] py-2"),
md: tw("px-4 py-[10px]"),
};

/**
* Style mappings for button widths
*/
const widths: Record<ButtonWidth, string> = {
auto: "w-auto",
full: "w-full max-w-full",
};

/**
* Button component that supports multiple variants, sizes, and can render as different elements
*/
export const Button = React.forwardRef<HTMLElement, ButtonProps>(
function Button(props: ButtonProps, ref) {
let {
function Button(
{
as = "button",
className,
variant = "primary",
Expand All @@ -50,145 +157,91 @@ export const Button = React.forwardRef<HTMLElement, ButtonProps>(
onlyIconOnMobile,
error,
hideErrorText = false,
target,
} = props;
const Component: React.ComponentType<any> | string = props?.to ? Link : as;
const baseButtonClasses = `inline-flex items-center justify-center rounded font-semibold text-center gap-2 max-w-xl border text-sm box-shadow-xs`;

const variants = {
primary: tw(
`border-primary-400 bg-primary-500 text-white focus:ring-2`,
disabled ? "border-primary-300 bg-primary-300" : "hover:bg-primary-400"
),
secondary: tw(
`border-gray-300 bg-white text-gray-700 `,
disabled ? "text-gray-500" : "hover:bg-gray-50"
),
tertiary: tw(
`border-b border-primary/10 pb-1 leading-none`,
disabled ? "text-gray-300" : ""
),
link: tw(
`border-none p-0 text-text-sm font-semibold text-primary-700 hover:text-primary-800`
),
"block-link": tw(
"-mt-1 border-none px-2 py-1 text-[14px] font-normal hover:bg-primary-50 hover:text-primary-600"
),

"block-link-gray": tw(
"-mt-1 border-none px-2 py-1 text-[14px] font-normal hover:bg-gray-50 hover:text-gray-600"
),
danger: tw(
`border-error-600 bg-error-600 text-white focus:ring-2`,
disabled ? "border-error-300 bg-error-300" : "hover:bg-error-800"
),
};
...props
},
ref
) {
const Component = isLinkProps(props) ? Link : as;
const baseButtonClasses =
"inline-flex items-center justify-center rounded font-semibold text-center gap-2 max-w-xl border text-sm box-shadow-xs";

const sizes = {
xs: tw("px-2 py-[6px] text-xs"),
sm: tw("px-[14px] py-2"),
md: tw("px-4 py-[10px]"),
};
const isDisabled =
typeof disabled === "boolean" ? disabled : disabled !== undefined;
const disabledReason =
typeof disabled === "object" ? disabled.reason : undefined;
const disabledTitle =
typeof disabled === "object" ? disabled.title : undefined;

const widths = {
auto: "w-auto",
full: "w-full max-w-full",
};
// Type guard for checking if props has target property
const hasTarget = (props: ButtonProps): props is LinkButtonProps =>
"target" in props;
const newTab = hasTarget(props) && props.target === "_blank";

const disabledStyles = disabled
? "opacity-50 cursor-not-allowed"
: undefined;
const attachedStyles = attachToInput
? tw(" rounded-l-none border-l-0")
: undefined;
const buttonContent = (
<>
{icon && <Icon icon={icon} />}
{children && (
<span
className={tw(
newTab ? "inline-flex items-center gap-[2px]" : "",
onlyIconOnMobile ? "hidden lg:inline-block" : ""
)}
>
<span>{children}</span>
{newTab && (
<ExternalLinkIcon className="external-link-icon mt-px" />
)}
</span>
)}
</>
);

const finalStyles = tw(
baseButtonClasses,
sizes[size],
variants[variant],
widths[width],
disabledStyles,
attachedStyles,
className,
error
? "border-error-300 focus:border-error-300 focus:ring-error-100"
: ""
sizes[size as ButtonSize], // Type assertion to ensure correct indexing
variants[variant as ButtonVariant],
widths[width as ButtonWidth],
isDisabled && "cursor-not-allowed opacity-50",
attachToInput && "rounded-l-none border-l-0",
error && "border-error-300 focus:border-error-300 focus:ring-error-100",
className
);

const isDisabled =
disabled === undefined // If it is undefined, then it is not disabled
? false
: typeof disabled === "boolean"
? disabled
: true; // If it is an object, then it is disabled
const reason = typeof disabled === "object" ? disabled.reason : "";
const disabledTitle =
typeof disabled === "object" ? disabled.title : undefined;

const newTab = target === "_blank";

if (isDisabled) {
// Render disabled button with hover card
if (isDisabled && disabledReason) {
return (
<HoverCard openDelay={50} closeDelay={50}>
<HoverCardTrigger
className={tw("disabled cursor-not-allowed ")}
asChild
>
<HoverCardTrigger className="disabled cursor-not-allowed" asChild>
<Component
{...props}
className={finalStyles}
onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
}}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
}}
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={(e: React.MouseEvent) => e.preventDefault()}
>
{icon && <Icon icon={icon} />}{" "}
{children ? (
<span
className={onlyIconOnMobile ? "hidden lg:inline-block" : ""}
>
{children}
</span>
) : null}
{buttonContent}
</Component>
</HoverCardTrigger>
{reason && (
<HoverCardContent side="left">
<h5 className="text-left text-[14px]">
{typeof disabledStyles === "string"
? disabledTitle
: "Action disabled"}
</h5>
<p className="text-left text-[14px]">{reason}</p>
</HoverCardContent>
)}
<HoverCardContent side="left">
<h5 className="text-left text-[14px]">
{disabledTitle || "Action disabled"}
</h5>
<p className="text-left text-[14px]">{disabledReason}</p>
</HoverCardContent>
</HoverCard>
);
}

// Render normal button
return (
<>
<Component
{...props}
className={finalStyles}
prefetch={props.to ? (props.prefetch ? "intent" : "none") : "none"}
prefetch={isLinkProps(props) ? props.prefetch ?? "none" : undefined}
ref={ref}
disabled={isDisabled}
>
{icon && <Icon icon={icon} />}{" "}
{children ? (
<span
className={tw(
newTab ? "inline-flex items-center gap-[2px]" : "",
onlyIconOnMobile ? "hidden lg:inline-block" : ""
)}
>
<span>{children}</span>{" "}
{newTab && (
<ExternalLinkIcon className="external-link-icon mt-px" />
)}
</span>
) : null}
{buttonContent}
</Component>
{!hideErrorText && error && (
<div className="text-sm text-error-500">{error}</div>
Expand All @@ -197,3 +250,5 @@ export const Button = React.forwardRef<HTMLElement, ButtonProps>(
);
}
);

Button.displayName = "Button";
Loading

0 comments on commit e6bce28

Please sign in to comment.