Skip to content

Commit

Permalink
refactor(button)!: convert to CSS modules (#288)
Browse files Browse the repository at this point in the history
* refactor(button)!: convert to CSS modules

* refactor(button): convert to Typescript

* fix(button): fix issue with scrim

* fix(button): use -webkit-transform to target Safari only for icon jank bug

* chore(button): add changeset file

* chore(button): fix release type in changeset

* fix(button): delete old .js file

* chore(button): clean up duplicate text-icon value, sort theme styles

* docs(button): move comment on brand-neutral theme value

* fix(button): fix button className test

* fix: temporarily backfill g-btn className in consuming packages

* fix(case-study-slider): backfill g-btn className

* style(hero): fix unnecessary curly braces

Co-authored-by: Jeff Escalante <jescalan@users.noreply.github.com>

* fix: replace relative imports for product type with imports from product-meta

* fix(button): use , not any, to better track TS debt

* fix(button): cleanup type issues

* fix(button): cleanup type issues again

* fix(button): fix issue with typecasting IconProps

* fix(button): clean up typecast now that useHover is TS

* fix(button): fix inaccuracy in button type

* docs(button): add task link to comment on

Co-authored-by: Jeff Escalante <jescalan@users.noreply.github.com>
  • Loading branch information
zchsh and jescalan committed Aug 31, 2021
1 parent ac334b5 commit b0fd753
Show file tree
Hide file tree
Showing 27 changed files with 626 additions and 703 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-wombats-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hashicorp/react-product-downloads-page': patch
---

Patches a minor type issue with HashiCorpProduct, which was previously imported from outside the package. Now imports from platform-product-meta.
5 changes: 5 additions & 0 deletions .changeset/dry-forks-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hashicorp/react-alert-banner': patch
---

Patches a minor type issue with HashiCorpProduct, which was previously imported from outside the package. Now imports from platform-product-meta.
10 changes: 10 additions & 0 deletions .changeset/early-jobs-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@hashicorp/react-button': major
---

Converts Button to CSS modules.

- 💥✨ BREAKING CHANGE: Refactored to CSS modules.
- Consumers will need to remove any `@hashicorp/react-button/style.css` imports.
- No longer renders a `g-btn` className. Does however accept a `className` prop, so that we can continue to meet override use cases.
- 🔨 Converts to Typescript
5 changes: 3 additions & 2 deletions packages/alert-banner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import cookie from 'js-cookie'
import slugify from 'slugify'
import classNames from 'classnames'
import VisuallyHidden from '@reach/visually-hidden'
import useProductMeta from '@hashicorp/platform-product-meta'
import useProductMeta, {
Products as HashiCorpProduct,
} from '@hashicorp/platform-product-meta'
import InlineSvg from '@hashicorp/react-inline-svg'
import CloseIcon from './img/close-icon.svg?include'
import fragment from './fragment.graphql'
import s from './style.module.css'
import analytics from './analytics'
import { HashiCorpProduct } from '../../types'

interface AlertBannerProps {
tag: string
Expand Down
29 changes: 29 additions & 0 deletions packages/button/hooks/use-hover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useRef, useState, useEffect, MutableRefObject } from 'react'

function useHover(): [
hoverRef: MutableRefObject<$TSFixMe>,
isHovered: boolean
] {
const [value, setValue] = useState(false)

const ref = useRef(null)
const handleMouseOver = () => setValue(true)
const handleMouseOut = () => setValue(false)

useEffect(() => {
const node = ref.current
if (node) {
node.addEventListener('mouseover', handleMouseOver)
node.addEventListener('mouseout', handleMouseOut)

return () => {
node.removeEventListener('mouseover', handleMouseOver)
node.removeEventListener('mouseout', handleMouseOut)
}
}
}, [ref.current])

return [ref, value]
}

export default useHover
101 changes: 0 additions & 101 deletions packages/button/index.js

This file was deleted.

13 changes: 10 additions & 3 deletions packages/button/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@ import slugify from 'slugify'
import Button from './'

describe('<Button />', () => {
it('should render a `.g-btn` <button/> when no url is passed', () => {
it('should render a className if provided', () => {
const className = 'my-special-button'
const { container } = render(
<Button title="No URL here" className={className} />
)
const rootElem = container.firstChild
expect(rootElem).toHaveClass(className)
})

it('should render a <button/> when no url is passed', () => {
const { container } = render(<Button title="No URL here" />)
const rootElem = container.firstChild
expect(rootElem).toHaveClass('g-btn')
expect(rootElem.tagName).toBe('BUTTON')
})

it('should render a `.g-btn` <a/> with the correct href when passed a url', () => {
const url = 'https://www.hashicorp.com'
const { container } = render(<Button title="Linked Button" url={url} />)
const rootElem = container.firstChild
expect(rootElem).toHaveClass('g-btn')
expect(rootElem.tagName).toBe('A')
expect(rootElem.getAttribute('href')).toBe(url)
})
Expand Down
140 changes: 140 additions & 0 deletions packages/button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import React from 'react'
import slugify from 'slugify'
import fragment from './fragment.graphql'
import classNames from 'classnames'
import useProductMeta from '@hashicorp/platform-product-meta'
import InlineSvg from '@hashicorp/react-inline-svg'
import svgArrowRight from './icons/arrow-right.svg?include'
import svgExternalLink from './icons/external-link.svg?include'
import svgCornerRightDown from './icons/corner-right-down.svg?include'
import svgDownload from './icons/download.svg?include'
import s from './style.module.css'
import sTheme from './theme.module.css'
import useHover from './hooks/use-hover'
import normalizeButtonTheme from './helpers/normalizeButtonTheme.js'
import { Size, LinkType, IconObject, Theme, IconProps } from './types'

const linkTypeToIcon = {
inbound: svgArrowRight,
outbound: svgExternalLink,
anchor: svgCornerRightDown,
download: svgDownload,
}

interface ButtonProps {
title: string
url?: string
label?: string
external?: boolean
theme?: Theme
ga_prefix?: string
onClick?: React.MouseEventHandler<HTMLAnchorElement> &
React.MouseEventHandler<HTMLButtonElement>
disabled?: boolean
className?: string
linkType?: LinkType
icon?: IconObject
size?: Size
/**
* Note: Removing this TS "any" seems like it'll be quite a task.
* One path forward might be to fully separate our
* "ButtonButton" and "AnchorButton", and ask the consumer
* to choose the correct component based on whether they need
* a <a> or <button>.
* Task ref: https://app.asana.com/0/1100423001970639/1200880473915564/f
*/
[attr: string]: $TSFixMe
}

function Button({
title,
url,
label,
external,
theme = {
variant: 'primary',
brand: 'hashicorp',
background: 'light',
},
ga_prefix,
onClick,
disabled,
className,
linkType,
icon,
size = 'medium',
...attrs
}: ButtonProps): React.ReactElement {
const [hoverRef, isHovered] = useHover()
const themeObj = normalizeButtonTheme(theme)
const { themeClass } = useProductMeta(themeObj.brand)
const gaSlug = slugify(title, { lower: true })
const isExternal = url && (linkType === 'outbound' || external)
const Elem = url ? 'a' : 'button'
const iconProps = linkTypeToIcon[linkType]
? ({
svg: linkTypeToIcon[linkType],
position: icon?.position || 'right',
animationId: linkType,
isAnimated: icon?.isAnimated || true,
isHovered,
size,
} as IconProps)
: { ...icon, position: icon?.position || 'right', size, isHovered }
const hasIcon = iconProps && iconProps.svg
const hasRightIcon = hasIcon && iconProps.position !== 'left'
const hasLeftIcon = hasIcon && iconProps.position === 'left'

return (
<Elem
className={classNames(
s.root,
themeClass,
s[`size-${size}`],
sTheme[`variant-${themeObj.variant}`],
{ [sTheme['brand-neutral']]: themeObj.brand === 'neutral' },
sTheme[`background-${themeObj.background}`],
className
)}
data-ga-button={`${ga_prefix ? ga_prefix + ' | ' : ''}${gaSlug}`}
href={url}
ref={hoverRef}
rel={isExternal ? 'noopener' : undefined}
target={isExternal ? '_blank' : undefined}
onClick={onClick}
disabled={disabled}
aria-label={label}
{...attrs}
>
{hasLeftIcon && <Icon {...iconProps} />}
<span className={s.text}>{title}</span>
{hasRightIcon && <Icon {...iconProps} />}
</Elem>
)
}

function Icon({
svg,
position,
animationId,
isAnimated,
isHovered,
size,
}: IconProps) {
return (
<InlineSvg
className={classNames(
s.icon,
s[`size-${size}`],
s[`at-${position}`],
{ [s.isHovered]: isHovered },
{ [s[`animation-${animationId}`]]: isAnimated }
)}
src={svg}
/>
)
}

Button.fragmentSpec = { fragment }

export default Button
Loading

1 comment on commit b0fd753

@vercel
Copy link

@vercel vercel bot commented on b0fd753 Aug 31, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.