Useful React hooks for accessibility

import { useMemo } from 'react';
import { v4 as uuid } from 'uuid';

export function useLabelledBy(): readonly [
  string,
  { 'aria-labelledby': string }
] {
  const uniqueID = useMemo(uuid, [uuid]);
  return [uniqueID, { 'aria-labelledby': uniqueID }];
}

export function useDescribedBy(): readonly [
  string,
  { 'aria-describedby': string }
] {
  const uniqueID = useMemo(uuid, [uuid]);
  return [uniqueID, { 'aria-describedby': uniqueID }];
}

This allows us to create id/aria-labelledby pairs to add as attributes to the label and labelled. And another hook for descriptions with id/aria-describedby attributes.

interface ProductProps {
  name: string;
  description: string;
}
function Product({ name, description, price }: ProductProps) {
  const [labelID, labelledBy] = useLabelledBy();
  const [descriptionID, describedBy] = useDescribedBy();

  return (
    <article {...labelledBy} {...describedBy}>
      <h2 id={labelID}>{name}</h2>
      <p id={descriptionID}>{description}</h2>
      ...
    </article>
  );
}

And then for <dl> which are useful for presenting key-value pairs (e.g. attributes of a product, FAQ questions and answers). Here’s a helper that creates a <dt> and <dd> pair and associates them so they label one another.

import { visuallyHidden } from "./shared.css";

interface TermAndDefinitionProps {
  term: React.ReactNode;
  definition: React.ReactNode;
  termVisuallyHidden?: boolean;
}
function TermAndDefinition(props: TermAndDefinitionProps): JSX.Element {
  const [termID, labelledby] = useLabelledBy();  return (
    <>
      <dt
        id={termID}
        className={props.termVisuallyHidden ? visuallyHidden : undefined}
      >
        {props.term}
      </dt>
      <dd {...labelledby}>{props.definition}</dd>
    </>
  );
}

We could use it like so to present the price and color for our product:

interface ProductProps {
  name: string;
  description: string;
  price: string;
  color: string;
}
function Product({ name, description, price, color }: ProductProps) {
  const [labelID, labelledBy] = useLabelledBy();
  const [descriptionID, describedBy] = useDescribedBy();

  return (
    <article {...labelledBy} {...describedBy}>
      <h2 id={labelID}>{name}</h2>
      <p id={descriptionID}>{description}</h2>
      <dl>
        <TermAndDefinition term="Price:" definition={price} />
        <TermAndDefinition term="Color:" definition={color} />
      </dl>
    </article>
  );
}

This means in our tests we can look up the value for a specific key in the UI. We could use React Testing Library which offers looking elements up their accessible role.

The implicit role for a <dd> is definition, so we can look those up by their accessible name. We have wired the corresponding

to be the label which becomes the accessible name. So testing becomes straightforward.

Say to assert that the price shown is $50:

expect(
  screen.getByRole('definition', { name: 'Price:' })
).toHaveTextContent('$50');

Or the color is red:

expect(
  screen.getByRole('definition', { name: 'Color:' })
).toHaveTextContent('Red');