Skip to content

Commit

Permalink
feat: add Link component (#48)
Browse files Browse the repository at this point in the history
* 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

* feat: add Link component

* chore: tweak Highlight color scheme

* fix: use `button-` prefix on component tokens

* fix: use `link-` prefix on component tokens

* chore: remove unneeded `disabled` prop

* test: add unit test for Link

* chore: add Link export

* chore: make `href` attribute of Link required

* chore: add screen reader text for external links

* test: resolve link role testing

* chore: make blank target options for Links and Buttons
  • Loading branch information
jaredcwhite authored Sep 8, 2023
1 parent b5bb785 commit b08276b
Show file tree
Hide file tree
Showing 11 changed files with 289 additions and 27 deletions.
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./src/expandableText/ExpandableText"

export { default as Button } from "./src/actions/Button"
export { default as Link } from "./src/actions/Link"
export { default as Alert } from "./src/blocks/Alert"
export { default as Message } from "./src/blocks/Message"
export { default as Toast } from "./src/blocks/Toast"
Expand Down
18 changes: 16 additions & 2 deletions src/actions/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useContext } from "react"
import {
NavigationContext,
ExternalLinkScreenReaderText,
isExternalLink,
shouldShowExternalLinkIcon,
} from "../global/NavigationContext"
Expand Down Expand Up @@ -36,6 +37,8 @@ export interface ButtonProps {
onClick?: (e: React.MouseEvent) => void
/** Use an `<a href>` tag instead of `<button>` for a hyperlink */
href?: string
/** Set external link target if it meets accessibility criteria */
newWindowTarget?: boolean
/** HTML button type */
type?: "button" | "submit" | "reset"
/** Set to true to disable the button */
Expand All @@ -50,6 +53,11 @@ export interface ButtonProps {
className?: string
}

// internal extended interface
interface ButtonPropsWithTarget extends ButtonProps {
target?: string
}

const setupButtonProps = (props: ButtonProps) => {
const classNames = ["seeds-button"]

Expand All @@ -69,6 +77,7 @@ const setupButtonProps = (props: ButtonProps) => {
"data-size": props.size || "md",
id: props.id,
className: classNames.join(" "),
target: props.newWindowTarget ? "_blank" : undefined,
"aria-label": props.ariaLabel,
"aria-hidden": props.ariaHidden,
tabIndex: props.ariaHidden ? -1 : null,
Expand All @@ -85,9 +94,14 @@ const ButtonElement = (props: ButtonProps) => {
return <button {...props} />
}

const LinkButton = (props: ButtonProps) => {
const LinkButton = (props: ButtonPropsWithTarget) => {
if (props.href && isExternalLink(props.href)) {
return <a target="_blank" {...props} />
return (
<a {...props}>
{props.children}
{props.target === "_blank" && <ExternalLinkScreenReaderText />}
</a>
)
} else {
const { LinkComponent } = useContext(NavigationContext)
return <LinkComponent {...props} />
Expand Down
19 changes: 19 additions & 0 deletions src/actions/Link.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.seeds-link, .seeds-prose a {
text-decoration: underline;
}

.seeds-link {
--link-interior-gap: var(--seeds-s1);
--link-icon-size-percentage: 75%;

display: inline-flex;
gap: var(--link-interior-gap);

& > * {
align-self: center;
}

& > .seeds-icon {
font-size: var(--link-icon-size-percentage);
}
}
82 changes: 82 additions & 0 deletions src/actions/Link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useContext } from "react"
import {
ExternalLinkScreenReaderText,
NavigationContext,
isExternalLink,
shouldShowExternalLinkIcon,
} from "../global/NavigationContext"

import Icon from "../icons/Icon"
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"

import "./Link.scss"

export interface LinkProps {
/** Link content */
children: React.ReactNode
/** URL to link to */
href: string
/** 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
/** Set external link target if it meets accessibility criteria */
newWindowTarget?: boolean
/** Set to true to hide the link from the accessibility tree */
ariaHidden?: boolean
/** Accessible label if link doesn't contain text content */
ariaLabel?: string
/** Element ID */
id?: string
/** Additional CSS classes */
className?: string
}

const Link = (props: LinkProps) => {
const classNames = ["seeds-link"]

const tailIcon = shouldShowExternalLinkIcon(props) ? (
<Icon icon={faArrowUpRightFromSquare} />
) : (
props.tailIcon
)

if (props.className) classNames.push(props.className)
if (props.leadIcon) classNames.push("has-lead-icon")
if (tailIcon) classNames.push("has-tail-icon")

const additionalProps = {
id: props.id,
className: classNames.join(" "),
"aria-label": props.ariaLabel,
"aria-hidden": props.ariaHidden,
}

if (props.href && isExternalLink(props.href)) {
return (
<a
href={props.href}
target={props.newWindowTarget ? "_blank" : undefined}
{...additionalProps}
>
{props.leadIcon}
{props.children}
{tailIcon}
{props.newWindowTarget && <ExternalLinkScreenReaderText />}
</a>
)
} else {
const { LinkComponent } = useContext(NavigationContext)
return (
<LinkComponent href={props.href} {...additionalProps}>
{props.leadIcon}
{props.children}
{tailIcon}
</LinkComponent>
)
}
}

export default Link
23 changes: 14 additions & 9 deletions src/actions/__stories__/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export const defaultButton = () => <Button onClick={() => alert("Clicked!")}>Def

export const buttonVariants = () => (
<>
<HeadingGroup size="2xl" heading="sm" subheading="Small Size" />
<div style={{marginTop: "1rem"}}>
<HeadingGroup size="2xl" heading="sm" subheading="Small Size" />
<div style={{ marginTop: "1rem" }}>
<Button variant="primary" size="sm">
Primary Button
</Button>
Expand All @@ -40,7 +40,7 @@ export const buttonVariants = () => (
Disabled Button
</Button>
</div>
<div style={{marginBottom: "2rem"}}>
<div style={{ marginBottom: "2rem" }}>
<Button variant="primary-outlined" size="sm">
Primary Button
</Button>
Expand All @@ -57,8 +57,8 @@ export const buttonVariants = () => (
Disabled Button
</Button>
</div>
<HeadingGroup size="2xl" heading="md" subheading="Medium Size" />
<div style={{marginTop: "1rem"}}>
<HeadingGroup size="2xl" heading="md" subheading="Medium Size" />
<div style={{ marginTop: "1rem" }}>
<Button variant="primary" size="md">
Primary Button
</Button>
Expand All @@ -75,7 +75,7 @@ export const buttonVariants = () => (
Disabled Button
</Button>
</div>
<div style={{marginBottom: "2rem"}}>
<div style={{ marginBottom: "2rem" }}>
<Button variant="primary-outlined" size="md">
Primary Button
</Button>
Expand All @@ -92,8 +92,8 @@ export const buttonVariants = () => (
Disabled Button
</Button>
</div>
<HeadingGroup size="2xl" heading="lg" subheading="Large Size" />
<div style={{marginTop: "1rem"}}>
<HeadingGroup size="2xl" heading="lg" subheading="Large Size" />
<div style={{ marginTop: "1rem" }}>
<Button variant="primary" size="lg">
Primary Button
</Button>
Expand Down Expand Up @@ -134,7 +134,12 @@ export const buttonVariants = () => (
export const linkButtons = () => (
<div style={{ display: "flex", gap: "1rem" }}>
<Button href="/test">Internal Link</Button>
<Button href="https://www.exygy.com">External Link</Button>
<Button href="https://www.exygy.com" hideExternalLinkIcon>
External Link (Same Window)
</Button>
<Button href="https://www.exygy.com" newWindowTarget>
External Link (New Window)
</Button>
</div>
)

Expand Down
15 changes: 15 additions & 0 deletions src/actions/__stories__/Link.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ArgsTable } from "@storybook/addon-docs"
import Link from "../Link"

# &lt;Link /&gt;

## Properties

<ArgsTable of={Link} />

## Theme Variables

| Name | Description | Default |
| ----------------------------- | -------------------------- | ------------ |
| `--link-interior-gap` | Space between icons/text | `--seeds-s1` |
| `--link-icon-size-percentage` | Relative size to base font | `75%` |
47 changes: 47 additions & 0 deletions src/actions/__stories__/Link.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from "react"

import Link from "../Link"

import Icon from "../../icons/Icon"
import { faHeart } from "@fortawesome/free-solid-svg-icons"

import MDXDocs from "./Link.docs.mdx"

export default {
title: "Actions/Link",
component: Link,
parameters: {
docs: {
page: MDXDocs,
},
},
}

export const basicLink = () => (
<p>
Hello world. <Link href="#">This is a link.</Link>
</p>
)

export const externalLink = () => (
<div style={{ display: "flex", gap: "1rem" }}>
<Link href="/test">Internal Link</Link>
<Link href="https://www.exygy.com" hideExternalLinkIcon>
External Link (Same Window)
</Link>
<Link href="https://www.exygy.com" newWindowTarget>
External Link (New Window)
</Link>
</div>
)

export const linksWithIcons = () => (
<div style={{ display: "flex", gap: "1rem" }}>
<Link href="/test" leadIcon={<Icon icon={faHeart} />}>
Lead Icon
</Link>
<Link href="/test" tailIcon={<Icon icon={faHeart} />}>
Tail Icon
</Link>
</div>
)
35 changes: 19 additions & 16 deletions src/actions/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,19 @@ describe("<Button>", () => {
const content = "Button Label"
const { getByRole, container } = render(<Button href="/my-page">{content}</Button>)

expect(getByRole("link", { name: content })).toHaveAttribute(
"href",
"/my-page"
)
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(<Button href="https://example.com">{content}</Button>)
const { getByRole, container } = render(
<Button href="https://example.com" newWindowTarget>
{content}
</Button>
)

expect(getByRole("link", { name: content })).toHaveAttribute(
expect(getByRole("link", { name: `${content} (opens in a new tab)` })).toHaveAttribute(
"href",
"https://example.com"
)
Expand All @@ -49,23 +50,25 @@ describe("<Button>", () => {

it("displays external links with a custom icon", () => {
const content = "Button Label"
const { getByRole, container } = render(<Button href="https://example.com" tailIcon={<Icon icon={faHeart} />}>{content}</Button>)

expect(getByRole("link", { name: content })).toHaveAttribute(
"href",
"https://example.com"
const { getByRole, container } = render(
<Button href="https://example.com" tailIcon={<Icon icon={faHeart} />}>
{content}
</Button>
)

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(<Button href="https://example.com" hideExternalLinkIcon>{content}</Button>)

expect(getByRole("link", { name: content })).toHaveAttribute(
"href",
"https://example.com"
const { getByRole, container } = render(
<Button href="https://example.com" hideExternalLinkIcon>
{content}
</Button>
)

expect(getByRole("link", { name: content })).toHaveAttribute("href", "https://example.com")
expect(container.querySelector("svg")).toBeNull()
})
})
Loading

0 comments on commit b08276b

Please sign in to comment.