2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { LinkProps } from "next/link";
import Link from "next/link";
import React, { forwardRef } from "react";
import classNames from "@calcom/lib/classNames";
import { Icon, type IconName } from "../..";
import { Tooltip } from "../tooltip";
type InferredVariantProps = VariantProps<typeof buttonClasses>;
export type ButtonColor = NonNullable<InferredVariantProps["color"]>;
export type ButtonBaseProps = {
/** Action that happens when the button is clicked */
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
/**Left aligned icon*/
CustomStartIcon?: React.ReactNode;
StartIcon?: IconName;
/**Right aligned icon */
EndIcon?: IconName;
shallow?: boolean;
/**Tool tip used when icon size is set to small */
tooltip?: string | React.ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
tooltipOffset?: number;
disabled?: boolean;
flex?: boolean;
} & Omit<InferredVariantProps, "color"> & {
color?: ButtonColor;
};
export type ButtonProps = ButtonBaseProps &
(
| (Omit<JSX.IntrinsicElements["a"], "href" | "onClick" | "ref"> & LinkProps)
| (Omit<JSX.IntrinsicElements["button"], "onClick" | "ref"> & { href?: never })
);
export const buttonClasses = cva(
"whitespace-nowrap inline-flex items-center text-sm font-medium relative rounded-md transition disabled:cursor-not-allowed",
{
variants: {
variant: {
button: "",
icon: "flex justify-center",
fab: "rounded-full justify-center md:rounded-md radix-state-open:rotate-45 md:radix-state-open:rotate-0 radix-state-open:shadown-none radix-state-open:ring-0 !shadow-none",
},
color: {
primary:
"bg-brand-default hover:bg-brand-emphasis focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-brand-default text-brand disabled:bg-brand-subtle disabled:text-brand-subtle disabled:opacity-40 disabled:hover:bg-brand-subtle disabled:hover:text-brand-default disabled:hover:opacity-40",
secondary:
"text-emphasis border border-default bg-default hover:bg-muted hover:border-emphasis focus-visible:bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-empthasis disabled:border-subtle disabled:bg-opacity-30 disabled:text-muted disabled:hover:bg-opacity-30 disabled:hover:text-muted disabled:hover:border-subtle disabled:hover:bg-default",
minimal:
"text-emphasis hover:bg-subtle focus-visible:bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-empthasis disabled:border-subtle disabled:bg-opacity-30 disabled:text-muted disabled:hover:bg-transparent disabled:hover:text-muted disabled:hover:border-subtle",
destructive:
"border border-default text-emphasis hover:text-red-700 dark:hover:text-red-100 focus-visible:text-red-700 hover:border-red-100 focus-visible:border-red-100 hover:bg-error focus-visible:bg-error focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-red-700 disabled:bg-red-100 disabled:border-red-200 disabled:text-red-700 disabled:hover:border-red-200 disabled:opacity-40",
},
size: {
sm: "px-3 py-2 leading-4 rounded-sm" /** For backwards compatibility */,
base: "h-9 px-4 py-2.5 ",
lg: "h-[36px] px-4 py-2.5 ",
},
loading: {
true: "cursor-wait",
},
},
compoundVariants: [
// Primary variants
{
loading: true,
color: "primary",
className: "bg-brand-subtle text-brand-subtle",
},
// Secondary variants
{
loading: true,
color: "secondary",
className: "bg-subtle text-emphasis/80",
},
// Minimal variants
{
loading: true,
color: "minimal",
className: "bg-subtle text-emphasis/30",
},
// Destructive variants
{
loading: true,
color: "destructive",
className:
"text-red-700/30 dark:text-red-700/30 hover:text-red-700/30 border border-default text-emphasis",
},
{
variant: "icon",
size: "base",
className: "min-h-[36px] min-w-[36px] !p-2 hover:border-default",
},
{
variant: "icon",
size: "sm",
className: "h-6 w-6 !p-1",
},
{
variant: "fab",
size: "base",
className: "h-14 md:h-9 md:w-auto md:px-4 md:py-2.5",
},
],
defaultVariants: {
variant: "button",
color: "primary",
size: "base",
},
}
);
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
props: ButtonProps,
forwardedRef
) {
const {
loading = false,
color = "primary",
size,
variant = "button",
type = "button",
tooltipSide = "top",
tooltipOffset = 4,
StartIcon,
CustomStartIcon,
EndIcon,
shallow,
// attributes propagated from `HTMLAnchorProps` or `HTMLButtonProps`
...passThroughProps
} = props;
// Buttons are **always** disabled if we're in a `loading` state
const disabled = props.disabled || loading;
// If pass an `href`-attr is passed it's `<a>`, otherwise it's a `<button />`
const isLink = typeof props.href !== "undefined";
const elementType = isLink ? "a" : "button";
const element = React.createElement(
elementType,
{
...passThroughProps,
disabled,
type: !isLink ? type : undefined,
ref: forwardedRef,
className: classNames(buttonClasses({ color, size, loading, variant }), props.className),
// if we click a disabled button, we prevent going through the click handler
onClick: disabled
? (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
}
: props.onClick,
},
<>
{CustomStartIcon ||
(StartIcon && (
<>
{variant === "fab" ? (
<>
<Icon
name={StartIcon}
className="hidden h-4 w-4 stroke-[1.5px] ltr:-ml-1 ltr:mr-2 rtl:-mr-1 rtl:ml-2 md:inline-flex"
/>
<Icon name="plus" data-testid="plus" className="inline h-6 w-6 md:hidden" />
</>
) : (
<Icon
name={StartIcon}
className={classNames(
variant === "icon" && "h-4 w-4",
variant === "button" && "h-4 w-4 stroke-[1.5px] ltr:-ml-1 ltr:mr-2 rtl:-mr-1 rtl:ml-2"
)}
/>
)}
</>
))}
{variant === "fab" ? <span className="hidden md:inline">{props.children}</span> : props.children}
{loading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<svg
className={classNames(
"mx-4 h-5 w-5 animate-spin",
color === "primary" ? "text-inverted" : "text-emphasis"
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
{EndIcon && (
<>
{variant === "fab" ? (
<>
<Icon name={EndIcon} className="-mr-1 me-2 ms-2 hidden h-5 w-5 md:inline" />
<Icon name="plus" data-testid="plus" className="inline h-6 w-6 md:hidden" />
</>
) : (
<Icon
name={EndIcon}
className={classNames(
"inline-flex",
variant === "icon" && "h-4 w-4",
variant === "button" && "h-4 w-4 stroke-[1.5px] ltr:-mr-1 ltr:ml-2 rtl:-ml-1 rtl:mr-2"
)}
/>
)}
</>
)}
</>
);
return props.href ? (
<Link data-testid="link-component" passHref href={props.href} shallow={shallow && shallow} legacyBehavior>
{element}
</Link>
) : (
<Wrapper
data-testid="wrapper"
tooltip={props.tooltip}
tooltipSide={tooltipSide}
tooltipOffset={tooltipOffset}>
{element}
</Wrapper>
);
});
const Wrapper = ({
children,
tooltip,
tooltipSide,
tooltipOffset,
}: {
tooltip?: string | React.ReactNode;
children: React.ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
tooltipOffset?: number;
}) => {
if (!tooltip) {
return <>{children}</>;
}
return (
<Tooltip data-testid="tooltip" content={tooltip} side={tooltipSide} sideOffset={tooltipOffset}>
{children}
</Tooltip>
);
};

View File

@@ -0,0 +1,21 @@
import React from "react";
import { Icon, type IconName } from "@calcom/ui";
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Icon: IconName;
}
export default function LinkIconButton(props: LinkIconButtonProps) {
return (
<div className="-ml-2">
<button
type="button"
{...props}
className="text-md hover:bg-emphasis hover:text-emphasis text-default flex items-center rounded-md px-2 py-1 text-sm font-medium">
<Icon name={props.Icon} className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
{props.children}
</button>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
VariantsTable,
VariantColumn,
RowTitles,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Plus, X } from "../icon";
import { Button } from "./Button";
<Meta title="UI/Button" component={Button} />
<Title title="Buttons" suffix="Brief" subtitle="Version 2.1 — Last Update: 24 Aug 2023" />
## Definition
Button are clickable elements that initiates user actions. Labels in the button guide users to what action will occur when the user interacts with it.
## Structure
<CustomArgsTable of={Button} />
<Examples
title="Button style"
footnote={
<ul>
<li>Primary: Signals most important actions at any given point in the application.</li>
<li>Secondary: Gives visual weight to actions that are important</li>
<li>Minimal: Used for actions that we want to give very little significane to</li>
</ul>
}>
<Example title="Primary">
<Button className="sb-fake-pseudo--focus">Button text</Button>
</Example>
<Example title="Secondary">
<Button color="secondary">Button text</Button>
</Example>
<Example title="Minimal">
<Button color="minimal">Button text</Button>
</Example>
</Examples>
<Examples title="State">
<Example title="Default">
<Button>Button text</Button>
</Example>
<Example title="Hover">
<Button className="sb-pseudo--hover">Button text</Button>
</Example>
<Example title="Focus">
<Button className="sb-pseudo--focus">Button text</Button>
</Example>
<Example title="Disabled">
<Button disabled>Button text</Button>
</Example>
<Example title="Loading">
<Button loading>Button text</Button>
</Example>
</Examples>
<Examples title="Icons">
<Example title="Default">
<Button>Button text</Button>
</Example>
<Example title="Icon left">
<Button StartIcon="plus">Button text</Button>
</Example>
<Example title="Icon right">
<Button EndIcon="plus">Button text</Button>
</Example>
</Examples>
<Examples title="Icons">
<Example title="Icon Normal">
<Button StartIcon="x" variant="icon" size="base" color="minimal"></Button>
</Example>
<Example title="Icon Small">
<Button StartIcon="x" variant="icon" size="sm" color="minimal"></Button>
</Example>
<Example title="Icon Loading">
<Button StartIcon="x" variant="icon" size="sm" color="minimal" loading></Button>
</Example>
</Examples>
## Anatomy
Button are clickable elements that initiates user actions. Labels in the button guide users to what action will occur when the user interacts with it.
## Usage
<Note>In general, there should be only one Primary button in any application context</Note>
<Note>
Aim to use maximum 2 words for the button label. Button size can be flexible based on the visual hierarchy
and devices.{" "}
</Note>
<Note>Hover state variant for Mobile button is an option for assistive device.</Note>
<Title offset title="Buttons" suffix="Variants" />
<Canvas>
<Story name="All variants">
<VariantsTable titles={["Primary", "Secondary", "Minimal", "Destructive", "Icon"]} columnMinWidth={150}>
<VariantRow variant="Default">
<Button>Button text</Button>
<Button color="secondary">Button text</Button>
<Button color="minimal">Button text</Button>
<Button color="destructive">Button text</Button>
<Button color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Hover">
<Button className="sb-pseudo--hover">Button text</Button>
<Button className="sb-pseudo--hover" color="secondary">
Button text
</Button>
<Button className="sb-pseudo--hover" color="minimal">
Button text
</Button>
<Button className="sb-pseudo--hover" color="destructive">
Button text
</Button>
<Button className="sb-pseudo--hover" color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Focus">
<Button className="sb-pseudo--focus">Button text</Button>
<Button className="sb-pseudo--focus" color="secondary">
Button text
</Button>
<Button className="sb-pseudo--focus" color="minimal">
Button text
</Button>
<Button className="sb-pseudo--focus" color="destructive">
Button text
</Button>
<Button className="sb-pseudo--focus" color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Loading">
<Button loading>Button text</Button>
<Button loading color="secondary">
Button text
</Button>
<Button loading color="minimal">
Button text
</Button>
<Button loading color="destructive">
Button text
</Button>
<Button loading color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Disabled">
<Button disabled>Button text</Button>
<Button disabled color="secondary">
Button text
</Button>
<Button disabled color="minimal">
Button text
</Button>
<Button disabled color="destructive">
Button text
</Button>
<Button disabled color="minimal" variant="icon" StartIcon="x"></Button>
</VariantRow>
</VariantsTable>
</Story>
<Story
name="Button Playground"
play={({ canvasElement }) => {
const darkVariantContainer = canvasElement.querySelector('[data-mode="dark"]');
const buttonElement = darkVariantContainer.querySelector("button");
buttonElement?.addEventListener("mouseover", () => {
setTimeout(() => {
document.querySelector('[data-testid="tooltip"]').classList.add("dark");
}, 55);
});
}}
args={{
color: "primary",
size: "base",
loading: false,
disabled: false,
children: "Button text",
className: "",
tooltip: "tooltip",
}}
argTypes={{
color: {
control: {
type: "inline-radio",
options: ["primary", "secondary", "minimal", "destructive"],
},
},
size: {
control: {
type: "inline-radio",
options: ["base", "sm"],
},
},
loading: {
control: {
type: "boolean",
},
},
disabled: {
control: {
type: "boolean",
},
},
children: {
control: {
type: "text",
},
},
className: {
control: {
type: "inline-radio",
options: ["", "sb-pseudo--hover", "sb-pseudo--focus"],
},
},
tooltip: {
control: {
type: "text",
},
},
}}>
{({ children, ...args }) => (
<VariantsTable titles={["Light & Dark Modes"]} columnMinWidth={150}>
<VariantRow variant="Button">
<TooltipProvider>
<Button variant="default" {...args}>
{children}
</Button>
</TooltipProvider>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,248 @@
/* eslint-disable playwright/missing-playwright-await */
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { vi } from "vitest";
import { Button, buttonClasses } from "./Button";
const observeMock = vi.fn();
window.ResizeObserver = vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: observeMock,
unobserve: vi.fn(),
}));
vi.mock("../tooltip", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = (await vi.importActual("../tooltip")) as any;
const TooltipMock = (props: object) => {
const [open, setOpen] = useState(false);
return (
<actual.Tooltip
{...props}
open={open}
onOpenChange={(isOpen: boolean) => {
setOpen(isOpen);
}}
/>
);
};
return {
Tooltip: TooltipMock,
};
});
describe("Tests for Button component", () => {
test("Should apply the icon variant class", () => {
render(<Button variant="icon">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ variant: "icon" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the fab variant class", () => {
render(<Button variant="fab">Test Button</Button>);
expect(screen.getByText("Test Button")).toHaveClass("hidden md:inline");
});
test("Should apply the secondary color class", () => {
render(<Button color="secondary">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ color: "secondary" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the minimal color class", () => {
render(<Button color="minimal">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ color: "minimal" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the sm size class", () => {
render(<Button size="sm">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ size: "sm" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the base size class", () => {
render(<Button size="base">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ size: "base" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the lg size class", () => {
render(<Button size="lg">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ size: "lg" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the loading class", () => {
render(<Button loading>Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ loading: true });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the disabled class when disabled prop is true", () => {
render(<Button disabled>Test Button</Button>);
const buttonClass = screen.getByText("Test Button").className;
const expectedClassName = "disabled:cursor-not-allowed";
expect(buttonClass.includes(expectedClassName)).toBe(true);
});
test("Should apply the custom class", () => {
const className = "custom-class";
render(<Button className={className}>Test Button</Button>);
expect(screen.getByText("Test Button")).toHaveClass(className);
});
test("Should render as a button by default", () => {
render(<Button>Test Button</Button>);
const button = screen.getByText("Test Button");
expect(button.tagName).toBe("BUTTON");
});
test("Should render StartIcon and Plus icon if is fab variant", async () => {
render(
<Button variant="fab" StartIcon="plus" data-testid="start-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("start-icon")).toBeInTheDocument();
expect(await screen.findByTestId("plus")).toBeInTheDocument();
});
test("Should render just StartIcon if is not fab variant", async () => {
render(
<Button StartIcon="plus" data-testid="start-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("start-icon")).toBeInTheDocument();
expect(screen.queryByTestId("plus")).not.toBeInTheDocument();
});
test("Should render EndIcon and Plus icon if is fab variant", async () => {
render(
<Button variant="fab" EndIcon="plus" data-testid="end-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("end-icon")).toBeInTheDocument();
expect(await screen.findByTestId("plus")).toBeInTheDocument();
});
test("Should render just EndIcon if is not fab variant", async () => {
render(
<Button EndIcon="plus" data-testid="end-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("end-icon")).toBeInTheDocument();
expect(screen.queryByTestId("plus")).not.toBeInTheDocument();
});
test("Should render Link if have href", () => {
render(<Button href="/test">Test Button</Button>);
const buttonElement = screen.getByText("Test Button");
expect(buttonElement).toHaveAttribute("href", "/test");
expect(buttonElement.closest("a")).toBeInTheDocument();
test("Should render Wrapper if don't have href", () => {
render(<Button>Test Button</Button>);
expect(screen.queryByTestId("link-component")).not.toBeInTheDocument();
expect(screen.getByText("Test Button")).toBeInTheDocument();
});
test("Should render Tooltip if exists", () => {
render(<Button tooltip="Hi, Im a tooltip">Test Button</Button>);
const tooltip = screen.getByTestId("tooltip");
expect(tooltip.getAttribute("data-state")).toEqual("closed");
expect(tooltip.getAttribute("data-state")).toEqual("instant-open");
expect(observeMock).toBeCalledWith(tooltip);
});
test("Should not render Tooltip if no exists", () => {
render(<Button>Test Button</Button>);
expect(screen.queryByTestId("tooltip")).not.toBeInTheDocument();
expect(screen.getByText("Test Button")).toBeInTheDocument();
});
test("Should render as a button with a custom type", () => {
render(<Button type="submit">Test Button</Button>);
const button = screen.getByText("Test Button");
expect(button.tagName).toBe("BUTTON");
expect(button).toHaveAttribute("type", "submit");
});
test("Should render as an anchor when href prop is provided", () => {
render(<Button href="/path">Test Button</Button>);
const button = screen.getByText("Test Button");
expect(button.tagName).toBe("A");
expect(button).toHaveAttribute("href", "/path");
});
test("Should call onClick callback when clicked", () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Test Button</Button>);
const button = screen.getByText("Test Button");
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test("Should render default button correctly", () => {
render(<Button loading={false}>Default Button</Button>);
const buttonClass = screen.getByText("Default Button").className;
const buttonComponentClass = buttonClasses({ variant: "button", color: "primary", size: "base" });
const buttonClassArray = buttonClass.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.every((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
expect(screen.getByText("Default Button")).toHaveAttribute("type", "button");
});
test("Should pass the shallow prop to Link component when href prop is passed", () => {
const href = "https://example.com";
render(<Button href={href} shallow />);
const linkComponent = screen.getByTestId("link-component");
expect(linkComponent).toHaveAttribute("shallow", "true");
});
});
});

View File

@@ -0,0 +1,3 @@
export { Button } from "./Button";
export type { ButtonBaseProps, ButtonProps } from "./Button";
export { default as LinkIconButton } from "./LinkIconButton";