DEV Community

Cover image for TypeScript and React: Enforcing Props for Accessibility
Nick Taylor
Nick Taylor Subscriber

Posted on • Updated on • Originally published at nickyt.co

TypeScript and React: Enforcing Props for Accessibility

Recently, I added a small accessibility win to our code base.

fix: now ToggleSwitch component has required label via aria-label or aria-labelledby #2035

Description

Now the <ToggleSwitch /> component requires a label via either the ariaLabel prop or the ariaLabellebBy prop.

See the MDN docs on the aria-label and aria-labelledby attributes.

I came across this fix while working on #1977

What type of PR is this? (check all applicable)

  • [ ] 🍕 Feature
  • [x] 🐛 Bug Fix
  • [ ] 📝 Documentation Update
  • [ ] 🎨 Style
  • [ ] 🧑‍💻 Code Refactor
  • [ ] 🔥 Performance Improvements
  • [ ] ✅ Test
  • [ ] 🤖 Build
  • [ ] 🔁 CI
  • [ ] 📦 Chore (Release)
  • [ ] ⏩ Revert

Fixes #2036 Relates to #1977

Mobile & Desktop Screenshots/Recordings

https://github.com/open-sauced/app/assets/833231/8d947222-902e-41f8-b678-d1d132230ca5

Added tests?

  • [ ] 👍 yes
  • [x] 🙅 no, because they aren't needed
  • [ ] 🙋 no, because I need help

Added to documentation?

  • [ ] 📜 README.md
  • [ ] 📓 docs.opensauced.pizza
  • [ ] 🍕 dev.to/opensauced
  • [ ] 📕 storybook
  • [x] 🙅 no documentation needed

[optional] Are there any post-deployment tasks we need to perform?

[optional] What gif best describes this PR or how it makes you feel?

The nice thing about baking in accessibility wins into components is that it improves the accessibility of the application everywhere the component is used within the app.

The TLDR; is I added two mandatory props to our <ToggleSwitch /> component to enforce a label for the component. However, the challenge was that one of them had to be required, but not both.

The Component before the change

The component before the change had a bunch of props, but there was no label associated with the toggle button which the <ToggleComponent /> component generated.

interface ToggleSwitchProps {
  name: string;
  checked: boolean;
  handleToggle: () => void;
  size?: "sm" | "lg" | "base";
  classNames?: string;
}
Enter fullscreen mode Exit fullscreen mode

Typically, a button will have text associated to it, but in this case, there was no text for the button which was causing the accessibility issue. When no text is present, you have a few options.

  • You can have text that is only visible to screen readers and other assistive technologies. To accomplish this you can create a CSS class, e.g. sr-only to move the text off the screen for sighted users, but since it's still visible in the document object model (DOM), assistive technologies can pick it up.

Note: Tailwind is pretty popular these days, so if you go with this option, you can use the sr-only CSS class that they provide out of the box.

<button aria-label="Page Visibility" type="button" role="switch" aria-checked="false" data-state="unchecked" value="on" id="isPublic" aria-labelledby="make-public-explainer" class="flex rounded-2xl p-[2px] transition overflow-hidden bg-light-slate-8 w-10 h-5">
    <span data-state="unchecked" class="bg-white block rounded-2xl  h-full w-1/2"></span>
</button>
Enter fullscreen mode Exit fullscreen mode

This will be used when the toggle button is announced for assistive technologies.

  • You can use the aria-labelledby attribute to provide the necessary label text. Typically it's linked to an element in the DOM that gives a description of what the element is used for.
<span id="make-public-explainer">Make this list publicly visible</span>

<!-- more markup... -->

<button type="button" role="switch" aria-checked="false" data-state="unchecked" value="on" id="isPublic" aria-labelledby="make-public-explainer" class="flex rounded-2xl p-[2px] transition overflow-hidden bg-light-slate-8 w-10 h-5">
    <span data-state="unchecked" class="bg-white block rounded-2xl  h-full w-1/2"></span>
</button>
Enter fullscreen mode Exit fullscreen mode

This will be used when the toggle button is announced for assistive technologies as well. The main difference is the text contents of the element with the id make-public-container will be used instead.

In our case, I opted for the aria attributes represented by the ariaLabel and ariaLabelledBy props in the component.

The TLDR;

If you want to get to the solution right away, take a peek at these lines of code in the PR.

Attempt 1: Use a Discriminated Union Type

A discriminated union type in TypeScript is a union type where one or more types differ on a particular property, e.g. type.

So in our case, maybe a labelType where the values could be aria-label and aria-labelledby. Although this would work, it meant adding two props to set a label. One for the labelType, and another being the label. And to be honest, this didn't make sense for a couple of reasons. In the case of aria-labelledby, the label would be an ID for an element in the Document Object Model (DOM) vs. an actual label. Renaming this to labelOrId seemed clunky.

Attempt 2: ariaLabel or ariaLabelledBy Props

This is really what I wanted. The component takes either the ariaLabel prop or the ariaLabelledBy prop.

I tried to keep things verbose to test the waters.

type ToggleSwitchProps =
  | {
      name: string;
      checked: boolean;
      handleToggle: () => void;
      size?: "sm" | "lg" | "base";
      classNames?: string;
      ariaLabel: string;
    }
  | {
      name: string;
      checked: boolean;
      handleToggle: () => void;
      size?: "sm" | "lg" | "base";
      classNames?: string;
      ariaLabelledBy: string;
    };
Enter fullscreen mode Exit fullscreen mode

In my head, this looked good. Narrator: "It was not". From a quick glance, this might look good, but what this translates into is ariaLabel and ariaLabelledBy being both optional.

Take a peek at the TypeScript Playground example demonstrating this.

Since this didn't work, I didn't bother refactoring, but it can be shortened to this.

type ToggleSwitchProps = {
  name: string;
  checked: boolean;
  handleToggle: () => void;
  size?: "sm" | "lg" | "base";
  classNames?: string;
} & ({ ariaLabel: string } | { ariaLabelledBy: string });
Enter fullscreen mode Exit fullscreen mode

Attempt 3: Hello never Type

I'm aware of the never type, but to the best of my knowledge, I've never used it explicitly. It's always been an inferred type for me, e.g. an error being thrown.

By assigning the never type to the prop that should not be included in each type of the union, I was able to enforce the exclusivity of the props. This meant that the component could only have either the ariaLabelledBy prop or the ariaLabel prop, but not both.

type ToggleSwitchProps = {
  name: string;
  checked: boolean;
  handleToggle: () => void;
  size?: "sm" | "lg" | "base";
  classNames?: string;
} & ({ ariaLabel: string; ariaLabelledBy?: never } | { ariaLabelledBy: string; ariaLabel?: never });
Enter fullscreen mode Exit fullscreen mode

And boom! I now had what I wanted. Check out the TypeScript Playground example to see it in action.

Code snippet showing the never type working

Conclusion

The use of the never type solved the prop exclusivity issue and had a positive impact on the component’s accessibility. Now, the component requires a label, ensured by either the ariaLabel prop or the areaLabelledBy prop, enforcing accessibility.

Never say never. 😜

Photo by Randy Rizo on Unsplash

Other places you can find me at:

🎬 YouTube

🎬 Twitch
🎬 nickyt.live
💻 GitHub
👾 My Discord
🐦 Twitter/X
🧵 Threads
🎙 My Podcast
🗞️ One Tip a Week Newsletter
🌐 My Website

Top comments (6)

Collapse
 
acerslee profile image
Alex Lee • Edited

For React at least, you can extend interface with React.HTMLProps<HTMLButtonElement>, and that would give you access to all the available props for the button element.

Image description

Similar to what Vesa mentioned, having to define new props you need to access as you go isn't an efficient method.

Collapse
 
nickytonline profile image
Nick Taylor • Edited

Thanks for the reply Alex!

I'm aware that I can extend the interface. As well, the internal component already has all the props as it's a Button element. The problem is all the aria-* props are optional.

The point of the change is to require a label for this specific element, not allow it to be optional. Not everyone is as well versed with accessible practices.

Collapse
 
euclidiankid profile image
Leonardo Moraes • Edited

You can use Required and Pick typescript utility types.

Like Required<Pick<HTMLButtonProps, \aria-${string}>>. This would make all aria attributes required.

Or like Required<Pick<HTMLButtonProps, 'aria-labeledby' | 'aria-label'>>. This would make aria-labeledby and aria-label required.

More at typescript doc.

Collapse
 
merri profile image
Vesa Piittinen • Edited

The type trick itself is valuable, however it is perfectly valid to omit both aria-label and aria-labelledby as there are three more ways to apply the label. So personally I wouldn't like to see that kind of strict typing, it gets more in the way than it does good.

In this particular case I'm sampling visually hidden utility using a global data attribute, but you can also turn it to a class:

:root [data-text='visuallyHidden' i]:not(:focus):not(:active) {
    all: initial;
    clip-path: inset(50%);
    contain: content;
    position: absolute;
    height: 1px;
    width: 1px;
    white-space: nowrap;
}
Enter fullscreen mode Exit fullscreen mode

Button within a label

<!-- utility not needed if the text can be visible in the label -->
<label><button ... /><span data-text="visuallyHidden"></span></label>
Enter fullscreen mode Exit fullscreen mode

Render the text content inside the button

<button ...><span data-text="visuallyHidden">Can't be seen, can be heard</span></button>
Enter fullscreen mode Exit fullscreen mode

Title attribute

<button title="Hello there">
Enter fullscreen mode Exit fullscreen mode

Title might even be preferrable in case there is no visual text for the switch otherwise.


Regarding accessibility enforcement, it should be done to output result and team conventions, not (attempted to be) enforced with technical limitations.

Collapse
 
nickytonline profile image
Nick Taylor

Thanks for reading and for the reply Vesa. Glad you like the never type trick!

I'm aware it's fine to omit aria-* attributes, and typically reaching for aria is usually the last thing you should do.

As mentioned in the post, I do talk about using visually hidden text, but opted not to use it, mainly because where the component is used, in some case a label made sense, but in other areas of the app, it mare sense to label it by another element.

For the title attribute, I hadn't considered it. Some older blog posts I found say it's a bad idea, but some more recent stuff in Deque's docs say otherwise, so that could definitely be an option instead of aria-label.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.