From fd6696371884b9d33100ea5de662599620ef0482 Mon Sep 17 00:00:00 2001 From: Tejas Kumar Date: Mon, 11 Mar 2019 16:52:52 +0100 Subject: [PATCH] **Feature:** GO HARD on a11y (#922) * Fix form field error spacing * Fix cursors on form field button icons * Fix styled import * Add id to Tooltip content for a11y * Fix label behavior on input * Fix cursors on input buttons * Add accessibility properties to inputs * Update examples * Add nanoid * Group input example * Refactor Autocomplete to use Hooks * Add useUniqueId hook * useUniqueId in input * Add role to tooltip * Refactor TextArea to use hooks + context + be accessible * useUniqueId in Autocomplete * Clean up incorrect attribute Thanks! Co-Authored-By: TejasQ * Update snapshots * Spread props on input field * Move useUniqueId * Update deps * Rename ref * Remove duplicate import * Finalize a11y in Textarea * Update Jest (#955) * Factor out DefaultInputProps * Update InputButtons to be accessible/focusable * Use DefaultInputProps in Textarea * Refactor useUniqueId to be simpler * Update uniqueId --- package.json | 6 +- src/Autocomplete/Autocomplete.tsx | 87 +- src/Hint/Hint.tsx | 7 +- src/Input/Input.Button.tsx | 18 +- src/Input/Input.Field.tsx | 84 +- src/Input/Input.tsx | 45 +- src/Input/README.md | 126 +- src/LabelText/LabelText.tsx | 4 +- src/Textarea/README.md | 4 + src/Textarea/Textarea.tsx | 184 +-- src/Tooltip/Tooltip.tsx | 4 +- .../__snapshots__/Tooltip.test.tsx.snap | 1 + src/index.ts | 1 + src/types.ts | 5 + src/useUniqueId/index.ts | 4 + src/utils/mixins.ts | 9 +- yarn.lock | 1431 +++++++++-------- 17 files changed, 1123 insertions(+), 897 deletions(-) create mode 100644 src/useUniqueId/index.ts diff --git a/package.json b/package.json index 7dc717a2f..5af1c57b2 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "emotion-theming": "^10.0.7", "lodash": "^4.17.10", "moment": "^2.22.2", + "nanoid": "^2.0.1", "node-fetch": "^2.3.0", "qs": "6.6.0", "react": "^16.8.1", @@ -94,6 +95,7 @@ "@types/enzyme": "^3.1.12", "@types/jest": "^23.3.0", "@types/lodash": "^4.14.115", + "@types/nanoid": "^1.2.1", "@types/qs": "6.5.1", "@types/react": "^16.8.2", "@types/react-beautiful-dnd": "^10.0.1", @@ -109,7 +111,7 @@ "enzyme-adapter-react-16": "^1.1.1", "enzyme-to-json": "^3.0.0-beta6", "husky": "^0.14.3", - "jest": "^23.5.0", + "jest": "^24.3.0", "jest-enzyme": "^6.0.2", "jest-serializer-enzyme": "^1.0.0", "lint-staged": "^7.2.0", @@ -121,7 +123,7 @@ "react-styleguidist": "^8.0.6", "react-testing-library": "5.6.0", "rimraf": "^2.6.2", - "ts-jest": "^23.0.0", + "ts-jest": "^24.0.0", "ts-loader": "^4.4.2", "tslint": "^5.10.0", "tslint-config-prettier": "^1.13.0", diff --git a/src/Autocomplete/Autocomplete.tsx b/src/Autocomplete/Autocomplete.tsx index 305b315a4..87d86e2d9 100644 --- a/src/Autocomplete/Autocomplete.tsx +++ b/src/Autocomplete/Autocomplete.tsx @@ -4,10 +4,14 @@ import ContextMenu from "../ContextMenu/ContextMenu" import { IContextMenuItem, IContextMenuItem as Item } from "../ContextMenu/ContextMenu.Item" import Input, { InputProps } from "../Input/Input" import Progress from "../Progress/Progress" +import { DefaultInputProps } from "../types" +import { useUniqueId } from "../useUniqueId" import styled from "../utils/styled" import { makeItems } from "./Autocomplete.utils" -export interface AutocompleteProps { +export interface AutocompleteProps extends DefaultInputProps { + /** The ID for this element, for accessibility et al */ + id?: string /** * Label text, rendering the input inside a tag if specified. * The `labelId` props is responsible for specifying for and id attributes. @@ -61,6 +65,7 @@ export interface AutocompleteProps { * Is a result selected? */ selectedResult?: Item + children?: never } const Container = styled(ContextMenu)<{ fullWidth: boolean }>` @@ -69,52 +74,42 @@ const Container = styled(ContextMenu)<{ fullWidth: boolean }>` align-items: center; ` -const initialState = { isContextMenuOpen: false } +export function Autocomplete({ + id, + fullWidth, + tabIndex, + results, + resultIcon, + loading, + noResultsMessage = "No Results Found", + onResultClick, + selectedResult, + children, + ...inputProps +}: AutocompleteProps) { + const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false) + const uniqueId = useUniqueId(id) -export class Autocomplete extends React.Component, Readonly> { - public state = initialState - - private openContextMenu = () => this.setState(() => ({ isContextMenuOpen: true })) - private closeContextMenu = () => this.setState(() => ({ isContextMenuOpen: false })) - - public static defaultProps = { - noResultsMessage: "No results found.", - } - - public render() { - const { - fullWidth, - results, - resultIcon, - loading, - noResultsMessage, - onResultClick, - selectedResult, - children, - ...inputProps - } = this.props - - const { isContextMenuOpen } = this.state - - return ( - onResultClick(item as IContextMenuItem)} - > - {loading && } - - - ) - } + return ( + onResultClick(item as IContextMenuItem)} + > + {loading && } + setIsContextMenuOpen(true)} + onBlur={() => setIsContextMenuOpen(false)} + fullWidth={true} + preset={Boolean(selectedResult)} + {...inputProps} + /> + + ) } export default Autocomplete diff --git a/src/Hint/Hint.tsx b/src/Hint/Hint.tsx index 18bc95423..850513f5d 100644 --- a/src/Hint/Hint.tsx +++ b/src/Hint/Hint.tsx @@ -18,6 +18,7 @@ export interface HintProps extends DefaultProps { */ right?: boolean tooltipPosition?: "left" | "top" | "right" | "bottom" | "smart" + textId?: string } const Container = styled("div")<{ left?: HintProps["left"]; right?: HintProps["right"] }>(({ left, right, theme }) => ({ @@ -31,7 +32,7 @@ const Container = styled("div")<{ left?: HintProps["left"]; right?: HintProps["r ...hoverTooltip, })) -const HintTooltip: React.SFC<{ position: HintProps["tooltipPosition"] }> = props => { +const HintTooltip: React.SFC<{ position: HintProps["tooltipPosition"]; textId: HintProps["textId"] }> = props => { switch (props.position) { case "right": return @@ -51,7 +52,9 @@ const HintTooltip: React.SFC<{ position: HintProps["tooltipPosition"] }> = props const Hint: React.SFC = props => ( - {props.children} + + {props.children} + ) diff --git a/src/Input/Input.Button.tsx b/src/Input/Input.Button.tsx index 168e13959..8bda8d388 100644 --- a/src/Input/Input.Button.tsx +++ b/src/Input/Input.Button.tsx @@ -3,11 +3,13 @@ import CopyToClipboard from "react-copy-to-clipboard" import Icon, { IconName } from "../Icon/Icon" import OperationalContext from "../OperationalContext/OperationalContext" +import { inputFocus } from "../utils" import styled from "../utils/styled" import { height } from "./Input.constants" interface InputButtonBaseProps { onIconClick?: () => void + tabIndex?: number } interface InputButtonPropsWithoutCopy extends InputButtonBaseProps { @@ -24,7 +26,7 @@ interface InputButtonPropsWithCopy extends InputButtonBaseProps { export type InputButtonProps = InputButtonPropsWithoutCopy | InputButtonPropsWithCopy -const Button = styled("div")` +const Button = styled("button")` width: ${height}px; /** Makes sure the button doesn't shrink when inside a flex container */ flex: 0 0 ${height}px; @@ -35,6 +37,16 @@ const Button = styled("div")` align-items: center; justify-content: center; cursor: pointer; + + :focus { + ${inputFocus} + border: 1px solid ${({ theme }) => theme.color.primary}; + } + + /* Don't respond to children's pointer-events */ + * { + pointer-events: none; + } ${({ theme }) => ` background-color: ${theme.color.background.lighter}; border-top-left-radius: ${theme.borderRadius}px; @@ -48,7 +60,7 @@ const Button = styled("div")` `}; ` -const InputButton: React.SFC = ({ icon, copy, value, onIconClick }) => { +const InputButton: React.SFC = ({ tabIndex, icon, copy, value, onIconClick }) => { if (!icon && !copy) { return null } @@ -57,7 +69,7 @@ const InputButton: React.SFC = ({ icon, copy, value, onIconCli {({ pushMessage }) => ( pushMessage({ body: "Copied to clipboard", type: "info" })}> - diff --git a/src/Input/Input.Field.tsx b/src/Input/Input.Field.tsx index f880f59d5..8559941ea 100644 --- a/src/Input/Input.Field.tsx +++ b/src/Input/Input.Field.tsx @@ -11,17 +11,14 @@ const width = 360 const Container = styled("div")<{ fullWidth: InputProps["fullWidth"] - withLabel?: boolean + withLabel: boolean }>` position: relative; align-items: center; justify-content: center; - max-width: 100%; - ${({ fullWidth, withLabel }) => ` - display: ${withLabel ? "flex" : "inline-flex"}; - width: 100%; - max-width: ${fullWidth ? "none" : `${width}px`}; - `}; + display: inline-flex; + width: 100%; + max-width: ${({ fullWidth }) => (fullWidth ? "none" : `${width}px`)}; ` const Field = styled("input")<{ @@ -95,6 +92,8 @@ const ClearButton = styled("div")` ` const InputField: React.SFC = ({ + id, + hint, fullWidth, inputRef, autoFocus, @@ -110,57 +109,56 @@ const InputField: React.SFC = ({ onChange, preset, label, - labelId, clear, icon, copy, onIconClick, + tabIndex, + ...props }) => { const shouldShowIconButton = Boolean(icon) || Boolean(copy) - const forAttributeId = label && labelId const renderButton = () => { if (copy === true) { - return + return } else { - return + return } } return ( - <> - - {shouldShowIconButton && renderButton()} - ) => { - if (onChange) { - onChange(ev.currentTarget.value) - } - }} - clear={clear} - preset={Boolean(preset)} - id={forAttributeId} - withIconButton={shouldShowIconButton} - autoComplete={autoComplete} - /> - {clear && value && ( - - - - )} - + + {shouldShowIconButton && renderButton()} + ) => { + if (onChange) { + onChange(ev.currentTarget.value) + } + }} + clear={clear} + preset={Boolean(preset)} + id={`input-field-${id}`} + withIconButton={shouldShowIconButton} + autoComplete={autoComplete} + {...props} + /> + {clear && value && ( + + + + )} {error ? {error} : null} - + ) } diff --git a/src/Input/Input.tsx b/src/Input/Input.tsx index 9f79ff40f..e79d693b7 100644 --- a/src/Input/Input.tsx +++ b/src/Input/Input.tsx @@ -3,11 +3,14 @@ import * as React from "react" import Hint from "../Hint/Hint" import Icon, { IconName } from "../Icon/Icon" import { LabelText } from "../LabelText/LabelText" -import { DefaultProps } from "../types" +import { DefaultInputProps, DefaultProps } from "../types" +import { useUniqueId } from "../useUniqueId" import { FormFieldControl, FormFieldControls, Label } from "../utils/mixins" import InputField from "./Input.Field" -export interface BaseProps extends DefaultProps { +export interface BaseProps extends DefaultProps, DefaultInputProps { + /** The ID for this element, for accessibility et al */ + id?: string /** Text displayed when the input field has no value. */ placeholder?: string /** The name used to refer to the input, for forms. */ @@ -63,18 +66,42 @@ export interface BasePropsWithoutCopy extends BaseProps { export type InputProps = BasePropsWithCopy | BasePropsWithoutCopy -const Input: React.SFC = ({ fullWidth, label, labelId, hint, onToggle, disabled, ...props }) => { - const forAttributeId = label && labelId - const Field = +const Input: React.SFC = ({ + id, + tabIndex, + fullWidth, + label, + labelId, + hint, + onToggle, + disabled, + ...props +}) => { + const uniqueId = useUniqueId(id) + + const Field = ( + + ) if (label) { return ( -