require-accessible-name

Github

Require an accessible name on Pluma components rendered without visible text.


Use this rule to catch icon-only controls that ship with no accessible name.

A Button with isIconOnly discards its children, so any visible text you pass is dropped from the output. Without aria-label the button reaches a screen reader as "button" with nothing else — the icon conveys nothing.

Use the native aria-label attribute. Button has no ariaLabel prop in either framework: React spreads the unknown prop without emitting an attribute, and Ember drops the unrecognised argument, so the camelCase form leaves the button nameless. Some other Pluma components (TextField, ProgressBar, Select) do declare ariaLabelButton is not one of them.

Types cannot enforce this. In Ember the name is usually a splattribute, which component code and Glint can never see, so a lint rule is the only check that works across both frameworks.

Incorrect

import { Button } from '@customerio/pluma-components/react';

<Button isIconOnly icon="trash" />
// An icon and no children is unnamed whether or not `isIconOnly` is set:
<Button icon="trash" />
// `isIconOnly` with an icon discards the children, so this text never renders:
<Button isIconOnly icon="trash">Delete</Button>
// `Button` has no `ariaLabel` prop, so this is unnamed as well:
<Button isIconOnly icon="trash" ariaLabel="Delete" />
// The Icon is `aria-hidden`, so an icon passed as a child names nothing either:
<Button isIconOnly><Icon name="trash" /></Button>

Correct

import { Button } from '@customerio/pluma-components/react';

<Button isIconOnly icon="trash" aria-label="Delete" />
<Button isIconOnly icon="trash" aria-labelledby="row-heading" />
// Not icon-only, so the visible text is the name:
<Button icon="trash">Delete</Button>
// `isIconOnly` with no icon: nothing is discarded, so the text still names it
<Button isIconOnly>Save</Button>
// Text inside an element or fragment counts, and so does a dynamic child
<Button icon="trash"><span>Delete</span></Button>
<Button icon="trash">{label}</Button>

What is checked

The rule reports an imported Pluma Button that renders no visible text and has no aria-label or aria-labelledby. Only those native attributes are accepted, because only they produce a real accessible name on a Button — the camelCase ariaLabel / ariaLabelledby forms are not props of Button and are discarded, so accepting them would let the rule pass on a nameless button.

"Renders no visible text" is decided from the children rather than from isIconOnly alone, because Button discards its children only when isIconOnly is set and an icon is supplied:

table
isIconOnlyicon propchildrenRendersReported
yesyestexticon only — children droppedyes, as discardedLabel
yesnotextthe textno — it is named
noyesnoneicon onlyyes
noyestexticon + textno — it is named
yesanyicon element onlyicon onlyyes

A dynamic flag such as isIconOnly={isCompact} counts as set: the button renders icon-only in at least one state, and needs a name in that state.

These are not reported:

  • Components with spread props or ...attributes, because the caller may supply the name and the rule cannot see through a spread.
  • Buttons whose children render visible text, including text wrapped in an element or fragment, and dynamic children such as {label} — which may be text, so the rule stays quiet rather than erroring on correct code.
  • Buttons with neither an icon nor isIconOnly. A bare <Button /> is a different problem and not this rule's concern.
  • Controls with aria-hidden, which are removed from the accessibility tree and so need no name — Pluma's own FileField does this for a decorative trigger whose real name lives on a sibling input.

title is deliberately not accepted. It only appears on hover, is unavailable on touch, and is the last resort in accessible name computation, so an icon-only control needs a name that is always present.

Ember: only the native attribute counts

aria-label and aria-labelledby name a control, and aria-hidden exempts it, only as native attributes. An @-prefixed form does not, so it never satisfies this rule:

<template>
	{{! Reported — the argument is discarded, so this button has no name }}
	<PlumaButton @isIconOnly={{true}} @icon="trash" @aria-label="Delete" />

	{{! Reported — this does not hide anything, so the button is unnamed AND exposed }}
	<PlumaButton @isIconOnly={{true}} @icon="trash" @aria-hidden="true" />

	{{! Correct — the native attribute, with no `@` }}
	<PlumaButton @isIconOnly={{true}} @icon="trash" aria-label="Delete" />
</template>

Arguments (@foo) and attributes (foo) are separate channels in Glimmer, and ...attributes forwards only the attribute one. A hash of props cannot be splatted as attributes, so a prop reaches the DOM only if some component writes foo={{…}} in a template — and no Pluma component writes an ARIA attribute out of an argument. An @aria* argument is read into restProps, handed to Box as unsafe_props, used solely to compute sprinkle class names, and then dropped.

Types do not catch this either: Box is polymorphic and intersects native HTML attribute types into its Args, which makes aria-label a valid key on a component that never reads it. Glint rejects @ariaLabel, but not @aria-label.

The fix is always to remove the @.

This rule only ensures such a form cannot pass as a name. It does not report the inert argument itself — that applies to most Pluma components rather than just icon-only ones, and fires on controls that are correctly named, so it belongs in a rule of its own.