From 2b09432a264d806c89c48c30298e24b1dc7e632f Mon Sep 17 00:00:00 2001 From: Jared White Date: Mon, 17 Jul 2023 12:52:47 -0700 Subject: [PATCH] feat: add Button component (#42) * feat: add skeleton code for Button component * feat: add more options to Button * feat: continue building out Button * feat: add lots of Button styles * feat: add additional variants of Button * feat: additional styling fixes and code comments * docs: add docs for Button * chore: add Aria functionality to the Button * test: add tests for Button * chore: add Button to index exports * chore: tweak docs and remove router interface * fix: button prop typo * chore: update Button component to use `--seeds` nomenclature * test: use the right icon class in Button test * chore: update Button with more prefix fixes * chore: fix icon sizing * feat: provide more options for external link icons * chore: refactor Button internals, restyle hovers * chore: tweak Highlight color scheme * fix: use `button-` prefix on component tokens --- index.ts | 1 + src/actions/Button.scss | 185 +++++++++++++++++++++ src/actions/Button.tsx | 128 ++++++++++++++ src/actions/__stories__/Button.docs.mdx | 28 ++++ src/actions/__stories__/Button.stories.tsx | 168 +++++++++++++++++++ src/actions/__tests__/Button.test.tsx | 71 ++++++++ src/global/NavigationContext.tsx | 47 ++++++ src/global/tokens/borders.scss | 3 + src/global/tokens/colors.scss | 7 + 9 files changed, 638 insertions(+) create mode 100644 src/actions/Button.scss create mode 100644 src/actions/Button.tsx create mode 100644 src/actions/__stories__/Button.docs.mdx create mode 100644 src/actions/__stories__/Button.stories.tsx create mode 100644 src/actions/__tests__/Button.test.tsx create mode 100644 src/global/NavigationContext.tsx diff --git a/index.ts b/index.ts index 498a1cd..037eda4 100644 --- a/index.ts +++ b/index.ts @@ -11,3 +11,4 @@ export { default as Toast } from "./src/blocks/Toast" export { default as Card } from "./src/blocks/Card" export { default as Grid } from "./src/layout/Grid" export { default as Tabs } from "./src/navigation/Tabs" +export { default as Button } from "./src/actions/Button" diff --git a/src/actions/Button.scss b/src/actions/Button.scss new file mode 100644 index 0000000..f0252be --- /dev/null +++ b/src/actions/Button.scss @@ -0,0 +1,185 @@ +.seeds-button { + --button-border-width: var(--seeds-border-2); + --button-font-family: var(--seeds-font-alt-sans); + --button-font-weight: bold; + --button-interior-gap: var(--seeds-s3); + --button-icon-size-percentage: 75%; + --button-icon-side-padding: var(--seeds-s4); + + --button-padding-sm: calc(var(--seeds-s3_5) - var(--button-border-width)) + calc(var(--seeds-s5) - var(--button-border-width)); + --button-font-size-sm: var(--seeds-font-size-sm); + --button-border-radius-sm: var(--seeds-rounded); + --button-padding-md: calc(var(--seeds-s4) - var(--button-border-width)) + calc(var(--seeds-s6) - var(--button-border-width)); + --button-font-size-md: var(--seeds-font-size-base); + --button-border-radius-md: var(--seeds-rounded); + --button-padding-lg: calc(var(--seeds-s5) - var(--button-border-width)) + calc(var(--seeds-s7) - var(--button-border-width)); + --button-font-size-lg: var(--seeds-font-size-lg); + --button-border-radius-lg: var(--seeds-rounded); + + all: initial; + display: inline-flex; + gap: var(--button-interior-gap); + padding: var(--button-padding-md); + border-style: solid; + border-width: var(--button-border-width); + border-radius: var(--button-border-radius-md); + font-family: var(--button-font-family); + font-weight: var(--button-font-weight); + font-size: var(--button-font-size-md); + line-height: var(--seeds-line-height-tight); + cursor: pointer; + + &[data-size="sm"] { + padding: var(--button-padding-sm); + font-size: var(--button-font-size-sm); + border-radius: var(--button-border-radius-sm); + } + + &[data-size="lg"] { + padding: var(--button-padding-lg); + font-size: var(--button-font-size-lg); + border-radius: var(--button-border-radius-lg); + } + + &[data-variant="primary"] { + background-color: var(--seeds-color-primary); + border-color: var(--seeds-color-primary); + color: var(--seeds-color-on-inverse); + + &:hover { + background-color: var(--seeds-color-primary-dark); + border-color: var(--seeds-color-primary-dark); + } + } + + &[data-variant="primary-outlined"] { + background-color: var(--seeds-bg-color-surface); + border-color: var(--seeds-color-primary); + color: var(--seeds-color-primary); + + &:hover { + background-color: var(--seeds-color-primary-dark); + border-color: var(--seeds-color-primary-dark); + color: var(--seeds-color-on-inverse); + } + } + + &[data-variant="secondary"] { + background-color: var(--seeds-color-secondary); + border-color: var(--seeds-color-secondary); + color: var(--seeds-color-on-inverse); + + &:hover { + background-color: var(--seeds-color-secondary-dark); + border-color: var(--seeds-color-secondary-dark); + } + } + + &[data-variant="secondary-outlined"] { + background-color: var(--seeds-bg-color-surface); + border-color: var(--seeds-color-secondary); + color: var(--seeds-color-secondary); + + &:hover { + background-color: var(--seeds-color-secondary-dark); + border-color: var(--seeds-color-secondary-dark); + color: var(--seeds-color-on-inverse); + } + } + + &[data-variant="alert"] { + background-color: var(--seeds-color-alert); + border-color: var(--seeds-color-alert); + color: var(--seeds-color-on-inverse); + + &:hover { + background-color: var(--seeds-color-alert-dark); + border-color: var(--seeds-color-alert-dark); + } + } + + &[data-variant="alert-outlined"] { + background-color: var(--seeds-bg-color-surface); + border-color: var(--seeds-color-alert); + color: var(--seeds-color-alert); + + &:hover { + background-color: var(--seeds-color-alert-dark); + border-color: var(--seeds-color-alert-dark); + color: var(--seeds-color-on-inverse); + } + } + + &[data-variant="highlight"] { + background-color: var(--seeds-color-highlight); + border-color: var(--seeds-color-highlight); + color: var(--seeds-color-secondary-darker); + + &:hover { + background-color: var(--seeds-color-highlight-dark); + border-color: var(--seeds-color-highlight-dark); + } + } + + &[data-variant="highlight-outlined"] { + background-color: var(--seeds-bg-color-surface); + border-color: var(--seeds-color-highlight); + color: var(--seeds-color-secondary-darker); + + &:hover { + background-color: var(--seeds-color-highlight-dark); + border-color: var(--seeds-color-highlight-dark); + } + } + + &[data-variant="borderless-text"] { + background-color: transparent; + border-color: transparent; + color: var(--seeds-color-primary); + font-weight: normal; + text-decoration: underline; + gap: var(--seeds-s1); + } + + &:not([data-variant$="-outlined"]) { + &[disabled], + &[disabled]:hover { + background-color: var(--seeds-color-gray-450); + border-color: var(--seeds-color-gray-450); + color: white; + } + } + + &[data-variant$="-outlined"] { + &[disabled], + &[disabled]:hover { + background-color: var(--seeds-color-bg-color-surface); + border-color: var(--seeds-color-gray-450); + color: var(--seeds-color-gray-450); + } + } + + &.has-lead-icon { + padding-inline-start: var(--button-icon-side-padding); + } + + &.has-tail-icon { + padding-inline-end: var(--button-icon-side-padding); + } + + & > * { + align-self: center; + } + + & > .seeds-icon { + font-size: var(--button-icon-size-percentage); + } + + &:focus-visible { + outline: var(--seeds-focus-ring-outline); + box-shadow: var(--seeds-focus-ring-box-shadow); + } +} diff --git a/src/actions/Button.tsx b/src/actions/Button.tsx new file mode 100644 index 0000000..55d6fe7 --- /dev/null +++ b/src/actions/Button.tsx @@ -0,0 +1,128 @@ +import React, { useContext } from "react" +import { + NavigationContext, + isExternalLink, + shouldShowExternalLinkIcon, +} from "../global/NavigationContext" +import Icon from "../icons/Icon" + +import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons" + +import "./Button.scss" + +export interface ButtonProps { + /** Button content */ + children: React.ReactNode + /** Appearance of the component */ + variant?: + | "primary" + | "primary-outlined" + | "secondary" + | "secondary-outlined" + | "alert" + | "alert-outlined" + | "highlight" + | "highlight-outlined" + | "borderless-text" + /** Button size */ + size?: "sm" | "md" | "lg" + /** Icon to show before the label text */ + leadIcon?: React.ReactNode + /** Icon to show after the label text */ + tailIcon?: React.ReactNode + /** Set to true if you don't want external links to show a related icon */ + hideExternalLinkIcon?: boolean + /** Event handler for the button click */ + onClick?: (e: React.MouseEvent) => void + /** Use an `` tag instead of ` + +export const buttonVariants = () => ( + <> + +
+ + + + + +
+
+ + + + + +
+ +
+ + + + + +
+
+ + + + + +
+ +
+ + + + + +
+
+ + + + + +
+ + +) + +export const linkButtons = () => ( +
+ + +
+) + +export const buttonsWithIcons = () => ( +
+ + +
+) + +export const buttonsBorderlessText = () => ( + <> +
+ + +
+
+ {" "} + + +
+ +) diff --git a/src/actions/__tests__/Button.test.tsx b/src/actions/__tests__/Button.test.tsx new file mode 100644 index 0000000..d9150ee --- /dev/null +++ b/src/actions/__tests__/Button.test.tsx @@ -0,0 +1,71 @@ +import { render, cleanup, fireEvent } from "@testing-library/react" +import Button from "../Button" + +import Icon from "../../icons/Icon" +import { faHeart } from "@fortawesome/free-solid-svg-icons" + +afterEach(cleanup) + +describe(" + ) + expect(getByText(content)).toBeInTheDocument() + expect( + container.querySelector("#test-button.test-class[data-variant='primary']") + ).not.toBeNull() + + fireEvent.click(getByText(content)) + + expect(wasClicked).toBeTruthy() + }) + + it("displays an anchor with an href", () => { + const content = "Button Label" + const { getByRole, container } = render() + + expect(getByRole("link", { name: content })).toHaveAttribute( + "href", + "/my-page" + ) + expect(container.querySelector("svg")).toBeNull() + }) + + it("displays external links with an icon", () => { + const content = "Button Label" + const { getByRole, container } = render() + + expect(getByRole("link", { name: content })).toHaveAttribute( + "href", + "https://example.com" + ) + expect(container.querySelector("svg[data-icon='arrow-up-right-from-square']")).toBeVisible() + }) + + it("displays external links with a custom icon", () => { + const content = "Button Label" + const { getByRole, container } = render() + + expect(getByRole("link", { name: content })).toHaveAttribute( + "href", + "https://example.com" + ) + expect(container.querySelector("svg[data-icon='heart']")).toBeVisible() + }) + + it("displays external links with no icon", () => { + const content = "Button Label" + const { getByRole, container } = render() + + expect(getByRole("link", { name: content })).toHaveAttribute( + "href", + "https://example.com" + ) + expect(container.querySelector("svg")).toBeNull() + }) +}) diff --git a/src/global/NavigationContext.tsx b/src/global/NavigationContext.tsx new file mode 100644 index 0000000..528b6b4 --- /dev/null +++ b/src/global/NavigationContext.tsx @@ -0,0 +1,47 @@ +import React, { + createContext, + FunctionComponent, + AnchorHTMLAttributes, + DetailedHTMLProps, +} from "react" + +export const isExternalLink = (href: string) => { + return href.startsWith("http://") || href.startsWith("https://") +} + +export const isInternalLink = (href: string) => { + return href.startsWith("/") && !href.startsWith("//") +} + +export const shouldShowExternalLinkIcon = ({ + href, + tailIcon, + hideExternalLinkIcon, +}: { + href?: string | undefined + tailIcon?: React.ReactNode + hideExternalLinkIcon?: boolean +}) => { + return href && !tailIcon && isExternalLink(href) && !hideExternalLinkIcon +} + +type DefaultLinkProps = DetailedHTMLProps< + AnchorHTMLAttributes, + HTMLAnchorElement +> + +export interface LinkProps extends DefaultLinkProps { + className?: string +} + +export interface NavigationContextProps { + LinkComponent: FunctionComponent +} + +export const NavigationContext = createContext({ + LinkComponent: (props) => ( +
+ {props.children} + + ), +}) diff --git a/src/global/tokens/borders.scss b/src/global/tokens/borders.scss index cb00cce..74ff5b6 100644 --- a/src/global/tokens/borders.scss +++ b/src/global/tokens/borders.scss @@ -14,4 +14,7 @@ --seeds-border-8: 8px; --seeds-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + + --seeds-focus-ring-outline: 2px solid var(--seeds-color-white); + --seeds-focus-ring-box-shadow: 0px 0px 3px 3px var(--seeds-color-accent-cool); } diff --git a/src/global/tokens/colors.scss b/src/global/tokens/colors.scss index d8a3ab0..def7943 100644 --- a/src/global/tokens/colors.scss +++ b/src/global/tokens/colors.scss @@ -104,6 +104,13 @@ --seeds-color-warn-dark: var(--seeds-color-yellow-700); --seeds-color-warn-darker: var(--seeds-color-yellow-900); + /* Highlight – by default mirrors Warn */ + --seeds-color-highlight: var(--seeds-color-yellow-500); + --seeds-color-highlight-light: var(--seeds-color-yellow-300); + --seeds-color-highlight-lighter: var(--seeds-color-yellow-100); + --seeds-color-highlight-dark: var(--seeds-color-yellow-700); + --seeds-color-highlight-darker: var(--seeds-color-yellow-900); + /* Accent (Cool) */ --seeds-color-accent-cool: var(--seeds-color-cyan-500); --seeds-color-accent-cool-light: var(--seeds-color-cyan-300);