From a6b446f73d9d0c399f5dadecd94fe5dcb69c7003 Mon Sep 17 00:00:00 2001
From: Andrew Holloway <booc0mtaco@users.noreply.github.com>
Date: Fri, 15 Mar 2024 11:15:03 -0500
Subject: [PATCH] feat(Button)!: introduce v2.0 component (#1889)

- completely rebuild component to match updated design and API
- add new tests and snapshots
- preserve v1 for now, for comparisons and avoiding snapshot churn
- fix v2 type issues
- add v2 stories for disabled
---
 src/components/Button/Button-v2.module.css  | 306 ++++++++++++++++++++
 src/components/Button/Button-v2.stories.tsx | 166 +++++++++++
 src/components/Button/Button-v2.tsx         | 160 ++++++++++
 3 files changed, 632 insertions(+)
 create mode 100644 src/components/Button/Button-v2.module.css
 create mode 100644 src/components/Button/Button-v2.stories.tsx
 create mode 100644 src/components/Button/Button-v2.tsx

diff --git a/src/components/Button/Button-v2.module.css b/src/components/Button/Button-v2.module.css
new file mode 100644
index 000000000..df2e58e8d
--- /dev/null
+++ b/src/components/Button/Button-v2.module.css
@@ -0,0 +1,306 @@
+@import '../../design-tokens/mixins.css';
+
+/*------------------------------------*\
+    # BUTTON
+\*------------------------------------*/
+
+.button {
+  position: relative;
+  border-radius: var(--eds-border-radius-full);
+  border: var(--eds-border-width-sm) solid;
+  overflow: hidden;
+  display: flex;
+}
+
+.button__text {
+  display: flex;
+  gap: 0.25rem;
+  align-items: center;
+  justify-content: center;
+
+  width: 100%;
+}
+
+.button__text.button--is-loading {
+  visibility: hidden;
+}
+
+.button__loader {
+  position: absolute;
+  top: 0;
+  left: 0;
+
+  width: 100%;
+  height: 100%;
+
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+/**
+ * Sizes and Widths
+ */
+.button--lg {
+  padding: 0.5rem 1.25rem;
+  font: var(--eds-theme-typography-button-lg);
+
+  min-width: 4.5rem;
+  max-width: 20rem;
+  max-height: 2.5rem;
+}
+
+.button--md {
+  padding: 0.25rem 1rem;
+  font: var(--eds-theme-typography-button-md);
+
+  min-width: 3.75rem;
+  max-width: 16rem;
+  max-height: 2rem;
+}
+
+.button--sm {
+  padding: 0.25rem 1.33333333rem;
+  /* TODO: need eds-theme-typography-button-sm => preset-009 */
+  font: var(--eds-typography-preset-009);
+
+  min-width: 3rem;
+  max-width: 12rem;
+  max-height: 1.5rem;
+}
+
+.button--full-width {
+  width: 100%;
+}
+
+/**
+ * Anatomy and iconLayout (w/ size)
+ */
+.button--layout-icon-only {
+  min-width: unset;
+}
+
+.button--lg.button--layout-left {
+  padding-left: 1rem;
+}
+
+.button--lg.button--layout-right {
+  padding-right: 1rem;
+}
+
+.button--lg.button--layout-icon-only {
+  padding: 0.5rem;
+}
+
+.button--md.button--layout-icon-only {
+  padding: 0.5rem
+}
+
+.button--sm.button--layout-icon-only {
+  padding: 0.25rem;
+}
+
+.button:focus-visible {
+  outline: none;
+  box-shadow: 0 0 0 0.125rem var(--eds-theme-color-background-utility-base-1), 0 0 0 0.25rem var(--eds-theme-color-border-utility-focus);
+}
+
+/* stylelint-disable-next-line eds/no-tier-1-color-variable */
+.button.button--variant-inverse:focus-visible {
+  outline: none;
+  box-shadow: 0 0 0 0.125rem var(--eds-color-black), 0 0 0 0.25rem var(--eds-theme-color-background-utility-base-1);
+}
+
+/**
+ * Rank & Emphasis
+ */
+.button--primary.button--variant-default {
+  color: var(--eds-theme-color-text-utility-inverse);
+  background-color: var(--eds-theme-color-background-utility-interactive-high-emphasis);
+  border-color: var(--eds-theme-color-background-utility-interactive-high-emphasis);
+}
+
+.button--secondary.button--variant-default {
+  color: var(--eds-theme-color-background-utility-interactive-high-emphasis);
+  border-color: currentColor;
+  background-color: var(--eds-theme-color-background-utility-interactive-no-emphasis);
+}
+
+.button--tertiary.button--variant-default {
+  color: var(--eds-theme-color-background-utility-interactive-high-emphasis);
+  border-color: var(--eds-theme-color-background-utility-interactive-no-emphasis);
+  background-color: var(--eds-theme-color-background-utility-interactive-no-emphasis);
+}
+
+.button--tertiary.button--context-standalone {
+  color: var(--eds-theme-color-text-utility-interactive-secondary);
+}
+
+/**
+ * Button status variants
+ */
+.button--primary.button--variant-critical {
+  color: var(--eds-theme-color-text-utility-inverse);
+  border-color: var(--eds-theme-color-background-utility-critical-high-emphasis);
+  background-color: var(--eds-theme-color-background-utility-critical-high-emphasis);
+}
+
+.button--secondary.button--variant-critical {
+  color: var(--eds-theme-color-background-utility-critical-high-emphasis);
+  border-color: currentColor;
+  background-color:  var(--eds-theme-color-background-utility-inverse-high-emphasis);
+}
+
+.button--tertiary.button--variant-critical {
+  color: var(--eds-theme-color-background-utility-critical-high-emphasis);
+  border-color:  var(--eds-theme-color-background-utility-inverse-high-emphasis);
+}
+
+/**
+ * Inverse
+ */
+
+.button--primary.button--variant-inverse {
+  color: var(--eds-theme-color-text-utility-neutral-primary);
+  border-color: var(--eds-theme-color-background-utility-inverse-high-emphasis);
+  background-color: var(--eds-theme-color-background-utility-inverse-high-emphasis);
+}
+
+.button--secondary.button--variant-inverse {
+  color: var(--eds-theme-color-text-utility-inverse);
+  border-color: currentColor;
+  background-color: var(--eds-theme-color-background-utility-interactive-high-emphasis);
+}
+
+.button--tertiary.button--variant-inverse {
+  color: var(--eds-theme-color-text-utility-inverse);
+  border-color: var(--eds-theme-color-background-utility-interactive-high-emphasis);
+  background-color: var(--eds-theme-color-background-utility-interactive-high-emphasis);
+}
+
+/**
+ * Disabled
+ */
+
+.button--variant-default.button--disabled,
+.button--variant-critical.button--disabled {
+  color: var(--eds-theme-color-text-utility-disabled-primary);
+  border-color: var(--eds-theme-color-background-utility-disabled-medium-emphasis);
+  background-color:  var(--eds-theme-color-background-utility-disabled-medium-emphasis);
+
+  pointer-events: none;
+}
+
+.button--variant-inverse.button--disabled {
+  color: var(--eds-theme-color-text-utility-inverse-disabled);
+  border-color: var(--eds-theme-color-background-utility-inverse-disabled);
+  background-color: var(--eds-theme-color-background-utility-inverse-disabled);
+
+  pointer-events: none;
+}
+
+/**
+ * States
+ */
+
+ /* Hover */
+.button--variant-default:hover {
+  background-color: var(--eds-theme-color-border-utility-interactive-hover);
+  border-color: var(--eds-theme-color-border-utility-interactive-hover);
+}
+
+.button--secondary.button--variant-default:hover,
+.button--tertiary.button--variant-default:hover {
+  color: var(--eds-theme-color-text-utility-interactive-primary-hover);
+
+  background-color: var(--eds-theme-color-background-utility-interactive-no-emphasis-hover);
+  border-color: var(--eds-theme-color-border-utility-interactive-hover);
+}
+
+.button--tertiary.button--variant-default:hover {
+  border-color: var(--eds-theme-color-background-utility-interactive-no-emphasis-hover);
+}
+
+.button--variant-critical:hover {
+  background-color: var(--eds-theme-color-background-utility-critical-high-emphasis-hover);
+  border-color: var(--eds-theme-color-background-utility-critical-high-emphasis-hover);
+}
+
+.button--secondary.button--variant-critical:hover,
+.button--tertiary.button--variant-critical:hover {
+  color: var(--eds-theme-color-text-utility-critical-hover);
+
+  background-color: var(--eds-theme-color-background-utility-critical-no-emphasis-hover);
+  border-color: var(--eds-theme-color-border-utility-critical-hover);
+}
+
+.button--tertiary.button--variant-critical:hover {
+  border-color: var(--eds-theme-color-background-utility-critical-no-emphasis-hover);
+}
+
+.button--primary.button--variant-inverse:hover {
+  background-color: var(--eds-theme-color-background-utility-inverse-high-emphasis-hover)
+}
+
+.button--secondary.button--variant-inverse:hover,
+.button--tertiary.button--variant-inverse:hover {
+  background-color: var(--eds-theme-color-background-utility-inverse-no-emphasis-hover);
+}
+
+.button--tertiary.button--variant-inverse:hover {
+  /* TODO-AH: b/c of opacity, the background and borders stack, causing a faint border */
+  border-color: var(--eds-theme-color-background-utility-inverse-no-emphasis-hover);
+}
+
+.button--tertiary.button--context-standalone:hover {
+  color: var(--eds-theme-color-text-utility-interactive-secondary-hover);
+
+}
+
+/* Active */
+.button--variant-default:active {
+  background-color: var(--eds-theme-color-border-utility-interactive-active);
+  border-color: var(--eds-theme-color-border-utility-interactive-active);
+}
+
+.button--secondary.button--variant-default:active,
+.button--tertiary.button--variant-default:active {
+  color: var(--eds-theme-color-text-utility-neutral-primary-active);
+
+  background-color: var(--eds-theme-color-background-utility-interactive-no-emphasis-active);
+  border-color: var(--eds-theme-color-border-utility-interactive-active);
+}
+
+.button--tertiary.button--variant-default:active {
+  border-color: var(--eds-theme-color-background-utility-interactive-no-emphasis-active);
+}
+
+.button--variant-critical:active {
+  background-color: var(--eds-theme-color-background-utility-critical-high-emphasis-active);
+  border-color: var(--eds-theme-color-background-utility-critical-high-emphasis-active);
+}
+
+.button--secondary.button--variant-critical:active,
+.button--tertiary.button--variant-critical:active {
+  color: var(--eds-theme-color-text-utility-critical-active);
+
+  background-color: var(--eds-theme-color-background-utility-critical-no-emphasis-active);
+  border-color: var(--eds-theme-color-border-utility-critical-active);
+}
+
+.button--tertiary.button--variant-critical:active {
+  border-color: var(--eds-theme-color-background-utility-critical-no-emphasis-active);
+}
+
+.button--primary.button--variant-inverse:active {
+  background-color: var(--eds-theme-color-background-utility-inverse-high-emphasis-active);
+}
+
+.button--secondary.button--variant-inverse:active,
+.button--tertiary.button--variant-inverse:active {
+  background-color: var(--eds-theme-color-background-utility-inverse-no-emphasis-active);
+}
+
+.button--tertiary.button--context-standalone:active {
+  color: var(--eds-theme-color-text-utility-interactive-secondary-active);
+}
\ No newline at end of file
diff --git a/src/components/Button/Button-v2.stories.tsx b/src/components/Button/Button-v2.stories.tsx
new file mode 100644
index 000000000..8417cc8c2
--- /dev/null
+++ b/src/components/Button/Button-v2.stories.tsx
@@ -0,0 +1,166 @@
+import type { StoryObj, Meta } from '@storybook/react';
+import React from 'react';
+import { Button } from './Button-v2';
+import { SIZES } from '../ClickableStyle';
+
+// TODO-AH: add documentation to each story
+
+export default {
+  title: 'Components/Button (v2)',
+  component: Button,
+  args: {
+    children: 'Button',
+    isFullWidth: false,
+    size: 'lg',
+    isLoading: false,
+  },
+  argTypes: {
+    size: {
+      control: {
+        type: 'select',
+      },
+      options: SIZES,
+    },
+    isFullWidth: {
+      control: 'boolean',
+    },
+    isLoading: {
+      control: 'boolean',
+    },
+  },
+  parameters: {
+    badges: ['intro-1.0', 'current-2.0'],
+  },
+} as Meta<Args>;
+
+type Args = React.ComponentProps<typeof Button>;
+
+export const Default: StoryObj<Args> = {
+  args: {
+    children: 'Default',
+  },
+};
+
+export const DefaultRanks: StoryObj<Args> = {
+  args: {
+    ...Default.args,
+  },
+  render: (args) => {
+    return (
+      <div className="flex gap-1">
+        <Button {...args} rank="primary">
+          Primary
+        </Button>
+        <Button {...args} rank="secondary">
+          Secondary
+        </Button>
+        <Button {...args} rank="tertiary">
+          Tertiary
+        </Button>
+      </div>
+    );
+  },
+};
+
+export const Disabled: StoryObj<Args> = {
+  args: {
+    ...DefaultRanks.args,
+    isDisabled: true,
+  },
+  render: DefaultRanks.render,
+};
+
+export const TertiaryStandalone: StoryObj<Args> = {
+  args: {
+    rank: 'tertiary',
+    context: 'standalone',
+  },
+};
+
+export const CriticalRanks: StoryObj<Args> = {
+  args: {
+    ...DefaultRanks.args,
+    variant: 'critical',
+  },
+  render: DefaultRanks.render,
+};
+
+export const InverseRanks: StoryObj<Args> = {
+  args: {
+    ...DefaultRanks.args,
+    variant: 'inverse',
+  },
+  render: DefaultRanks.render,
+  // TODO-AH: find a cleaner way to decorate with unavailable tokens using parameters:backgounds:
+  decorators: [
+    (Story) => (
+      <div className="bg-[var(--eds-color-blue-850)] p-1">{Story()}</div>
+    ),
+  ],
+};
+
+export const Sizes: StoryObj<Args> = {
+  args: {
+    ...Default.args,
+  },
+  render: (args) => {
+    return (
+      <div className="flex items-center gap-1">
+        <Button {...args} size="lg">
+          Large
+        </Button>
+        <Button {...args} size="md">
+          Medium
+        </Button>
+        <Button {...args} size="sm">
+          Small
+        </Button>
+      </div>
+    );
+  },
+};
+
+export const FullWidths: StoryObj<Args> = {
+  args: {
+    ...Sizes.args,
+    isFullWidth: true,
+  },
+  render: Sizes.render,
+};
+
+export const LoadingStates: StoryObj<Args> = {
+  args: {
+    ...Sizes.args,
+    isLoading: true,
+  },
+  render: Sizes.render,
+};
+
+/**
+ * `iconLayout` lets you place the icons adjacent to button text, or as the only visible element.
+ * When using `"icon-only"`, you **must** include a label (e.g., via `aria-label`).
+ */
+export const IconLayouts: StoryObj<Args> = {
+  args: {
+    ...Default.args,
+  },
+  render: (args) => {
+    return (
+      <div className="flex items-center gap-1">
+        <Button {...args} iconLayout="left">
+          Left
+        </Button>
+        <Button {...args} iconLayout="right">
+          Right
+        </Button>
+        <Button
+          {...args}
+          aria-label="Label must be applied with icon-only layout"
+          iconLayout="icon-only"
+        >
+          Icon Only (text not visible)
+        </Button>
+      </div>
+    );
+  },
+};
diff --git a/src/components/Button/Button-v2.tsx b/src/components/Button/Button-v2.tsx
new file mode 100644
index 000000000..7d1ad1bb3
--- /dev/null
+++ b/src/components/Button/Button-v2.tsx
@@ -0,0 +1,160 @@
+import clsx from 'clsx';
+import React, { forwardRef } from 'react';
+import type { Size } from '../../util/variant-types';
+import Icon from '../Icon';
+import type { IconName } from '../Icon';
+import LoadingIndicator from '../LoadingIndicator';
+
+import styles from './Button-v2.module.css';
+
+type ButtonHTMLElementProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
+
+type ButtonV2Props = ButtonHTMLElementProps & {
+  // Component API
+  /**
+   * `Button` contents or label.
+   */
+  children: string;
+  /**
+   * Determine the behavior of the button upon click:
+   * - **button** `Button` is a clickable button with no default behavior
+   * - **submit** `Button` is a clickable button that submits form data
+   * - **reset** `Button` is a clickable button that resets the form-data to its initial values
+   */
+  type?: 'button' | 'reset' | 'submit';
+
+  // Design API
+  /**
+   * Sets the hierarchy rank of the button
+   *
+   * **Default is `"primary"`**.
+   */
+  rank?: 'primary' | 'secondary' | 'tertiary';
+
+  /**
+   * The size of the button on screen
+   */
+  size?: Extract<Size, 'sm' | 'md' | 'lg'>;
+
+  /**
+   * The variant of the default tertiary button.
+   */
+  context?: 'default' | 'standalone';
+
+  /**
+   * Icon from the set of defined EDS icon set, when `iconLayout` is used.
+   */
+  icon?: IconName;
+
+  /**
+   * Allows configuation of the icon's positioning within `Button`.
+   *
+   * - When set to a value besides `"none"`, an icon must be specified.
+   * - When `"icon-only"`, `aria-label` must be given a value.
+   */
+  iconLayout?: 'none' | 'left' | 'right' | 'icon-only';
+
+  /**
+   * Status (color) variant for `Button`.
+   *
+   * **Default is `"default"`**.
+   */
+  variant?: 'default' | 'critical' | 'inverse';
+
+  /**
+   * Whether the width of the button is set to the full layout.
+   */
+  isFullWidth?: boolean;
+
+  /**
+   * Whether `Button` is set to disabled state (disables interaction and updates appearance).
+   */
+  isDisabled?: boolean;
+
+  /**
+   * Loading state passed down from higher level used to trigger loader and text change.
+   */
+  isLoading?: boolean;
+};
+
+/**
+ * `import {Button} from "@chanzuckerberg/eds";`
+ *
+ * Component for making buttons that do not navigate the user to another page. Use button to trigger actions, menus,
+ * or other in-page activity.
+ *
+ * - If you need to style a navigation anchor, please use the `Link` component.
+ * - If you need to style a different element or component to
+ *   look like a button or link, you can use the `ClickableStyle` component.
+ */
+export const Button = forwardRef<HTMLButtonElement, ButtonV2Props>(
+  (
+    {
+      children,
+      className,
+      context,
+      icon = 'empty-circle',
+      iconLayout = 'none',
+      isDisabled,
+      isFullWidth,
+      isLoading,
+      type = 'button',
+      rank = 'primary',
+      size = 'lg',
+      variant = 'default',
+      ...other
+    },
+    ref,
+  ) => {
+    const componentClassName = clsx(
+      styles['button'],
+      context && clsx(styles[`button--context-${context}`]),
+      iconLayout && clsx(styles[`button--layout-${iconLayout}`]),
+      isDisabled && clsx(styles['button--disabled']),
+      isFullWidth && clsx(styles['button--full-width']),
+      isLoading && clsx(styles['button--loading']),
+      rank && clsx(styles[`button--${rank}`]),
+      size && clsx(styles[`button--${size}`]),
+      variant && clsx(styles[`button--variant-${variant}`]),
+      className,
+    );
+
+    const buttonContentClassName = clsx(
+      styles['button__text'],
+      isLoading && styles['button--is-loading'],
+    );
+
+    return (
+      <button
+        className={componentClassName}
+        disabled={isDisabled}
+        ref={ref}
+        type={type}
+        {...other}
+      >
+        {/* TODO-AH: revisit sizing when rebuilding LoadingIndicator */}
+        <span className={buttonContentClassName}>
+          {iconLayout === 'icon-only' && (
+            <Icon
+              name={icon}
+              purpose="decorative"
+              size={size === 'lg' ? '1.5rem' : '1rem'}
+            />
+          )}
+          {iconLayout === 'left' && (
+            <Icon name={icon} purpose="decorative" size="1rem" />
+          )}
+          {iconLayout !== 'icon-only' && children}
+          {iconLayout === 'right' && (
+            <Icon name={icon} purpose="decorative" size="1rem" />
+          )}
+        </span>
+        {isLoading && (
+          <LoadingIndicator className={styles['button__loader']} size="sm" />
+        )}
+      </button>
+    );
+  },
+);
+
+Button.displayName = 'Button';