Instruction file imported from epam/ai-dial-admin-frontend (
.github/instructions/a11y.instructions.md). Copyright stays with the author.
Accessibility (WCAG 2.1 AA)
Apply these whenever you add or review interactive UI — not only when asked for an "accessibility
pass". eslint-plugin-jsx-a11y enforces the mechanically checkable subset (static roles, ARIA prop
names, alt text); everything below is what lint cannot see.
Decorative icons inside an already-labeled control
When an icon sits inside a control that already carries an aria-label (or renders next to visible
label text), mark the icon aria-hidden so assistive tech doesn't announce a redundant or competing
name.
// Correct
<DialGhostIconButton
icon={<IconTrashX size={DIAL_ICON_SIZE.SM} aria-hidden />}
aria-label={t(ButtonsI18nKey.Delete)}
onClick={onDelete}
/>
// Wrong — the icon contributes a second, competing name
<DialGhostIconButton icon={<IconTrashX />} aria-label={t(ButtonsI18nKey.Delete)} />
Toggle state must be programmatic, not just visual
A control whose only feedback is a class change (color, icon swap) is invisible to screen readers. Expose the state through the matching ARIA attribute:
| Pattern | Attribute |
|---|---|
| Pressed / active toggle | aria-pressed={isActive} |
| Expand / collapse (section, detail panel) | aria-expanded={isOpen} + aria-controls={id} (pair with a real useId() id) |
| Tab, step, or segment selection | aria-selected / aria-current |
| Sort direction on a column header | aria-sort="ascending" | "descending" | "none" |
Hidden panels that keep focusable descendants
Never set aria-hidden="true" on a container while its focusable descendants (inputs, buttons)
remain mounted and tabbable — that leaves keyboard focus reachable inside a region hidden from
assistive tech. Use inert (React 19 supports it natively); it removes the subtree from both the
accessibility tree and the tab order.
// Wrong — hidden from screen readers but still reachable with Tab
<aside aria-hidden={!isOpen}>{children}</aside>
// Correct
<aside inert={!isOpen}>{children}</aside>
Status feedback for dynamic content
Any state change with no persistent visible text confirmation — copy-to-clipboard success, a grid
filtered to "no results", a save that only flips a toast, a background sync finishing — needs an
aria-live announcement.
<span role="status" aria-live="polite" className="sr-only">
{statusMessage}
</span>
- Use
aria-live="polite"for confirmations and status; reserveassertivefor genuine errors. - Keep the live region separate from the control's own
aria-label. The label stays stable ("Copy value"); the live region carries the transient "Copied" message. - Announce near a limit, not on every keystroke — a live region that fires per character is noise.
- The toast/notification container counts as a live region. If you surface a result only through
NotificationContext, verify the container is announced rather than adding a second region.
Grouping in row-like and card-like collections
When a collection renders different visual treatments for different kinds of item (entity status,
publication type, severity), expose the distinction with role="group" + aria-label on each item
root. Color, icon, or alignment alone is not accessible.
Contrast
Target AA: 4.5:1 for normal text, 3:1 for large text (≥18.66px bold / ≥24px) and for non-text UI boundaries such as input borders and focus rings (WCAG 1.4.11).
Every textColor token in apps/ai-dial-admin/tailwind.config.js already clears AA against
bg-layer-0 through bg-layer-4 on the built-in dark fallbacks, so using the tokens is the
compliant path — verified ratios, primary → deepest layer:
| Token | on layer-1 |
on layer-4 |
|---|---|---|
text-primary |
16.8:1 | 12.3:1 |
text-secondary |
7.8:1 | 5.7:1 |
text-accent-primary |
7.8:1 | 5.7:1 |
text-error |
6.3:1 | 4.6:1 |
Practical consequences:
- Never hardcode a hex or reach for a stock Tailwind color (
text-gray-400,text-red-500). That is where AA actually breaks — not in the token set. Seecomponents.md§6. text-erroronbg-layer-4is the tightest pair in the system at 4.6:1. It passes, but it has no headroom — don't shrink error text below normal size on a deep layer.- Borders and dividers that convey structure need 3:1:
stroke-primarygives 3.35:1 onlayer-2.stroke-secondaryis far below 3:1 there — decorative separators only, never the sole indicator of an input's bounds or a validation error. - Disabled controls are exempt from contrast minimums, so
controls-text-secondary-disable(2.7:1) is fine for a disabled label — but never reuse a*-disabletoken for enabled content. - A theme served by the themes service can override any of these. When a design supplies its own palette, re-check the pair rather than assuming the fallback ratio still holds.
Focus-visible must match hover, not be a lesser version of it
If :hover / :active change background or border color to signal interactivity, apply the same
change to :focus-visible — in addition to, never instead of, the outline. Keyboard users should get
at least as much feedback as mouse users.
&:hover,
&:active,
&:focus-visible {
background-color: var(--controls-bg-neutral-hover, #242c42);
border-color: var(--stroke-hover, #eef1f7);
}
Keyboard parity for hover-only affordances
Any onMouseEnter that does real work — prefetching a detail panel, revealing a row's action
buttons — needs an onFocus counterpart, or keyboard users never get what mouse users get before
they click. Row actions that appear only on hover must also appear on focus-within.
Long and truncated content
- Never truncate with
break-all(or similar) without a way to reach the full value. - Use
DialEllipsisTooltipfrom ui-kit when truncating, so the full content stays reachable. Atitleattribute alone is not keyboard-accessible.
AG Grid
The grid is this repo's densest interactive surface, and its accessibility is mostly configuration:
- Every column needs a real
headerName— a blank header leaves the column unnamed for screen readers. Icon-only action columns need anaria-labelon the cell renderer's control, not just on the header. - Custom cell renderers are ordinary React: a clickable cell must be a
<button>, not a<div>withonClick.BooleanButtonCellRendereris the exemplar (seecomponents.md§11). - Don't disable keyboard navigation to fix a focus-styling problem. If Tab order feels wrong, the cause is usually a renderer that mounts extra focusable nodes.
Verifying
getByRolein tests is an accessibility check that runs in CI. If a spec can't query an element by role or accessible name, fix the component rather than reaching for a different query — that rule exists for this reason (testing.md§4).- The
spec-verification-gateagent locates elements only by role, label, and accessible name. A scenario it reports asblockedbecause nothing was addressable is a real finding, not gate noise.
Scope boundary
These patterns apply to code this repo owns under apps/ai-dial-admin/. Accessibility gaps inside an
installed package (@epam/ai-dial-ui-kit internals in node_modules) are out of scope for a fix
here — report them upstream instead of patching vendor output. Check getMigrationGuides on the
ui-kit MCP server before assuming a gap is unfixed.