diff --git a/.eslintrc.js b/.eslintrc.js index 916fd5dd..ebd98d0a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,4 +5,12 @@ const ignore = 0; module.exports = { root: true, extends: ['@storybook/eslint-config-storybook'], + overrides: [ + { + files: ['**/*.tsx'], + rules: { + 'react/prop-types': 'off', + }, + }, + ], }; diff --git a/.storybook/main.js b/.storybook/main.js index 53ebee5c..76d23d74 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,8 +1,4 @@ module.exports = { - stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.js'], - addons: [ - '@storybook/addon-essentials', - '@storybook/addon-storysource', - '@storybook/addon-a11y', - ], + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.js', '../src/**/*.stories.tsx'], + addons: ['@storybook/addon-essentials', '@storybook/addon-storysource', '@storybook/addon-a11y'], }; diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 731f90f8..6baad71c 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import React, { FunctionComponent, ComponentProps } from 'react'; import styled, { css } from 'styled-components'; import { color, typography } from './shared/styles'; diff --git a/src/components/AvatarList.tsx b/src/components/AvatarList.tsx index 6cc171ef..93cec799 100644 --- a/src/components/AvatarList.tsx +++ b/src/components/AvatarList.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import React, { ComponentProps, FunctionComponent } from 'react'; import styled from 'styled-components'; diff --git a/src/components/FormErrorState.stories.tsx b/src/components/FormErrorState.stories.tsx new file mode 100644 index 00000000..817f2660 --- /dev/null +++ b/src/components/FormErrorState.stories.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { action, actions as makeActions } from '@storybook/addon-actions'; + +import { + FormErrorState, + PureFormErrorState, + FormErrorStateProps, + PureFormErrorStateProps, + FormErrorStateChildrenArgs, + GetErrorArgs, +} from './FormErrorState'; +// @ts-ignore +import { Button } from './Button'; +// @ts-ignore +import { Input as UnstyledInput } from './Input'; + +export default { + title: 'Design System/forms/FormErrorState', + component: FormErrorState, +}; + +const FormWrapper = styled.div` + padding: 3em 12em; +`; + +const Input = styled(UnstyledInput)` + padding-bottom: 1em; +`; + +const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + action('onClick')(e); +}; + +const Children = ({ + onSubmit: handleSubmit, + getFormErrorFieldProps, +}: FormErrorStateChildrenArgs) => { + // Use whatever form state management you need. This is just an example. + const [input1Value, setInput1Value] = useState(''); + const [input2Value, setInput2Value] = useState(''); + return ( + // @ts-ignore +
+ + !value && `There is an error with this field with value: "${value}"`, + })} + value={input1Value} + onChange={(e: React.ChangeEvent) => { + action('change')(e.target.value); + setInput1Value(e.target.value); + }} + startFocused + appearance="secondary" + label="Label" + hideLabel + /> + `There is an error with this field with value: "${value}"`, + })} + value={input2Value} + onChange={(e: React.ChangeEvent) => { + action('change')(e.target.value); + setInput2Value(e.target.value); + }} + appearance="secondary" + label="Label" + hideLabel + /> + + + ); +}; + +const decorators = [(storyFn: any) => {storyFn()}]; + +export const Default = (args: FormErrorStateProps) => ; +Default.decorators = decorators; +Default.args = { + onSubmit, + children: Children, +}; + +const pureActions = makeActions('onFocus', 'onBlur', 'onMouseEnter', 'onMouseLeave', 'trackErrors'); + +export const PureMultipleErrors = (args: PureFormErrorStateProps) => ( + +); +PureMultipleErrors.decorators = decorators; +PureMultipleErrors.args = { + ...pureActions, + getError: (args: GetErrorArgs) => `There is an error with this field with value: "${args.value}"`, + primaryFieldId: 'input-2', + blurredFieldIds: new Set(['input-1', 'input-2']), + children: Children, +}; diff --git a/src/components/FormErrorState.tsx b/src/components/FormErrorState.tsx new file mode 100644 index 00000000..5785b275 --- /dev/null +++ b/src/components/FormErrorState.tsx @@ -0,0 +1,172 @@ +/* eslint-disable no-unused-expressions */ +import React, { useEffect, useCallback, useState } from 'react'; + +interface FormErrorFieldProps { + onMouseEnter: () => void; + onMouseLeave: () => void; + onFocus: () => void; + onBlur: () => void; + error: (value: string) => string | void; + suppressErrorMessage: boolean; +} + +interface GetFormErrorFieldPropsArgs { + id: string; + validate: (value: string) => string | void; +} + +export interface GetErrorArgs extends GetFormErrorFieldPropsArgs { + value: string; +} + +export interface FormErrorStateChildrenArgs { + onSubmit: Function; + getFormErrorFieldProps: (args: GetFormErrorFieldPropsArgs) => FormErrorFieldProps; +} + +export interface FormErrorStateProps { + onSubmit: Function; + children: (args: FormErrorStateChildrenArgs) => JSX.Element; +} + +export interface PureFormErrorStateProps extends FormErrorStateProps { + primaryFieldId: string; + onMouseEnter: (id: string) => void; + onMouseLeave: (id: string) => void; + onFocus: (id: string) => void; + onBlur: (id: string) => void; + getError: (args: GetErrorArgs) => void; +} + +export const PureFormErrorState = ({ + children, + onSubmit, + onMouseEnter, + onMouseLeave, + onBlur, + onFocus, + getError, + primaryFieldId, +}: PureFormErrorStateProps) => { + const getFormErrorFieldProps = ({ id, validate }: GetFormErrorFieldPropsArgs) => ({ + onMouseEnter: () => onMouseEnter(id), + onMouseLeave: () => onMouseLeave(id), + onFocus: () => onFocus(id), + onBlur: () => onBlur(id), + error: (value: string) => getError({ id, value, validate }), + suppressErrorMessage: !primaryFieldId || primaryFieldId !== id, + }); + + return children({ getFormErrorFieldProps, onSubmit }); +}; + +export const FormErrorState: React.FunctionComponent = ({ + onSubmit, + ...rest +}) => { + const [focusedFieldId, setFocusedFieldId] = useState(undefined); + // The lastInteractionFieldId is used to control error messaging for fields that + // are not active, but had an error after a recent focus. + const [lastInteractionFieldId, setLastInteractionFieldId] = useState(undefined); + const [hoveredFieldId, setHoveredFieldId] = useState(undefined); + // The primary field is the field that's visual cues take precedence over any + // others given the entire form's focused & hover states. + // Use this to control things like error messaging priority. + const [primaryFieldId, setPrimaryFieldId] = useState(undefined); + const [blurredFieldIds, setBlurredFieldIds] = useState(new Set()); + const [erroredFieldIds, setErroredFieldIds] = useState(new Set()); + const [didAttemptSubmission, setDidAttemptSubmission] = useState(false); + + useEffect(() => { + if (hoveredFieldId) setPrimaryFieldId(hoveredFieldId); + else if (focusedFieldId && erroredFieldIds.has(focusedFieldId)) + setPrimaryFieldId(focusedFieldId); + else if (erroredFieldIds.size > 0) setPrimaryFieldId(lastInteractionFieldId); + else if (focusedFieldId) setPrimaryFieldId(focusedFieldId); + else setPrimaryFieldId(undefined); + }, [focusedFieldId, hoveredFieldId, lastInteractionFieldId, erroredFieldIds]); + + // Wrap the submit handler to control form error state once it has been submitted + const handleSubmit = useCallback( + (...args: any[]) => { + setDidAttemptSubmission(true); + onSubmit(...args); + }, + [onSubmit] + ); + + // There are a lot of pieces of state that can affect the callbacks. Rather + // than list each one in every callback which could lead to one being left + // out easily, just regenerate the callbacks when any of them change. + const callbackRegenValues = [ + focusedFieldId, + lastInteractionFieldId, + hoveredFieldId, + primaryFieldId, + blurredFieldIds, + erroredFieldIds, + didAttemptSubmission, + ]; + + const trackErrorsAndValidate = useCallback(({ id, validate, value }) => { + const error = validate(value); + if (error) { + !erroredFieldIds.has(id) && setErroredFieldIds(new Set(erroredFieldIds.add(id))); + } else { + erroredFieldIds.delete(id) && setErroredFieldIds(new Set(erroredFieldIds)); + } + return error; + }, callbackRegenValues); + + const wasFieldTouched = useCallback( + (id: string) => blurredFieldIds.has(id) || didAttemptSubmission, + callbackRegenValues + ); + + const isErrorVisible = useCallback( + (id: string) => wasFieldTouched(id) && erroredFieldIds.has(id), + callbackRegenValues + ); + + const onFocus = useCallback((id: string) => setFocusedFieldId(id), callbackRegenValues); + + const onBlur = useCallback((id: string) => { + !blurredFieldIds.has(id) && setBlurredFieldIds(blurredFieldIds.add(id)); + setLastInteractionFieldId(focusedFieldId); + setFocusedFieldId(undefined); + }, callbackRegenValues); + + // We only care about the hover state of previously blurred fields. + // We don't want to show error tooltips for fields that haven't been + // visited yet. In the case that the form has already had an attempted + // submission, all errors will be visible. + const onMouseEnter = useCallback((id: string) => { + if (isErrorVisible(id)) setHoveredFieldId(id); + }, callbackRegenValues); + + const onMouseLeave = useCallback((id: string) => { + if (isErrorVisible(id)) { + setLastInteractionFieldId(hoveredFieldId); + setHoveredFieldId(undefined); + } + }, callbackRegenValues); + + const getError = useCallback(({ id, value, validate }: GetErrorArgs) => { + return wasFieldTouched(id) && trackErrorsAndValidate({ id, validate, value }); + }, callbackRegenValues); + + return ( + + ); +}; diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 5c555519..f2ae9c4d 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import React, { FunctionComponent } from 'react'; import styled from 'styled-components'; import { icons } from './shared/icons'; diff --git a/src/components/Input.js b/src/components/Input.js index a43f22c9..6b23fc1a 100644 --- a/src/components/Input.js +++ b/src/components/Input.js @@ -1,9 +1,12 @@ -import React from 'react'; +import React, { useEffect, useCallback, useRef, useState, forwardRef } from 'react'; import PropTypes from 'prop-types'; import styled, { css } from 'styled-components'; import { color, typography, spacing } from './shared/styles'; import { jiggle } from './shared/animation'; import { Icon } from './Icon'; +import { Link } from './Link'; +import WithTooltip from './tooltip/WithTooltip'; +import { TooltipMessage } from './tooltip/TooltipMessage'; // prettier-ignore const Label = styled.label` @@ -31,10 +34,9 @@ const LabelWrapper = styled.div` `; // prettier-ignore -const InputText = styled.input.attrs({ type: 'text' })` +const InputEl = styled.input` ::placeholder { color: ${color.mediumdark}; - font-weight: ${typography.weight.bold}; } appearance: none; border:none; @@ -52,10 +54,42 @@ const InputText = styled.input.attrs({ type: 'text' })` &:-webkit-autofill { -webkit-box-shadow: 0 0 0 3em ${color.lightest} inset; } `; -const Error = styled.div` - position: absolute; - right: 0; -`; +const getStackLevelStyling = (props) => { + const radius = 4; + const stackLevelDefinedStyling = css` + position: relative; + ${props.error && `z-index: 1;`} + + &:focus { + z-index: 2; + } + `; + switch (props.stackLevel) { + case 'top': + return css` + border-top-left-radius: ${radius}px; + border-top-right-radius: ${radius}px; + ${stackLevelDefinedStyling} + `; + case 'middle': + return css` + border-radius: 0px; + margin-top: -1px; + ${stackLevelDefinedStyling} + `; + case 'bottom': + return css` + border-bottom-left-radius: ${radius}px; + border-bottom-right-radius: ${radius}px; + margin-top: -1px; + ${stackLevelDefinedStyling} + `; + default: + return css` + border-radius: ${radius}px; + `; + } +}; // prettier-ignore const InputWrapper = styled.div` @@ -64,9 +98,10 @@ const InputWrapper = styled.div` vertical-align: top; width: 100%; - ${InputText} { + ${InputEl} { + position: relative; + ${props => getStackLevelStyling(props)} background: ${color.lightest}; - border-radius: 0; color: ${color.darkest}; font-family: ${props => props.appearance === 'code' && typography.type.code }; font-size: ${props => props.appearance === 'code' ? typography.size.s1 : typography.size.s2 }px; @@ -76,7 +111,7 @@ const InputWrapper = styled.div` &:focus { box-shadow: ${color.primary} 0 0 0 1px inset; } ${props => props.appearance === 'secondary' && css` - box-shadow: ${color.mediumlight} 0 0 0 1px inset; + box-shadow: ${color.border} 0 0 0 1px inset; &:focus { box-shadow: ${color.secondary} 0 0 0 1px inset; } `} @@ -114,33 +149,11 @@ const InputWrapper = styled.div` `} } - ${Error} { - position: absolute; - top: 50%; - right: 1px; - margin-left: 1px; - transform: translate3d(100%, -50%, 0); - transition: all 200ms ease-out; - font-family: ${props => props.appearance === 'code' ? typography.type.code : typography.type.primary } ; - font-size: ${typography.size.s1}px; - line-height: 1em; - opacity: 0; - pointer-events: none; - - background: ${props => - props.appearance !== 'tertiary' && - 'rgba(255,255,255,.9)' }; - color: ${color.negative}; - - ${props => props.appearance === 'tertiary' && css` right: 0; `} - ${props => props.appearance === 'code' && css` - top: -4px; - right: auto; - left: 0; - border-radius: ${spacing.borderRadius.small}px; - padding: 6px; - `} - } + ${props => props.startingType === 'password' && css` + ${InputEl} { + padding-right: 3.8em; + } + `} ${props => props.icon && css` > svg { @@ -149,9 +162,14 @@ const InputWrapper = styled.div` top: 50%; height: 1em; width: 1em; - font-size: ${props.appearance === 'pill' ? 0.75 : 1 }em; - margin-top: -.5em; - z-index: 1; + font-size: ${props.appearance === 'pill' ? '0.75em' : '0.875em'}; + margin-top: -.525em; + z-index: 3; + ${props.appearance === 'pill' ? css` + left: 0.8em; + ` : css ` + left: ${props.appearance === 'tertiary' ? 0 : 1.07 }em; + `} background: transparent; @@ -161,57 +179,21 @@ const InputWrapper = styled.div` } } - ${InputText}:focus + svg path { + ${InputEl}:focus + svg path { fill: ${color.darker}; } - ${InputText} { - padding-left: 2.75em; + ${InputEl} { + padding-left: 2.78em; ${props.appearance === 'pill' && css` padding-left: 2.4em; `}; ${props.appearance === 'tertiary' && css` padding-left: 1.75em; `}; } - > svg { left: ${props.appearance === 'tertiary' ? 0 : 0.8 }em; } - `} ${props => props.error && css` - ${Error} { - color: ${color.negative}; - background: none; - transform: translate3d(0%, -50%, 0); - opacity: 1; - padding: .25em 1.25em .25em .5em; - } - - ${InputText}:hover + ${Error}, - ${InputText}:focus + ${Error} { - opacity: 0; - transform: translate3d(100%, -50%, 0); - padding: 0; - } - - ${props.focused && css` - ${Error} { - opacity: 0; - transform: translate3d(100%, -50%, 0); - } - `} - - ${props.appearance === 'code' && css` - ${Error} { - opacity: 0; - } - ${InputText}:hover + ${Error}, - ${InputText}:focus + ${Error} { - transform: translate3d(0%, -100%, 0); - opacity: 1; - padding: .25em 1.25em .25em .5em; - } - `} - ${props.appearance !== 'tertiary' && css` - ${InputText} { + ${InputEl} { box-shadow: ${color.negative} 0 0 0 1px inset; &:focus { box-shadow: ${color.negative} 0 0 0 1px inset !important; } } @@ -245,79 +227,207 @@ const InputContainer = styled.div` `} `; -export function Input({ - id, - value, - label, - hideLabel, - orientation, - icon, - error, - appearance, - className, - focused, - lastErrorValue, - ...props -}) { - const errorId = `${id}-error`; - let errorMessage = error; +const ErrorTooltip = styled(WithTooltip)` + width: 100%; +`; + +const ErrorTooltipMessage = styled(TooltipMessage)` + width: 170px; +`; + +const Action = styled.div` + position: absolute; + right: 0; + min-width: 45px; + top: 50%; + transform: translateY(-50%); + font-weight: bold; + font-size: 11px; + z-index: 2; +`; + +const getErrorMessage = ({ error, value, lastErrorValue }) => { + let errorMessage = typeof error === 'function' ? error(value) : error; if (lastErrorValue) { if (value !== lastErrorValue) { errorMessage = null; } } + return errorMessage; +}; - return ( - - - - - - - {icon && } - - {error} - - - ); -} +export const PureInput = forwardRef( + ( + { + id, + value, + label, + hideLabel, + orientation, + icon, + error, + appearance, + className, + lastErrorValue, + startingType, + type, + onActionClick, + stackLevel, + suppressErrorMessage, + ...props + }, + ref + ) => { + const [errorMessage, setErrorMessage] = useState( + getErrorMessage({ error, value, lastErrorValue }) + ); + const errorId = `${id}-error`; + + useEffect(() => { + setErrorMessage(getErrorMessage({ error, value, lastErrorValue })); + }, [value, error, lastErrorValue]); + + const inputEl = ( + + ); + + return ( + + + + + + + {icon && } + {/** + The tooltip is rendered regardless of the presence of an error. + This is done to preserve the focus state of the Input when it is + used inside of a form that can choose when to show/hide error + states based on various factors. + */} + + } + > + {inputEl} + + + {startingType === 'password' && ( + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + + {type === 'password' ? 'Show' : 'Hide'} + + + )} + + + ); + } +); -Input.propTypes = { +PureInput.propTypes = { id: PropTypes.string.isRequired, value: PropTypes.string, appearance: PropTypes.oneOf(['default', 'secondary', 'tertiary', 'pill', 'code']), + stackLevel: PropTypes.oneOf(['top', 'middle', 'bottom']), label: PropTypes.string.isRequired, hideLabel: PropTypes.bool, orientation: PropTypes.oneOf(['vertical', 'horizontal']), icon: PropTypes.string, - error: PropTypes.string, + error: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + suppressErrorMessage: PropTypes.bool, className: PropTypes.string, - focused: PropTypes.bool, lastErrorValue: PropTypes.string, + startingType: PropTypes.string, + type: PropTypes.string, + onActionClick: PropTypes.func, }; -Input.defaultProps = { +PureInput.defaultProps = { value: '', appearance: 'default', + stackLevel: undefined, hideLabel: false, orientation: 'vertical', icon: null, error: null, + suppressErrorMessage: false, className: null, - focused: false, lastErrorValue: null, + startingType: 'text', + type: 'text', + onActionClick: null, +}; + +export const Input = forwardRef(({ type: startingType, startFocused, ...rest }, ref) => { + const [type, setType] = useState(startingType); + const togglePasswordType = useCallback( + (event) => { + // Make sure this does not submit a form + event.preventDefault(); + event.stopPropagation(); + if (type === 'password') { + setType('text'); + return; + } + setType('password'); + }, + [type, setType] + ); + // Outside refs take precedence + const inputRef = ref || useRef(); + const didFocusOnStart = useRef(false); + useEffect(() => { + if (inputRef && inputRef.current && startFocused && !didFocusOnStart.current) { + inputRef.current.focus(); + didFocusOnStart.current = true; + } + }, [inputRef, inputRef.current, didFocusOnStart, didFocusOnStart.current]); + + return ( + + ); +}); + +Input.propTypes = { + startFocused: PropTypes.bool, + type: PropTypes.string, +}; + +Input.defaultProps = { + startFocused: false, + type: 'text', }; diff --git a/src/components/Input.stories.js b/src/components/Input.stories.js index 59b52c11..15c6e014 100644 --- a/src/components/Input.stories.js +++ b/src/components/Input.stories.js @@ -1,17 +1,21 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { action } from '@storybook/addon-actions'; +import styled from 'styled-components'; -import { Input } from './Input'; +import { Input as UnstyledInput, PureInput as UnstyledPureInput } from './Input'; +import { Link } from './Link'; const onChange = action('change'); export default { title: 'Design System/forms/Input', - component: Input, + component: UnstyledInput, }; -export const Basic = args => ; +export const Basic = (args) => ; Basic.args = { + id: 'Basic', label: 'label', value: 'value', appearance: 'pill', @@ -19,72 +23,44 @@ Basic.args = { hideLabel: false, }; -export const All = () => ( -
- - - - - - - - -
-); +const Form = styled.form` + padding: 3em 12em; +`; -export const Default = () => ( -
+const DarkForm = styled(Form)` + background: #eeeeee; +`; + +const Input = styled(UnstyledInput)` + padding-top: 1em; +`; + +const PureInput = styled(UnstyledPureInput)` + padding-top: 1em; +`; + +const ErrorInput = styled(Input)` + padding-top: 2em; +`; + +const All = ({ appearance }) => ( + <> + ( label="Input with value" hideLabel onChange={onChange} + appearance={appearance} /> ( hideLabel disabled onChange={onChange} + appearance={appearance} /> ( hideLabel icon="email" onChange={onChange} + appearance={appearance} /> - e.preventDefault()} hideLabel - placeholder="Error with icon" - icon="email" - error="There's a snake in my boots" + icon="key" onChange={onChange} + appearance={appearance} /> -
-); - -export const Secondary = () => ( -
- - - + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + This is an error with a link in it + + } onChange={onChange} + appearance={appearance} /> - - - -
+ +); + +All.propTypes = { + appearance: PropTypes.string, +}; + +All.defaultProps = { + appearance: undefined, +}; + +export const Default = () => ( + + + +); + +export const Secondary = () => ( +
+ + ); export const Tertiary = () => ( -
- + + +); + +export const Stacked = () => ( + + - - - - - + ); export const Pill = () => ( - ( ); export const Code = () => ( -
+ ( onChange={onChange} label="horizontal" /> -
+ ); diff --git a/src/components/Link.stories.js b/src/components/Link.stories.tsx similarity index 91% rename from src/components/Link.stories.js rename to src/components/Link.stories.tsx index aab84399..177429e0 100644 --- a/src/components/Link.stories.js +++ b/src/components/Link.stories.tsx @@ -4,6 +4,7 @@ import { action } from '@storybook/addon-actions'; import { Icon } from './Icon'; import { Link } from './Link'; +// @ts-ignore import { StoryLinkWrapper } from './StoryLinkWrapper'; const CustomLink = styled(Link)` @@ -19,8 +20,11 @@ export default { component: Link, }; -export const Basic = (args) => ; -Basic.args = { children: 'link text', inverse: false }; +export const Basic: React.FunctionComponent> = () => ( + + link text + +); export const All = () => ( <> diff --git a/src/components/Link.js b/src/components/Link.tsx similarity index 70% rename from src/components/Link.js rename to src/components/Link.tsx index 21ba774d..68f9ec57 100644 --- a/src/components/Link.js +++ b/src/components/Link.tsx @@ -1,12 +1,11 @@ import React, { forwardRef } from 'react'; -import PropTypes from 'prop-types'; import styled, { css } from 'styled-components'; import { darken } from 'polished'; import { Icon } from './Icon'; import { color } from './shared/styles'; -const LinkInner = styled.span` +const LinkInner = styled.span<{ withArrow: boolean }>` ${(props) => props.withArrow && css` @@ -21,7 +20,15 @@ const LinkInner = styled.span` `}; `; -const StyledLink = styled.a` +interface StyledLinkProps { + containsIcon?: boolean; + secondary?: boolean; + tertiary?: boolean; + nochrome?: boolean; + inverse?: boolean; +} + +const StyledLink = styled.a` display: inline-block; transition: transform 150ms ease-out, color 150ms ease-out; text-decoration: none; @@ -127,78 +134,71 @@ const LinkButton = styled.button` outline: inherit; `; +/** + * Links can contains text and/or icons. Be careful using only icons, you must provide a text alternative via aria-label for accessibility. + */ +export type LinkProps = React.ComponentProps & { + withArrow?: boolean; + isButton?: boolean; + LinkWrapper?: React.ComponentType; +}; + // The main purpose of this component is to strip certain props that get passed // down to the styled component, so that we don't end up passing them to a // tag which would throw warnings for non-standard props. const LinkComponentPicker = forwardRef( ( - { containsIcon, inverse, isButton, LinkWrapper, nochrome, secondary, tertiary, ...rest }, + { + containsIcon, + inverse, + isButton, + LinkWrapper, + nochrome, + secondary, + tertiary, + ...rest + }: LinkProps, ref ) => { // Use the UnstyledLink here to avoid duplicating styles and creating // specificity conflicts by first rendering the StyledLink higher up the chain // and then re-rendering it through the 'as' prop. - const LinkComponent = isButton ? LinkButton : LinkWrapper || UnstyledLink; - return ; + /* eslint no-else-return: ["error", { allowElseIf: true }] */ + if (isButton) { + return ; + } else if (LinkWrapper) { + return ; + } + + return ; } ); -const linkStyleProps = { - containsIcon: PropTypes.bool, - inverse: PropTypes.bool, - isButton: PropTypes.bool, - LinkWrapper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - nochrome: PropTypes.bool, - secondary: PropTypes.bool, - tertiary: PropTypes.bool, -}; +export const Link = forwardRef( + ({ children, withArrow, ...rest }, ref) => { + const content = ( + <> + + {children} + {withArrow && } + + + ); + + return ( + + {content} + + ); + } +); -const linkStyleDefaultProps = { +Link.defaultProps = { + withArrow: false, isButton: false, containsIcon: false, - LinkWrapper: undefined, - inverse: false, - nochrome: false, secondary: false, tertiary: false, -}; - -LinkComponentPicker.propTypes = { - ...linkStyleProps, -}; - -LinkComponentPicker.defaultProps = { - ...linkStyleDefaultProps, -}; - -/** - * Links can contains text and/or icons. Be careful using only icons, you must provide a text alternative via aria-label for accessibility. - */ -export const Link = forwardRef(({ children, withArrow, ...rest }, ref) => { - const content = ( - <> - - {children} - {withArrow && } - - - ); - - return ( - - {content} - - ); -}); - -Link.propTypes = { - children: PropTypes.node, - withArrow: PropTypes.bool, - ...linkStyleProps, -}; - -Link.defaultProps = { - children: null, - withArrow: false, - ...linkStyleDefaultProps, + nochrome: false, + inverse: false, }; diff --git a/src/components/index.js b/src/components/index.js index cfdff00c..ae1ef415 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -16,6 +16,7 @@ export * from './Subheading'; export * from './ProgressDots'; export * from './Spinner'; +export * from './FormErrorState'; export * from './Input'; export * from './Textarea'; export * from './Checkbox'; diff --git a/src/components/modal/WithModal.js b/src/components/modal/WithModal.js index 09742e78..a6fc9028 100644 --- a/src/components/modal/WithModal.js +++ b/src/components/modal/WithModal.js @@ -22,22 +22,24 @@ PureWithModal.propTypes = { children: PropTypes.func.isRequired, }; -export default function WithModal({ startOpen, ...rest }) { +export default function WithModal({ startOpen, onClose, ...rest }) { const [isOpen, onSetIsOpen] = useState(startOpen); const onOpen = () => onSetIsOpen(true); - const onClose = () => { + const handleClose = () => { onSetIsOpen(false); if (onClose) { onClose(); } }; - return ; + return ; } WithModal.propTypes = { startOpen: PropTypes.bool, + onClose: PropTypes.func, }; WithModal.defaultProps = { startOpen: false, + onClose: undefined, }; diff --git a/src/components/tooltip/Tooltip.js b/src/components/tooltip/Tooltip.js index def2a07c..8efcf065 100644 --- a/src/components/tooltip/Tooltip.js +++ b/src/components/tooltip/Tooltip.js @@ -13,6 +13,13 @@ const ArrowSpacing = 8; const Arrow = styled.div` position: absolute; border-style: solid; + /** + * Rather than choosing whether or not to render this component in the + * render function, the visibility is controlled here. The tooltip library + * positions this strangely in some cases if we let the render function + * handle this logic. + */ + display: ${(props) => (props.isVisible ? 'block' : 'none')}; margin-bottom: ${ifPlacementEquals('top', '0', ArrowSpacing)}px; margin-top: ${ifPlacementEquals('bottom', '0', ArrowSpacing)}px; @@ -80,14 +87,14 @@ export function Tooltip({ }) { return ( - {hasChrome && } + {children} ); } Tooltip.propTypes = { - children: PropTypes.node.isRequired, + children: PropTypes.node, hasChrome: PropTypes.bool, /* eslint-disable-next-line */ arrowProps: PropTypes.any, @@ -96,6 +103,7 @@ Tooltip.propTypes = { tooltipRef: PropTypes.any, // eslint-disable-line react/forbid-prop-types }; Tooltip.defaultProps = { + children: undefined, hasChrome: true, placement: 'top', arrowProps: null, diff --git a/src/components/tooltip/Tooltip.stories.js b/src/components/tooltip/Tooltip.stories.js index bc7fbcd5..88b6bf4a 100644 --- a/src/components/tooltip/Tooltip.stories.js +++ b/src/components/tooltip/Tooltip.stories.js @@ -52,7 +52,7 @@ export const basicDefaultRight = () => ( basicDefaultRight.storyName = 'basic, default, right'; export const noChrome = () => ( - + Text ); diff --git a/src/components/tooltip/WithTooltip.js b/src/components/tooltip/WithTooltip.js index 0d238611..eb908e57 100644 --- a/src/components/tooltip/WithTooltip.js +++ b/src/components/tooltip/WithTooltip.js @@ -20,6 +20,10 @@ const ButtonContainer = styled.button` text-decoration: none; `; +const StyledTooltip = styled(Tooltip)` + ${(props) => !props.hasTooltipContent && `display: none;`} +`; + const isDescendantOfAction = (element) => { const { parentElement } = element; @@ -35,7 +39,7 @@ const isDescendantOfAction = (element) => { }; const AsComponent = React.forwardRef( - ({ tagName, onClick, onMouseEnter, onMouseLeave, ...props }, ref) => { + ({ tabIndex, tagName, onClick, onMouseEnter, onMouseLeave, ...props }, ref) => { const Component = tagName || ButtonContainer; const asProps = { ref, @@ -63,7 +67,7 @@ const AsComponent = React.forwardRef( // for non button component, we need to simulate the same behavior as a button if (tagName) { - asProps.tabIndex = 0; + asProps.tabIndex = tabIndex || 0; asProps.onKeyDown = onKeyDown; } return ; @@ -71,6 +75,7 @@ const AsComponent = React.forwardRef( ); AsComponent.propTypes = { + tabIndex: PropTypes.number, tagName: PropTypes.string, onClick: PropTypes.func, onMouseEnter: PropTypes.func, @@ -78,6 +83,7 @@ AsComponent.propTypes = { }; AsComponent.defaultProps = { + tabIndex: undefined, tagName: undefined, onClick: undefined, onMouseEnter: undefined, @@ -130,7 +136,7 @@ function WithTooltip({ arrowRef, placement: tooltipPlacement, }) => ( - {typeof tooltip === 'function' ? tooltip({ onHide: closeTooltip }) : tooltip} - + )} > {({ getTriggerProps, triggerRef }) => ( @@ -169,7 +176,7 @@ WithTooltip.propTypes = { placement: PropTypes.string, modifiers: PropTypes.shape({}), hasChrome: PropTypes.bool, - tooltip: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, + tooltip: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), children: PropTypes.node.isRequired, startOpen: PropTypes.bool, delayHide: PropTypes.number, @@ -181,6 +188,7 @@ WithTooltip.defaultProps = { closeOnClick: false, placement: 'top', modifiers: {}, + tooltip: undefined, hasChrome: true, startOpen: false, delayHide: 100,