jsx-a11y/control-has-associated-label

Require buttons, links and other controls to have a text label

Also called jsx_a11y/control-has-associated-label

A button that only holds an icon is announced as "button". The person hearing it has to press it to find out what it does.

A control is an element people can operate, such as button, select, option, summary or a with an href, or an element with a role such as role="button".

These count as a label:

  • Text or an expression inside the control.
  • aria-label, aria-labelledby or alt, on the control or on an element inside it.
  • A component without children, such as <Icon />, because it might render text.
  • Spread props, because the label might be in the spread.

Configure#

Off by default. To turn it on:

bunfig.toml
[lint.rules]
"jsx-a11y/control-has-associated-label" = "error" # or "warn"

Every option, set to its default:

bunfig.toml
[lint.rules]
"jsx-a11y/control-has-associated-label" = ["error", {
  depth = 2,
  labelAttributes = [], # list of strings
  controlComponents = [], # list of strings
  ignoreElements = [
    "audio",
    "canvas",
    "embed",
    "input",
    "textarea",
    "tr",
    "video",
  ],
  ignoreRoles = [
    "grid",
    "listbox",
    "menu",
    "menubar",
    "radiogroup",
    "row",
    "tablist",
    "toolbar",
    "tree",
    "treegrid",
  ],
}]

Examples#

example.jsx2 problems
const close = <button onClick={closeDialog}><img src="/x.svg" /></button>;
const next = <div role="button" tabIndex={0} onClick={goNext} />;
example.jsxNo problems
const close = <button onClick={closeDialog}><img src="/x.svg" alt="Close" /></button>;
const next = <div role="button" tabIndex={0} onClick={goNext} aria-label="Next page" />;
const save = <button onClick={saveDraft}>Save</button>;
const menu = <button onClick={openMenu}><MenuIcon /></button>;

td and th count as controls too, so an empty table cell is reported:

example.jsx2 problems
const row = <tr><th /><td /></tr>;

If your tables have empty cells, add td and th to ignoreElements, after the defaults:

example.jsxwith { "ignoreElements": ["audio", "canvas", "embed", "input", "textarea", "tr", "video", "td", "th"] }No problems
const row = <tr><th /><td /></tr>;

Options#

OptionDefaultWhat it does
depth2How many levels of children to search for a label, up to 25.
labelAttributes[]Other attributes that count as a label.
controlComponents[]Components to check as controls.
ignoreElements["audio", "canvas", "embed", "input", "textarea", "tr", "video"]Elements to skip. Your list replaces the default.
ignoreRoles["grid", "listbox", "menu", "menubar", "radiogroup", "row", "tablist", "toolbar", "tree", "treegrid"]Roles to skip. Your list replaces the default.

The text here is on the third level, so the default depth doesn't find it:

example.jsx1 problem
const save = <button><span><b>Save</b></span></button>;
example.jsxwith { "depth": 3 }No problems
const save = <button><span><b>Save</b></span></button>;
example.jsxwith { "controlComponents": ["IconButton"] }1 problem
const remove = <IconButton icon="trash" />;
example.jsxwith { "controlComponents": ["IconButton"], "labelAttributes": ["tooltip"] }No problems
const remove = <IconButton icon="trash" tooltip="Delete" />;