diff --git a/.eslintrc b/.eslintrc index 8de3693a9e..b82ae8577b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -133,7 +133,8 @@ "no-use-before-define": "off", "import/no-extraneous-dependencies": "off", "no-unused-vars": "off", - "react/require-default-props": "off" + "react/require-default-props": "off", + "no-restricted-exports": "off" } }, { diff --git a/client/common/Button.tsx b/client/common/Button.tsx deleted file mode 100644 index cc83ad26d0..0000000000 --- a/client/common/Button.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { Link, LinkProps } from 'react-router-dom'; -import { remSize, prop } from '../theme'; - -const kinds = { - primary: 'primary', - secondary: 'secondary' -} as const; - -const displays = { - block: 'block', - inline: 'inline' -} as const; - -const buttonTypes = { - button: 'button', - submit: 'submit' -} as const; - -type Kind = keyof typeof kinds; -type Display = keyof typeof displays; -type ButtonType = keyof typeof buttonTypes; - -type StyledButtonProps = { - kind: Kind; - display: Display; -}; - -type SharedButtonProps = { - /** - * The visible part of the button, telling the user what - * the action is - */ - children?: React.ReactNode; - /** - If the button can be activated or not - */ - disabled?: boolean; - /** - * The display type of the button—inline or block - */ - display?: Display; - /** - * SVG icon to place after child content - */ - iconAfter?: React.ReactNode; - /** - * SVG icon to place before child content - */ - iconBefore?: React.ReactNode; - /** - * If the button content is only an SVG icon - */ - iconOnly?: boolean; - /** - * The kind of button - determines how it appears visually - */ - kind?: Kind; - /** - * Specifying an href will use an to link to the URL - */ - href?: string | null; - /** - * An ARIA Label used for accessibility - */ - 'aria-label'?: string | null; - /** - * Specifying a to URL will use a react-router Link - */ - to?: string | null; - /** - * If using a button, then type is defines the type of button - */ - type?: ButtonType; - /** - * Allows for IconButton to pass `focusable="false"` as a prop for SVGs. - * See @types/react > interface SVGAttributes extends AriaAttributes, DOMAttributes - */ - focusable?: boolean | 'true' | 'false'; -}; - -export type ButtonProps = SharedButtonProps & - React.ButtonHTMLAttributes & - React.AnchorHTMLAttributes & - Partial; - -// The '&&&' will increase the specificity of the -// component's CSS so that it overrides the more -// general global styles -const StyledButton = styled.button` - &&& { - font-weight: bold; - display: ${({ display }) => - display === displays.inline ? 'inline-flex' : 'flex'}; - justify-content: center; - align-items: center; - - width: max-content; - text-decoration: none; - - color: ${({ kind }) => prop(`Button.${kind}.default.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.default.background`)}; - cursor: pointer; - border: 2px solid ${({ kind }) => prop(`Button.${kind}.default.border`)}; - border-radius: 2px; - padding: ${remSize(8)} ${remSize(25)}; - line-height: 1; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.default.foreground`)}; - } - - &:hover:not(:disabled) { - color: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.hover.background`)}; - border-color: ${({ kind }) => prop(`Button.${kind}.hover.border`)}; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; - } - } - - &:active:not(:disabled) { - color: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.active.background`)}; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; - } - } - - &:disabled { - color: ${({ kind }) => prop(`Button.${kind}.disabled.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.disabled.background`)}; - border-color: ${({ kind }) => prop(`Button.${kind}.disabled.border`)}; - cursor: not-allowed; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.disabled.foreground`)}; - } - } - - > * + * { - margin-left: ${remSize(8)}; - } - } -`; - -const StyledInlineButton = styled.button` - &&& { - display: flex; - justify-content: center; - align-items: center; - - text-decoration: none; - - color: ${prop('primaryTextColor')}; - cursor: pointer; - border: none; - line-height: 1; - - svg * { - fill: ${prop('primaryTextColor')}; - } - - &:disabled { - cursor: not-allowed; - } - - > * + * { - margin-left: ${remSize(8)}; - } - } -`; - -/** - * A Button performs an primary action - */ -const Button = ({ - children = null, - display = displays.block, - href, - kind = kinds.primary, - iconBefore = null, - iconAfter = null, - iconOnly = false, - 'aria-label': ariaLabel, - to, - type = buttonTypes.button, - ...props -}: ButtonProps) => { - const hasChildren = React.Children.count(children) > 0; - const content = ( - <> - {iconBefore} - {hasChildren && !iconOnly && {children}} - {iconAfter} - - ); - const StyledComponent: React.ElementType = iconOnly - ? StyledInlineButton - : StyledButton; - - if (href) { - return ( - - {content} - - ); - } - - if (to) { - return ( - - {content} - - ); - } - - return ( - - {content} - - ); -}; - -Button.kinds = kinds; -Button.displays = displays; - -export default Button; diff --git a/client/common/Button.stories.jsx b/client/common/Button/Button.stories.jsx similarity index 94% rename from client/common/Button.stories.jsx rename to client/common/Button/Button.stories.jsx index d11634ae28..8b24d5854d 100644 --- a/client/common/Button.stories.jsx +++ b/client/common/Button/Button.stories.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; -import Button from './Button'; -import { GithubIcon, DropdownArrowIcon, PlusIcon } from './icons'; +import Button from './index'; +import { GithubIcon, DropdownArrowIcon, PlusIcon } from '../icons'; export default { title: 'Common/Button', diff --git a/client/common/Button/Button.styles.ts b/client/common/Button/Button.styles.ts new file mode 100644 index 0000000000..b716f93843 --- /dev/null +++ b/client/common/Button/Button.styles.ts @@ -0,0 +1,89 @@ +import styled from 'styled-components'; +import { remSize, prop } from '../../theme'; +import { StyledButtonProps } from './Button.types'; + +export const StyledButton = styled.button` + &&& { + font-weight: bold; + display: ${({ display }) => + display === 'inline' ? 'inline-flex' : 'flex'}; + justify-content: center; + align-items: center; + width: max-content; + text-decoration: none; + color: ${({ kind }) => prop(`Button.${kind}.default.foreground`)}; + background-color: ${({ kind }) => + prop(`Button.${kind}.default.background`)}; + cursor: pointer; + border: 2px solid ${({ kind }) => prop(`Button.${kind}.default.border`)}; + border-radius: 2px; + padding: ${remSize(8)} ${remSize(25)}; + line-height: 1; + + svg * { + fill: ${({ kind }) => prop(`Button.${kind}.default.foreground`)}; + } + + &:hover:not(:disabled) { + color: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; + background-color: ${({ kind }) => + prop(`Button.${kind}.hover.background`)}; + border-color: ${({ kind }) => prop(`Button.${kind}.hover.border`)}; + + svg * { + fill: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; + } + } + + &:active:not(:disabled) { + color: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; + background-color: ${({ kind }) => + prop(`Button.${kind}.active.background`)}; + + svg * { + fill: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; + } + } + + &:disabled { + color: ${({ kind }) => prop(`Button.${kind}.disabled.foreground`)}; + background-color: ${({ kind }) => + prop(`Button.${kind}.disabled.background`)}; + border-color: ${({ kind }) => prop(`Button.${kind}.disabled.border`)}; + cursor: not-allowed; + + svg * { + fill: ${({ kind }) => prop(`Button.${kind}.disabled.foreground`)}; + } + } + + > * + * { + margin-left: ${remSize(8)}; + } + } +`; + +export const StyledInlineButton = styled.button` + &&& { + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + color: ${prop('primaryTextColor')}; + cursor: pointer; + border: none; + line-height: 1; + + svg * { + fill: ${prop('primaryTextColor')}; + } + + &:disabled { + cursor: not-allowed; + } + + > * + * { + margin-left: ${remSize(8)}; + } + } +`; diff --git a/client/common/Button.test.tsx b/client/common/Button/Button.test.tsx similarity index 96% rename from client/common/Button.test.tsx rename to client/common/Button/Button.test.tsx index 863f2d89b2..303b9173ae 100644 --- a/client/common/Button.test.tsx +++ b/client/common/Button/Button.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render, screen, fireEvent } from '../test-utils'; -import Button from './Button'; +import { render, screen, fireEvent } from '../../test-utils'; +import Button from './index'; const MockIcon = (props: React.SVGProps) => ( diff --git a/client/common/Button/Button.tsx b/client/common/Button/Button.tsx new file mode 100644 index 0000000000..71eac0bb9a --- /dev/null +++ b/client/common/Button/Button.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { StyledButton, StyledInlineButton } from './Button.styles'; +import { ButtonProps, kinds, displays, buttonTypes } from './Button.types'; + +/** + * A Button performs a primary action + */ +const Button = ({ + children = null, + display = displays.block, + href, + kind = kinds.primary, + iconBefore = null, + iconAfter = null, + iconOnly = false, + 'aria-label': ariaLabel, + to, + type = buttonTypes.button, + ...props +}: ButtonProps) => { + const hasChildren = React.Children.count(children) > 0; + const content = ( + <> + {iconBefore} + {hasChildren && !iconOnly && {children}} + {iconAfter} + + ); + const StyledComponent: React.ElementType = iconOnly + ? StyledInlineButton + : StyledButton; + + if (href) { + return ( + + {content} + + ); + } + + if (to) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +}; + +Button.kinds = kinds; +Button.displays = displays; + +export default Button; diff --git a/client/common/Button/Button.types.ts b/client/common/Button/Button.types.ts new file mode 100644 index 0000000000..746aacfd82 --- /dev/null +++ b/client/common/Button/Button.types.ts @@ -0,0 +1,46 @@ +import React from 'react'; +import { LinkProps } from 'react-router-dom'; + +export const kinds = { + primary: 'primary', + secondary: 'secondary' +} as const; + +export const displays = { + block: 'block', + inline: 'inline' +} as const; + +export const buttonTypes = { + button: 'button', + submit: 'submit' +} as const; + +export type Kind = keyof typeof kinds; +export type Display = keyof typeof displays; +export type ButtonType = keyof typeof buttonTypes; + +export type StyledButtonProps = { + kind: Kind; + display: Display; +}; + +type SharedButtonProps = { + children?: React.ReactNode; + disabled?: boolean; + display?: Display; + iconAfter?: React.ReactNode; + iconBefore?: React.ReactNode; + iconOnly?: boolean; + kind?: Kind; + href?: string | null; + 'aria-label'?: string | null; + to?: string | null; + type?: ButtonType; + focusable?: boolean | 'true' | 'false'; +}; + +export type ButtonProps = SharedButtonProps & + React.ButtonHTMLAttributes & + React.AnchorHTMLAttributes & + Partial; diff --git a/client/common/Button/index.ts b/client/common/Button/index.ts new file mode 100644 index 0000000000..93d32c48d7 --- /dev/null +++ b/client/common/Button/index.ts @@ -0,0 +1,2 @@ +export { default } from './Button'; +export * from './Button.types'; diff --git a/client/common/ButtonOrLink.test.tsx b/client/common/ButtonOrLink/ButtonOrLink.test.tsx similarity index 95% rename from client/common/ButtonOrLink.test.tsx rename to client/common/ButtonOrLink/ButtonOrLink.test.tsx index 7b1a6326a4..d92ca26479 100644 --- a/client/common/ButtonOrLink.test.tsx +++ b/client/common/ButtonOrLink/ButtonOrLink.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor, history } from '../test-utils'; -import ButtonOrLink from './ButtonOrLink'; +import { render, screen, fireEvent, waitFor, history } from '../../test-utils'; +import ButtonOrLink from './index'; describe('ButtonOrLink', () => { const clickHandler = jest.fn(); diff --git a/client/common/ButtonOrLink.tsx b/client/common/ButtonOrLink/ButtonOrLink.tsx similarity index 68% rename from client/common/ButtonOrLink.tsx rename to client/common/ButtonOrLink/ButtonOrLink.tsx index 558a1c41a3..b4a4df0303 100644 --- a/client/common/ButtonOrLink.tsx +++ b/client/common/ButtonOrLink/ButtonOrLink.tsx @@ -1,26 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; - -/** - * Accepts all the props of an HTML or