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 <dt> 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');