From 55ee58d082d37a67c61e343844bf625dcff93a20 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Fri, 8 Mar 2024 17:45:54 -0600 Subject: [PATCH 1/3] feat(Button)!: introduce button v2.0 - completely rebuild component to match updated design and API - add new tests and snapshots - preserve v1 for now, for comparisons and avoiding snapshot churn --- src/components/Button/Button-v2.module.css | 306 ++++++++++++++++++++ src/components/Button/Button-v2.stories.tsx | 144 +++++++++ src/components/Button/Button-v2.tsx | 168 +++++++++++ 3 files changed, 618 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..a966382fc --- /dev/null +++ b/src/components/Button/Button-v2.stories.tsx @@ -0,0 +1,144 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import React from 'react'; +import { Button } from './Button-v2'; +import { SIZES } from '../ClickableStyle'; + +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; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + args: { + children: 'Default', + }, +}; + +export const DefaultRanks: StoryObj = { + args: { + ...Default.args, + }, + render: (args) => { + return ( +
+ + + +
+ ); + }, +}; + +export const TertiaryStandalone: StoryObj = { + args: { + rank: 'tertiary', + context: 'standalone', + }, +}; + +export const CriticalRanks: StoryObj = { + args: { + ...DefaultRanks.args, + variant: 'critical', + }, + render: DefaultRanks.render, +}; + +export const InverseRanks: StoryObj = { + args: { + ...DefaultRanks.args, + variant: 'inverse', + }, + render: DefaultRanks.render, + // TODO-AH: find a cleaner way to decorate with unavailable tokens using parameters:backgounds: + decorators: [(Story) =>
{Story()}
], +}; + +export const Sizes: StoryObj = { + args: { + ...Default.args, + }, + render: (args) => { + return ( +
+ + + +
+ ); + }, +}; + +export const FullWidths: StoryObj = { + args: { + ...Sizes.args, + isFullWidth: true, + }, + render: Sizes.render, +}; + +export const LoadingStates: StoryObj = { + args: { + ...Sizes.args, + isLoading: true, + }, + render: Sizes.render, +}; + +export const IconLayouts: StoryObj = { + args: { + ...Default.args, + }, + render: (args) => { + return ( +
+ + + +
+ ); + }, +}; diff --git a/src/components/Button/Button-v2.tsx b/src/components/Button/Button-v2.tsx new file mode 100644 index 000000000..10f442119 --- /dev/null +++ b/src/components/Button/Button-v2.tsx @@ -0,0 +1,168 @@ +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +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; + +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; + + /** + * 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( + ( + { + 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'], + ); + + const ariaLabel = + iconLayout === 'icon-only' && !other['aria-label'] + ? children + : other['aria-label']; + + return ( + + ); + }, +); + +Button.displayName = 'Button'; From b33ddb170e6bafe6edb3bdc24a2f3d314bb98c89 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Tue, 12 Mar 2024 11:26:47 -0500 Subject: [PATCH 2/3] fix(Button): fix v2 type issues --- src/components/Button/Button-v2.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Button/Button-v2.tsx b/src/components/Button/Button-v2.tsx index 10f442119..391bec392 100644 --- a/src/components/Button/Button-v2.tsx +++ b/src/components/Button/Button-v2.tsx @@ -1,5 +1,4 @@ import clsx from 'clsx'; -import type { ReactNode } from 'react'; import React, { forwardRef } from 'react'; import type { Size } from '../../util/variant-types'; import Icon from '../Icon'; From 35015922e7aafa85c3681056855471f37be2eceb Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Tue, 12 Mar 2024 11:55:25 -0500 Subject: [PATCH 3/3] test(Button): add v2 stories for disabled --- src/components/Button/Button-v2.stories.tsx | 28 ++++++++++++++++++--- src/components/Button/Button-v2.tsx | 7 ------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/Button/Button-v2.stories.tsx b/src/components/Button/Button-v2.stories.tsx index a966382fc..8417cc8c2 100644 --- a/src/components/Button/Button-v2.stories.tsx +++ b/src/components/Button/Button-v2.stories.tsx @@ -3,6 +3,8 @@ 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, @@ -60,6 +62,14 @@ export const DefaultRanks: StoryObj = { }, }; +export const Disabled: StoryObj = { + args: { + ...DefaultRanks.args, + isDisabled: true, + }, + render: DefaultRanks.render, +}; + export const TertiaryStandalone: StoryObj = { args: { rank: 'tertiary', @@ -82,7 +92,11 @@ export const InverseRanks: StoryObj = { }, render: DefaultRanks.render, // TODO-AH: find a cleaner way to decorate with unavailable tokens using parameters:backgounds: - decorators: [(Story) =>
{Story()}
], + decorators: [ + (Story) => ( +
{Story()}
+ ), + ], }; export const Sizes: StoryObj = { @@ -122,6 +136,10 @@ export const LoadingStates: StoryObj = { 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: { ...Default.args, @@ -135,8 +153,12 @@ export const IconLayouts: StoryObj = { - ); diff --git a/src/components/Button/Button-v2.tsx b/src/components/Button/Button-v2.tsx index 391bec392..7d1ad1bb3 100644 --- a/src/components/Button/Button-v2.tsx +++ b/src/components/Button/Button-v2.tsx @@ -124,18 +124,11 @@ export const Button = forwardRef( isLoading && styles['button--is-loading'], ); - const ariaLabel = - iconLayout === 'icon-only' && !other['aria-label'] - ? children - : other['aria-label']; - return (