Skip to content

Commit

Permalink
Merge pull request #84 from FranciscoKloganB/master
Browse files Browse the repository at this point in the history
feat(PinField): increase input accessibility by using WAI-Aria attributes
  • Loading branch information
soywod authored Nov 16, 2023
2 parents 61abac3 + e4f7f84 commit 04dd1b8
Show file tree
Hide file tree
Showing 6 changed files with 99 additions and 27 deletions.
57 changes: 34 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -17,51 +17,52 @@ 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<HTMLInputElement[]>
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<HTMLInputElement>
ref?: React.Ref<HTMLInputElement[]>;
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<HTMLInputElement>;

const defaultProps = {
ref: {current: []},
className: "",
length: 5,
validate: /^[a-zA-Z0-9]$/,
format: key => key,
formatAriaLabel: (idx, length) => `pin code ${idx} of ${length}`,
onResolveKey: () => {},
onRejectKey: () => {},
onChange: () => {},
onComplete: () => {},
style: {},
}
};
```

### Reference

Every input can be controlled thanks to the React reference:

```typescript
<PinField ref={ref} />
<PinField ref={ref} />;

// 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
Expand All @@ -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
`<PinField />`. 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: i) site internationalisation (i18n); ii) simply describing
each input with different semantics than the ones provided by `react-pin-field`.

### Events

Expand Down
11 changes: 11 additions & 0 deletions demo/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ function App() {
<PinField className="pin-field" format={c => c.toUpperCase()} />
</div>

{/* TODO: uncomment this code snippet for docs
<h2 className="display-5 mt-5">Custom input aria-label</h2>
<p className="mb-4 text-muted">
You can customize inputs' aria-labels with your own sentence using{" "}
<code>(idx: number, codeLength: number) =&gt; string</code>
</p>
<div>
<PinField className="pin-field" formatAriaLabel={(i: number, c: number) => `custom pin code ${i} of ${c}`} />
</div>
*/}

<h2 className="display-5 mt-5">Events</h2>
<ul className="mb-4 text-muted">
<li>onResolveKey: when a key passes the validator</li>
Expand Down
42 changes: 42 additions & 0 deletions lib/src/pin-field/pin-field.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,45 @@ test("fallback events", async () => {
expect(handleChangeMock).toHaveBeenCalledTimes(1);
expect(handleChangeMock).toHaveBeenCalledWith("a");
});

describe("a11y", () => {
test("should have aria-label per input field", () => {
render(<PinField length={3} />);

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(<PinField length={3} formatAriaLabel={(i, c) => `${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(<PinField length={3} />);

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(<PinField length={3} disabled />);

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(<PinField length={3} readOnly />);

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");
});
});
3 changes: 2 additions & 1 deletion lib/src/pin-field/pin-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
Expand All @@ -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");
Expand Down
9 changes: 7 additions & 2 deletions lib/src/pin-field/pin-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand All @@ -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,
Expand Down Expand Up @@ -250,7 +251,7 @@ export function useEffectReducer({refs, ...props}: NotifierProps): EffectReducer

export const PinField: FC<Props> = 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<HTMLInputElement[]>([]);
const effectReducer = useEffectReducer({refs, ...props});
Expand Down Expand Up @@ -318,6 +319,10 @@ export const PinField: FC<Props> = 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)}
Expand Down
4 changes: 3 additions & 1 deletion lib/src/pin-field/pin-field.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +20,8 @@ export type PinFieldProps = Partial<PinFieldDefaultProps> & PinFieldInputProps;

export type PinFieldNotifierProps = {
refs: React.MutableRefObject<HTMLInputElement[]>;
} & PinFieldDefaultProps & PinFieldInputProps;
} & PinFieldDefaultProps &
PinFieldInputProps;

export type PinFieldState = {
focusIdx: number;
Expand Down

0 comments on commit 04dd1b8

Please sign in to comment.