From 541a9ba496ef1a4e48ed6fc34efbefe0731cc4fa Mon Sep 17 00:00:00 2001 From: Nikolai Lopin Date: Fri, 21 May 2021 23:41:10 +0200 Subject: [PATCH] fix(a11y): Link inputs and labels when `id` is not provided by the user (#86) --- package-lock.json | 5 + package.json | 1 + .../DatepickerRangeInput.spec.tsx.snap | 2 + .../DatepickerSingleInput.spec.tsx.snap | 1 + src/components/Input/Input.spec.tsx | 17 +- src/components/Input/Input.tsx | 4 +- .../Input/__snapshots__/Input.spec.tsx.snap | 165 +++--------------- src/components/Select/Select.tsx | 6 +- .../Select/__snapshots__/Select.spec.tsx.snap | 25 +++ src/components/Textarea/Textarea.spec.tsx | 19 +- src/components/Textarea/Textarea.tsx | 6 +- .../__snapshots__/Textarea.spec.tsx.snap | 13 ++ src/utils/__mocks__/ids.ts | 3 + src/utils/hooks/useGeneratedId.ts | 8 + src/utils/ids.ts | 5 + src/utils/testing.ts | 3 + 16 files changed, 130 insertions(+), 153 deletions(-) create mode 100644 src/utils/__mocks__/ids.ts create mode 100644 src/utils/hooks/useGeneratedId.ts create mode 100644 src/utils/ids.ts diff --git a/package-lock.json b/package-lock.json index 894d2607f..9bb747bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22496,6 +22496,11 @@ "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", "dev": true }, + "nanoid": { + "version": "3.1.23", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz", + "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==" + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", diff --git a/package.json b/package.json index ddbc33984..d9414c3f0 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@types/react-select": "^3.0.13", "@types/styled-system": "^5.1.9", "date-fns": "^2.11.1", + "nanoid": "^3.1.23", "react-select": "^3.1.0", "react-tether": "^2.0.7", "react-transition-group": "^4.3.0", diff --git a/src/components/Datepicker/__snapshots__/DatepickerRangeInput.spec.tsx.snap b/src/components/Datepicker/__snapshots__/DatepickerRangeInput.spec.tsx.snap index 1176654e0..fa34104f1 100644 --- a/src/components/Datepicker/__snapshots__/DatepickerRangeInput.spec.tsx.snap +++ b/src/components/Datepicker/__snapshots__/DatepickerRangeInput.spec.tsx.snap @@ -176,6 +176,7 @@ exports[`DatepickerRangeInput renders the default props 1`] = ` class="sc-AxirZ c2" data-error="false" data-testid="start-date-input" + id="random" type="text" value="" /> @@ -202,6 +203,7 @@ exports[`DatepickerRangeInput renders the default props 1`] = ` class="sc-AxirZ c2" data-error="false" data-testid="end-date-input" + id="random" tabindex="-1" type="text" value="" diff --git a/src/components/Datepicker/__snapshots__/DatepickerSingleInput.spec.tsx.snap b/src/components/Datepicker/__snapshots__/DatepickerSingleInput.spec.tsx.snap index e33e4cf63..d0c5600bb 100644 --- a/src/components/Datepicker/__snapshots__/DatepickerSingleInput.spec.tsx.snap +++ b/src/components/Datepicker/__snapshots__/DatepickerSingleInput.spec.tsx.snap @@ -120,6 +120,7 @@ exports[`DatepickerSingleInput renders the default props 1`] = ` class="sc-AxjAm c1" data-error="false" data-testid="start-date-input" + id="random" type="text" value="" /> diff --git a/src/components/Input/Input.spec.tsx b/src/components/Input/Input.spec.tsx index cff092ff9..6840afa00 100644 --- a/src/components/Input/Input.spec.tsx +++ b/src/components/Input/Input.spec.tsx @@ -72,8 +72,21 @@ describe('Input', () => { }); }); - it('should set the htmlFor attribute for the label', () => { - expect(render().container.firstChild).toMatchSnapshot(); + describe('link input with the label', () => { + it('uses `id` prop value if passed', () => { + render(); + + expect(screen.getByLabelText('Simple Label')).toHaveAttribute('id', 'test-input-id'); + expect(screen.getByText('Simple Label')).toHaveAttribute('for', 'test-input-id'); + }); + + it('generate id automatically if `id` prop is empty', () => { + render(); + const generatedId = 'random'; + + expect(screen.getByLabelText('Simple Label')).toHaveAttribute('id', generatedId); + expect(screen.getByText('Simple Label')).toHaveAttribute('for', generatedId); + }); }); it('allows to be tested using accessible queries', () => { diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 4dc8684ac..464b86677 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -1,5 +1,6 @@ import React, { forwardRef, useEffect, useState } from 'react'; import { extractClassNameProps, extractWidthProps, extractWrapperMarginProps } from '../../utils/extractProps'; +import { useGeneratedId } from '../../utils/hooks/useGeneratedId'; import { BottomLinedInput } from './BottomLinedInput'; import { BottomLinedInputLabel } from './BottomLinedInputLabel'; import { BoxedInput } from './BoxedInput'; @@ -12,7 +13,8 @@ const Input = forwardRef((props, const { marginProps, restProps: withoutMargin } = extractWrapperMarginProps(withoutClassName); const { widthProps, restProps } = extractWidthProps(withoutMargin); - const { label, onChange, size, id, ...rest } = restProps; + const { label, onChange, size, ...rest } = restProps; + const id = useGeneratedId(props.id); const [hasValue, setHasValue] = useState(rest.value && rest.value.toString().length > 0); diff --git a/src/components/Input/__snapshots__/Input.spec.tsx.snap b/src/components/Input/__snapshots__/Input.spec.tsx.snap index 0e51883c5..d2a7a3975 100644 --- a/src/components/Input/__snapshots__/Input.spec.tsx.snap +++ b/src/components/Input/__snapshots__/Input.spec.tsx.snap @@ -1,150 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Input should set the htmlFor attribute for the label 1`] = ` -.c3 { - position: absolute; - pointer-events: none; - background-color: transparent; - line-height: 1.5; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: calc(100% - 2rem); - -webkit-transition: top 100ms ease-out,left 100ms ease-out, padding 100ms ease-out,font-size 100ms ease-out, color 100ms ease-out,background 100ms ease-out; - transition: top 100ms ease-out,left 100ms ease-out, padding 100ms ease-out,font-size 100ms ease-out, color 100ms ease-out,background 100ms ease-out; - top: 0.75rem; - left: 0.5rem; - padding: 0 0.25rem; - font-size: 1rem; -} - -.c1 { - margin: 0; - box-sizing: border-box; - background: #FFFFFF; - border-radius: 0; - color: #001E3E; - font-size: 1rem; - font-family: "Open Sans",sans-serif; - -webkit-transition: box-shadow 100ms,border 100ms; - transition: box-shadow 100ms,border 100ms; - outline: none; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - width: 100%; - border-radius: 0.25rem; - border: 0.0625rem solid #C6CDD4; - font-size: 1rem; - height: 3rem; - padding: 0 0.75rem; -} - -.c1::-webkit-input-placeholder { - color: #9CA7B4; -} - -.c1::-moz-placeholder { - color: #9CA7B4; -} - -.c1:-ms-input-placeholder { - color: #9CA7B4; -} - -.c1::placeholder { - color: #9CA7B4; -} - -.c1:active, -.c1:focus { - border-color: #096BDB; - box-shadow: inset 0 0 0 0.0625rem #096BDB; -} - -.c1:disabled { - color: #C6CDD4; - border-color: #C6CDD4; - box-shadow: none; - cursor: not-allowed; -} - -.c1:disabled::-webkit-input-placeholder { - color: #C6CDD4; -} - -.c1:disabled::-moz-placeholder { - color: #C6CDD4; -} - -.c1:disabled:-ms-input-placeholder { - color: #C6CDD4; -} - -.c1:disabled::placeholder { - color: #C6CDD4; -} - -.c1:-webkit-autofill, -.c1:-webkit-autofill:hover, -.c1:-webkit-autofill:focus, -.c1:-webkit-autofill:active { - -webkit-text-fill-color: #001E3E; - -webkit-transition: background-color 99999999ms ease 99999999ms; - transition: background-color 99999999ms ease 99999999ms; -} - -.c1 + .c2 { - color: #9CA7B4; - background: #FFFFFF; - background: linear-gradient(0deg,#FFFFFF calc(50% + 0.0625rem),transparent 50%); -} - -.c1:disabled + .c2 { - color: #C6CDD4; -} - -.c1:-webkit-autofill + .c2, -.c1:-webkit-autofill:hover + .c2, -.c1:-webkit-autofill:focus + .c2, -.c1:-webkit-autofill:active + .c2 { - font-weight: 600; - top: -0.5rem; - font-size: 0.75rem; -} - -.c1:focus:not(:disabled) + .c2 { - font-weight: 600; - top: -0.5rem; - font-size: 0.75rem; - color: #096BDB; - background: #FFFFFF; - background: linear-gradient(0deg,#FFFFFF calc(50% + 0.0625rem),transparent 50%); -} - -.c0 { - display: inline-block; - position: relative; - box-sizing: border-box; -} - -
- - -
-`; - exports[`Input variant "bottom-lined" renders 1`] = ` .c1 { margin: 0; @@ -261,6 +116,7 @@ exports[`Input variant "bottom-lined" renders 1`] = ` > @@ -388,6 +244,7 @@ exports[`Input variant "bottom-lined" renders the error state 1`] = ` > @@ -535,11 +392,13 @@ exports[`Input variant "bottom-lined" renders the error state with label and pla > @@ -662,6 +521,7 @@ exports[`Input variant "bottom-lined" renders the inverted style 1`] = ` > @@ -800,10 +660,12 @@ exports[`Input variant "bottom-lined" renders the label 1`] = ` > @@ -946,11 +808,13 @@ exports[`Input variant "bottom-lined" renders the label and the placeholder 1`] > @@ -1073,6 +937,7 @@ exports[`Input variant "bottom-lined" renders the small size 1`] = ` > @@ -1194,6 +1059,7 @@ exports[`Input variant "boxed" renders 1`] = ` > @@ -1321,6 +1187,7 @@ exports[`Input variant "boxed" renders the error state 1`] = ` > @@ -1468,11 +1335,13 @@ exports[`Input variant "boxed" renders the error state with label and placeholde > @@ -1595,6 +1464,7 @@ exports[`Input variant "boxed" renders the inverted style 1`] = ` > @@ -1733,10 +1603,12 @@ exports[`Input variant "boxed" renders the label 1`] = ` > @@ -1879,11 +1751,13 @@ exports[`Input variant "boxed" renders the label and the placeholder 1`] = ` > @@ -2006,6 +1880,7 @@ exports[`Input variant "boxed" renders the small size 1`] = ` > diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 646f4ab3c..df93caee3 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -2,6 +2,7 @@ import React from 'react'; import styled from 'styled-components'; import { compose, margin, MarginProps, width, WidthProps } from 'styled-system'; import { theme } from '../../essentials/theme'; +import { useGeneratedId } from '../../utils/hooks/useGeneratedId'; import { ChevronDownIcon } from '../../icons/basic'; import { extractClassNameProps, extractWidthProps, extractWrapperMarginProps } from '../../utils/extractProps'; import { BaseSelectProps, SelectInput } from './SelectInput'; @@ -39,15 +40,16 @@ const Select: React.FC = props => { const { widthProps, restProps } = extractWidthProps(withoutMargin); const { label, ...rest } = restProps; + const id = useGeneratedId(props.id); return ( - + {label && ( - + {label} )} diff --git a/src/components/Select/__snapshots__/Select.spec.tsx.snap b/src/components/Select/__snapshots__/Select.spec.tsx.snap index 109b0fd33..53f7b88ee 100644 --- a/src/components/Select/__snapshots__/Select.spec.tsx.snap +++ b/src/components/Select/__snapshots__/Select.spec.tsx.snap @@ -98,6 +98,7 @@ exports[`Select renders with default props 1`] = ` >