From 9f02a4fc3572f60dfa63cbdf48bdb3f4582e5285 Mon Sep 17 00:00:00 2001 From: Francisco Barros Date: Wed, 15 Nov 2023 00:04:56 +0000 Subject: [PATCH 1/4] feat(pin-field): accessible input fields --- README.md | 57 +++++++++++++++++----------- lib/src/pin-field/pin-field.spec.tsx | 20 ++++++++++ lib/src/pin-field/pin-field.test.ts | 3 +- lib/src/pin-field/pin-field.tsx | 6 ++- lib/src/pin-field/pin-field.types.ts | 4 +- 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 06f1e23..ce83728 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ React component for entering PIN codes. ![gif](https://user-images.githubusercontent.com/10437171/70847884-f9d35f00-1e69-11ea-8152-1c70eda12137.gif) -*Live demo at https://soywod.github.io/react-pin-field/.* +_Live demo at https://soywod.github.io/react-pin-field/._ ## Installation @@ -17,24 +17,24 @@ npm install react-pin-field ## Usage ```typescript -import PinField from "react-pin-field" +import PinField from "react-pin-field"; ``` ## Props ```typescript type PinFieldProps = { - ref?: React.Ref - className?: string - length?: number - validate?: string | string[] | RegExp | ((key: string) => boolean) - format?: (char: string) => string - onResolveKey?: (key: string, ref?: HTMLInputElement) => any - onRejectKey?: (key: string, ref?: HTMLInputElement) => any - onChange?: (code: string) => void - onComplete?: (code: string) => void - style?: React.CSSProperties -} & React.InputHTMLAttributes + ref?: React.Ref; + className?: string; + length?: number; + validate?: string | string[] | RegExp | ((key: string) => boolean); + format?: (char: string) => string; + onResolveKey?: (key: string, ref?: HTMLInputElement) => any; + onRejectKey?: (key: string, ref?: HTMLInputElement) => any; + onChange?: (code: string) => void; + onComplete?: (code: string) => void; + style?: React.CSSProperties; +} & React.InputHTMLAttributes; const defaultProps = { ref: {current: []}, @@ -42,12 +42,13 @@ const defaultProps = { length: 5, validate: /^[a-zA-Z0-9]$/, format: key => key, + formatAriaLabel: (idx, length) => `pin code ${idx} of ${length}`, onResolveKey: () => {}, onRejectKey: () => {}, onChange: () => {}, onComplete: () => {}, style: {}, -} +}; ``` ### Reference @@ -55,13 +56,13 @@ const defaultProps = { Every input can be controlled thanks to the React reference: ```typescript - +; // reset all inputs -ref.current.forEach(input => (input.value = "")) +ref.current.forEach(input => (input.value = "")); // focus the third input -ref.current[2].focus() +ref.current[2].focus(); ``` ### Style @@ -79,15 +80,25 @@ Length of the code (number of characters). Characters can be validated with a validator. A validator can take the form of: - - a String of allowed characters: `abcABC123` - - an Array of allowed characters: `["a", "b", "c", "1", "2", "3"]` - - a RegExp: `/^[a-zA-Z0-9]$/` - - a predicate: `(char: string) => boolean` +- a String of allowed characters: `abcABC123` +- an Array of allowed characters: `["a", "b", "c", "1", "2", "3"]` +- a RegExp: `/^[a-zA-Z0-9]$/` +- a predicate: `(char: string) => boolean` ### Format -Characters can be formatted with a formatter `(char: string) => -string`. +Characters can be formatted with a formatter `(char: string) => string`. + +### Format Aria Label(s) + +This function is used to generate accessible labels for each input within the +``. By default it renders the string `pin code 1 of 6`, +`pin code 2 of 6`, etc., depending on the actual index of the input field +and the total length of the pin field. + +You can customize the aria-label string by passing your own function. This can +be useful for site internationalisation (i18n) or simply if you want to describe +each input with differently. ### Events diff --git a/lib/src/pin-field/pin-field.spec.tsx b/lib/src/pin-field/pin-field.spec.tsx index d6d1b52..30100f8 100644 --- a/lib/src/pin-field/pin-field.spec.tsx +++ b/lib/src/pin-field/pin-field.spec.tsx @@ -97,3 +97,23 @@ test("fallback events", async () => { expect(handleChangeMock).toHaveBeenCalledTimes(1); expect(handleChangeMock).toHaveBeenCalledWith("a"); }); + +describe("a11y", () => { + test("should have aria-label per input field", () => { + render(); + + expect(screen.getByRole("textbox", {name: /pin code 1 of 3/i})).toBeVisible(); + expect(screen.getByRole("textbox", {name: /pin code 2 of 3/i})).toBeVisible(); + expect(screen.getByRole("textbox", {name: /pin code 3 of 3/i})).toBeVisible(); + }); + + test("should support custom aria-label format", () => { + render( `${i}/${c}`} />); + + screen.debug(); + + expect(screen.getByRole("textbox", {name: "1/3"})).toBeVisible(); + expect(screen.getByRole("textbox", {name: "2/3"})).toBeVisible(); + expect(screen.getByRole("textbox", {name: "3/3"})).toBeVisible(); + }); +}); diff --git a/lib/src/pin-field/pin-field.test.ts b/lib/src/pin-field/pin-field.test.ts index f48140f..e17649d 100644 --- a/lib/src/pin-field/pin-field.test.ts +++ b/lib/src/pin-field/pin-field.test.ts @@ -28,7 +28,7 @@ test("constants", () => { const {NO_EFFECTS, PROP_KEYS, HANDLER_KEYS, IGNORED_META_KEYS} = pinField; expect(NO_EFFECTS).toEqual([]); - expect(PROP_KEYS).toEqual(["autoFocus", "length", "validate", "format", "debug"]); + expect(PROP_KEYS).toEqual(["autoFocus", "length", "validate", "format", "formatAriaLabel", "debug"]); expect(HANDLER_KEYS).toEqual(["onResolveKey", "onRejectKey", "onChange", "onComplete"]); expect(IGNORED_META_KEYS).toEqual(["Alt", "Control", "Enter", "Meta", "Shift", "Tab"]); }); @@ -39,6 +39,7 @@ test("default props", () => { expect(defaultProps).toHaveProperty("length", 5); expect(defaultProps).toHaveProperty("validate", /^[a-zA-Z0-9]$/); expect(defaultProps).toHaveProperty("format"); + expect(defaultProps).toHaveProperty("formatAriaLabel", expect.any(Function)); expect(defaultProps.format("abcABC123@-_[]")).toStrictEqual("abcABC123@-_[]"); expect(defaultProps.onResolveKey("a")).toStrictEqual(undefined); expect(defaultProps).toHaveProperty("onRejectKey"); diff --git a/lib/src/pin-field/pin-field.tsx b/lib/src/pin-field/pin-field.tsx index 6d06103..8972a5f 100644 --- a/lib/src/pin-field/pin-field.tsx +++ b/lib/src/pin-field/pin-field.tsx @@ -15,7 +15,7 @@ import { } from "./pin-field.types"; export const NO_EFFECTS: Effect[] = []; -export const PROP_KEYS = ["autoFocus", "length", "validate", "format", "debug"]; +export const PROP_KEYS = ["autoFocus", "length", "validate", "format", "formatAriaLabel", "debug"]; export const HANDLER_KEYS = ["onResolveKey", "onRejectKey", "onChange", "onComplete"]; export const IGNORED_META_KEYS = ["Alt", "Control", "Enter", "Meta", "Shift", "Tab"]; @@ -24,6 +24,7 @@ export const defaultProps: DefaultProps = { length: 5, validate: /^[a-zA-Z0-9]$/, format: key => key, + formatAriaLabel: (idx: number, codeLength: number) => `pin code ${idx} of ${codeLength}`, onResolveKey: noop, onRejectKey: noop, onChange: noop, @@ -250,7 +251,7 @@ export function useEffectReducer({refs, ...props}: NotifierProps): EffectReducer export const PinField: FC = forwardRef((customProps, fwdRef) => { const props: DefaultProps & InputProps = {...defaultProps, ...customProps}; - const {autoFocus, length: codeLength} = props; + const {autoFocus, formatAriaLabel, length: codeLength} = props; const inputProps: InputProps = omit([...PROP_KEYS, ...HANDLER_KEYS], props); const refs = useRef([]); const effectReducer = useEffectReducer({refs, ...props}); @@ -318,6 +319,7 @@ export const PinField: FC = forwardRef((customProps, fwdRef) => { autoComplete="off" inputMode="text" {...inputProps} + aria-label={formatAriaLabel(idx + 1, codeLength)} key={idx} ref={setRefAtIndex(idx)} autoFocus={hasAutoFocus(idx)} diff --git a/lib/src/pin-field/pin-field.types.ts b/lib/src/pin-field/pin-field.types.ts index 123065b..13ed758 100644 --- a/lib/src/pin-field/pin-field.types.ts +++ b/lib/src/pin-field/pin-field.types.ts @@ -5,6 +5,7 @@ export type PinFieldDefaultProps = { length: number; validate: string | string[] | RegExp | ((key: string) => boolean); format: (char: string) => string; + formatAriaLabel: (idx: number, codeLength: number) => string; onResolveKey: (key: string, ref?: HTMLInputElement) => any; onRejectKey: (key: string, ref?: HTMLInputElement) => any; onChange: (code: string) => void; @@ -19,7 +20,8 @@ export type PinFieldProps = Partial & PinFieldInputProps; export type PinFieldNotifierProps = { refs: React.MutableRefObject; -} & PinFieldDefaultProps & PinFieldInputProps; +} & PinFieldDefaultProps & + PinFieldInputProps; export type PinFieldState = { focusIdx: number; From bf68f48d82fd1cae81b4271dc6d698e3f25d1000 Mon Sep 17 00:00:00 2001 From: Francisco Barros Date: Wed, 15 Nov 2023 00:26:53 +0000 Subject: [PATCH 2/4] feat: improve accessibility for screen reader --- lib/src/pin-field/pin-field.spec.tsx | 30 ++++++++++++++++++++++++---- lib/src/pin-field/pin-field.tsx | 3 +++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/src/pin-field/pin-field.spec.tsx b/lib/src/pin-field/pin-field.spec.tsx index 30100f8..4e65c07 100644 --- a/lib/src/pin-field/pin-field.spec.tsx +++ b/lib/src/pin-field/pin-field.spec.tsx @@ -100,7 +100,7 @@ test("fallback events", async () => { describe("a11y", () => { test("should have aria-label per input field", () => { - render(); + render(); expect(screen.getByRole("textbox", {name: /pin code 1 of 3/i})).toBeVisible(); expect(screen.getByRole("textbox", {name: /pin code 2 of 3/i})).toBeVisible(); @@ -108,12 +108,34 @@ describe("a11y", () => { }); test("should support custom aria-label format", () => { - render( `${i}/${c}`} />); - - screen.debug(); + render( `${i}/${c}`} />); expect(screen.getByRole("textbox", {name: "1/3"})).toBeVisible(); expect(screen.getByRole("textbox", {name: "2/3"})).toBeVisible(); expect(screen.getByRole("textbox", {name: "3/3"})).toBeVisible(); }); + + test("every input has aria-required", () => { + render(); + + expect(screen.getByRole("textbox", {name: /pin code 1 of 3/i})).toHaveAttribute("aria-required", "true"); + expect(screen.getByRole("textbox", {name: /pin code 2 of 3/i})).toHaveAttribute("aria-required", "true"); + expect(screen.getByRole("textbox", {name: /pin code 3 of 3/i})).toHaveAttribute("aria-required", "true"); + }); + + test("every input should have aria-disabled when PinField is disabled", () => { + render(); + + expect(screen.getByRole("textbox", {name: /pin code 1 of 3/i})).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("textbox", {name: /pin code 2 of 3/i})).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("textbox", {name: /pin code 3 of 3/i})).toHaveAttribute("aria-disabled", "true"); + }); + + test("every input should have aria-readonly when PinField is readOnly", () => { + render(); + + expect(screen.getByRole("textbox", {name: /pin code 1 of 3/i})).toHaveAttribute("aria-readonly", "true"); + expect(screen.getByRole("textbox", {name: /pin code 2 of 3/i})).toHaveAttribute("aria-readonly", "true"); + expect(screen.getByRole("textbox", {name: /pin code 3 of 3/i})).toHaveAttribute("aria-readonly", "true"); + }); }); diff --git a/lib/src/pin-field/pin-field.tsx b/lib/src/pin-field/pin-field.tsx index 8972a5f..1545e45 100644 --- a/lib/src/pin-field/pin-field.tsx +++ b/lib/src/pin-field/pin-field.tsx @@ -319,7 +319,10 @@ export const PinField: FC = forwardRef((customProps, fwdRef) => { autoComplete="off" inputMode="text" {...inputProps} + aria-disabled={inputProps.disabled ? "true" : undefined} aria-label={formatAriaLabel(idx + 1, codeLength)} + aria-readonly={inputProps.readOnly ? "true" : undefined} + aria-required="true" key={idx} ref={setRefAtIndex(idx)} autoFocus={hasAutoFocus(idx)} From 47a370b33bbb376b6f585dbebfa226b567a77eba Mon Sep 17 00:00:00 2001 From: Francisco Barros Date: Wed, 15 Nov 2023 09:25:32 +0000 Subject: [PATCH 3/4] docs: add example for formatAriaLabel in App demo --- demo/src/app.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/demo/src/app.tsx b/demo/src/app.tsx index 2ead22c..dea0215 100644 --- a/demo/src/app.tsx +++ b/demo/src/app.tsx @@ -138,6 +138,17 @@ function App() { c.toUpperCase()} /> + {/* TODO: uncomment this code snippet for docs +

Custom input aria-label

+

+ You can customize inputs' aria-labels with your own sentence using{" "} + (idx: number, codeLength: number) => string +

+
+ `custom pin code ${i} of ${c}`} /> +
+ */} +

Events

  • onResolveKey: when a key passes the validator
  • From e4f7f84234f5696eb6a97a8474bc7687360b231f Mon Sep 17 00:00:00 2001 From: Francisco Barros Date: Wed, 15 Nov 2023 22:08:09 +0000 Subject: [PATCH 4/4] chore: update README typo --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ce83728..984008e 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ This function is used to generate accessible labels for each input within the and the total length of the pin field. You can customize the aria-label string by passing your own function. This can -be useful for site internationalisation (i18n) or simply if you want to describe -each input with differently. +be useful for: i) site internationalisation (i18n); ii) simply describing +each input with different semantics than the ones provided by `react-pin-field`. ### Events