Skip to content
297 changes: 297 additions & 0 deletions .github/instructions/a11y.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
---
description: "Guidance for creating more accessible code"
applyTo: "resources/js/**,resources/css/**,resources/templates/**,packages/craftcms-cp/src/**"
---

# Accessibility instructions

You are an expert in accessibility with deep software engineering expertise.

## Non-negotiables (MUST)

- Conform to [WCAG 2.2 Level AA](https://www.w3.org/TR/WCAG22/).
- Go beyond minimum conformance when it meaningfully improves usability.
- UI components are defined as Lit Web Components in the component library (`@craftcms/cp`) or as Vue components in `./resources/js/`. You SHOULD use the component patterns as defined. Do not recreate patterns.
- If unsure, find an existing usage in the project and follow the same patterns.
- Ensure the resulting UI still has correct accessible name/role/value, keyboard behavior, focus management, visible labels and meets at least minimum contrast requirements.
- If a needed component does not exist, prefer native HTML elements/attributes over ARIA.
- The `@craftcms/cp` component library should include components that can be reused by plugin developers, or outside the context of this application. Vue components should be used for application-specific UI.
- Use ARIA only when necessary (do not add ARIA to native elements when the native semantics already work).
- Ensure correct accessible **name, role, value, states, and properties**.
- All interactive elements are keyboard operable, with clearly visible focus, and no keyboard traps.
- Do not claim the output is “fully accessible”.

## Inclusive language (MUST)

- Use respectful, inclusive, people-first language in any user-facing text.
- Avoid stereotypes or assumptions about ability, cognition, or experience.

## Cognitive load (SHOULD)

- Prefer plain language.
- Use consistent page structure (landmarks).
- Keep navigation order consistent.
- Keep the interface clean and simple (avoid unnecessary distractions).

## Structure and semantics

### Page structure (MUST)

- Use landmarks (`header`, `nav`, `main`, `footer`) appropriately.
- Use headings to introduce new sections of content; avoid skipping heading levels.
- Prefer one `h1` for the page topic. Generally, the first heading within the `main` element / landmark.

### Page title (SHOULD)

- Set a descriptive `<title>`.
- Prefer: “Unique page - section - site”.

## Keyboard and focus

### Core rules (MUST)

- All interactive elements are keyboard operable.
- Tab order follows reading order and is predictable.
- Focus is always visible.
- Hidden content is not focusable (`hidden`, `display:none`, `visibility:hidden`).
- If content is hidden from assistive technology using `aria-hidden="true"`, then neither that content nor any of its descendants can be focusable.
- Static content MUST NOT be tabbable.
- Exception: if an element needs programmatic focus, use `tabindex="-1"`.

### Skip link / bypass blocks (MUST)

- Provide a skip link as the first focusable element.
- When implementing a section with a large number of tab stops, consider adding a skip link to jump to the next section.

```html
<header>
<a href="#maincontent" class="skip-link">Skip to main content</a>
<!-- header content -->
</header>
<nav>
<!-- navigation -->
</nav>
<main id="maincontent" tabindex="-1">
<h1><!-- page title --></h1>
<!-- content -->
</main>
```

### Composite widgets (SHOULD)

If a component uses arrow-key navigation within itself (tabs, listbox, menu-like UI, grid/date picker):

- Provide one tab stop for the composite container or one child.
- Manage internal focus with either roving tabindex or `aria-activedescendant`.

Roving tabindex (SHOULD):

- Exactly one focusable item has `tabindex="0"`; all others are `-1`.
- Arrow keys move focus by swapping tabindex and calling `.focus()`.

`aria-activedescendant` (SHOULD):

- Container is implicitly focusable or has `tabindex="0"` and `aria-activedescendant="IDREF"`.
- Arrow keys update `aria-activedescendant`.

## Low vision and contrast (MUST)

### Contrast requirements (MUST)

- Text contrast: at least 4.5:1 (large text: 3:1).
- Large text is at least 24px regular or 18.66px bold.
- Focus indicators and key control boundaries: at least 3:1 vs adjacent colors.
- Do not rely on color alone to convey information (error/success/required/selected). Provide text and/or icons with accessible names.

### Color generation rules (MUST)

- Do not invent arbitrary colors.
- Use project-approved design tokens (CSS variables).
- If no relevant colors exist in the palette, define a small token palette and only use those tokens.
- Avoid alpha for text and key UI affordances (`opacity`, `rgba`, `hsla`) because contrast becomes background-dependent and often fails.
- Ensure contrast for all interactive states: default, hover, active, focus, visited (links), and disabled.

### Safe defaults when unsure (SHOULD)

- Prefer very dark text on very light backgrounds, or the reverse.
- Avoid mid-gray text on white; muted text should still meet 4.5:1.

### Tokenized palette contract (SHOULD)

- Define and use tokens like: `--c-bg-body`, `--c-bg-default`, `--c-color-danger-bg-normal`, `--c-color-danger-bg-emphasis`, `--c-color-danger-bg-subtle`.
- Only assign UI colors via these tokens (avoid scattered inline hex values).

### Verification (MUST)

Contrast verification is covered by the Final verification checklist.

## High contrast / forced colors mode (MUST)

### Support OS-level accessibility features (MUST)

- Never override or disrupt OS accessibility settings.
- The UI MUST adapt to High Contrast / Forced Colors mode automatically.
- Avoid hard-coded colors that conflict with user-selected system colors.

### Use the `forced-colors` media query when needed (SHOULD)

Use `@media (forced-colors: active)` only when system defaults are not sufficient.

```css
@media (forced-colors: active) {
/* Example: Replace box-shadow (suppressed in forced-colors) with a border */
.button {
border: 2px solid ButtonBorder;
}
}

/* if using box-shadow for a focus style, also use a transparent outline
so that the outline will render when the high contrast setting is enabled */
.button:focus {
box-shadow: 0 0 4px 3px rgba(90, 50, 200, .7);
outline: 2px solid transparent;
}
```

In Forced Colors mode, avoid relying on:

- Box shadows
- Decorative gradients

### Respect user color schemes in forced colors (MUST)

- Use system color keywords (e.g., `ButtonText`, `ButtonBorder`, `CanvasText`, `Canvas`).
- Do not use fixed hex/RGB colors inside `@media (forced-colors: active)`.

### Do not disable forced colors (MUST)

- Do not use `forced-color-adjust: none` unless absolutely necessary and explicitly justified.
- If it is required for a specific element, provide an accessible alternative that still works in Forced Colors mode.

### Icons (MUST)

- Icons MUST adapt to text color.
- Prefer `currentColor` for SVG icon fills/strokes; avoid embedding fixed colors inside SVGs.

```css
svg {
fill: currentColor;
stroke: currentColor;
}
```

## Reflow (WCAG 2.2 SC 1.4.10) (MUST)

### Goal (MUST)

Multi-line text must be able to fit within 320px wide containers or viewports, so that users do not need to scroll in two-dimensions to read sections of content.

### Core principles (MUST)

- Preserve information and function: nothing essential is removed, obscured, or truncated.
- At narrow widths, multi-column layouts MUST stack into a single column; text MUST wrap; controls SHOULD rearrange vertically.
- Users MUST NOT need to scroll left/right to read multi-line text.
- If content is collapsed in the narrow layout, the full content/function MUST be available within 1 click (e.g., overflow menu, dialog, tooltip).

### Engineering requirements (MUST)

- Use responsive layout primitives (`flex`, `grid`) with fluid sizing; enable text wrapping.
- Avoid fixed widths that force two-dimensional scrolling at 320px.
- Avoid absolute positioning and `overflow: hidden` when it causes content loss, or would result in the obscuring of content at smaller viewport sizes.
- Media and containers SHOULD NOT overflow the viewport at 320px (for example, prefer `max-width: 100%` for images/video/canvas/iframes).
- In flex/grid layouts, ensure children can shrink/wrap (common fix: `min-width: 0` on flex/grid children).
- Handle long strings (URLs, tokens) without forcing overflow (common fix: `overflow-wrap: anywhere` or equivalent).
- Ensure all interactive elements remain visible, reachable, and operable at 320px.

### Exceptions (SHOULD)

If a component truly requires a two-dimensional layout for meaning/usage (e.g., large data tables, maps, diagrams, charts, games, presentations), allow horizontal scrolling only at the component level.

- The page as a whole MUST still reflow (unless the page layout truly requires two-dimensional layout for usage).
- The component MUST remain fully usable (all content reachable; controls operable).

## Controls and labels

### Visible labels (MUST)

- Every interactive element has a visible label.
- The label cannot disappear while entering text or after the field has a value.

### Voice access (MUST)

- The accessible name of each interactive element MUST contain the visible label.
- If using `aria-label`, include the visual label text.
- If multiple controls share the same visible label (e.g., many “Remove” buttons), use an `aria-label` that keeps the visible label text and adds context (e.g., “Remove item: Socks”). If text that adds additional context exists on the page, prefer using a `aria-labelledby` with an ID that references the text node.

## Forms

### Labels and help text (MUST)

- Every form control has a programmatic label.
- Prefer `<label for="...">`.
- Labels describe the input purpose.
- If help text exists, associate it with `aria-describedby`.

### Required fields (MUST)

- Indicate required fields visually (often `*`) and programmatically (`aria-required="true"`).

### Errors and validation (MUST)

- Provide error messages that explain how to fix the issue.
- Use `aria-invalid="true"` for invalid fields; remove it when valid.
- Associate inline errors with the field via `aria-describedby`.
- Submit buttons SHOULD NOT be disabled solely to prevent submission.
- On submit with invalid input, focus the first invalid control.

## Graphics and images

All graphics include `img`, `svg`, icon fonts, and emojis.

- Informative graphics MUST have meaningful alternatives.
- `img`: use `alt`.
- `svg`: prefer `role="img"` and `aria-label`/`aria-labelledby`.
- Decorative graphics MUST be hidden.
- `img`: `alt=""`.
- Other: `aria-hidden="true"`.

## Navigation and menus

- Use semantic navigation: `<nav>` with lists and links.
- Do not use `role="menu"` / `role="menubar"` for site navigation.
- For expandable navigation:
- Include button elements to toggle navigation and/or sub-navigations. Use `aria-expanded` on the button to indicate state.
- `Escape` MAY close open sub-navigations.

## Tables and grids

### Tables for static data (MUST)

- Use `<table>` for static tabular data.
- Use `<th>` to associate headers.
- Column headers are in the first row.
- Row headers (when present) use `<th>` in each row.

### Grids for dynamic UIs (SHOULD)

- Use grid roles only for truly interactive/dynamic experiences.
- If using `role="grid"`, grid cells MUST be nested in rows so header/cell relationships are determinable.
- Use arrow navigation to navigate within the grid.

## Final verification checklist (MUST)

Before finalizing output, explicitly verify:

- Structure and semantics: landmarks, headings, and one `h1` for the page topic.
- Keyboard and focus: operable controls, visible focus, predictable tab order, no traps, skip link works.
- Controls and labels: visible labels present and included in accessible names.
- Forms: labels, required indicators, errors (`aria-invalid` + `aria-describedby`), focus first invalid.
- Contrast: meets 4.5:1 / 3:1 thresholds, focus/boundaries meet 3:1, color not the only cue.
- Forced colors: does not break OS High Contrast / Forced Colors; uses system colors in `forced-colors: active`.
- Reflow: sections of content should be able to adjust to 320px width without the need for two-dimensional scrolling to read multi-line text; no content loss; controls remain operable.
- Graphics: informative alternatives; decorative graphics hidden.
- Tables/grids: tables use `<th>`; grids (when needed) are structured with rows and cells.

## Final note

Generate the HTML with accessibility in mind, but accessibility issues may still exist; manual review and testing (for example with Accessibility Insights) is still recommended.
13 changes: 10 additions & 3 deletions resources/js/layout/AppLayout.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import SystemInfo from '@/components/SystemInfo.vue';
import useCraftData from '@/composables/useCraftData';
import {t} from '@craftcms/cp/utilities/translate.ts.mjs';
import {computed, reactive, ref, watch, useTemplateRef} from 'vue';
import CpSidebar from '@/components/CpSidebar.vue';
Expand All @@ -10,15 +11,17 @@
import {useAnnouncer} from '@/composables/useAnnouncer';
import LiveRegion from '@/components/LiveRegion.vue';

withDefaults(
const props = withDefaults(
defineProps<{
title?: string;
debug?: any;
fullWidth?: boolean;
}>(),
{fullWidth: false, crumbs: () => []}
{fullWidth: false}
);

const {system} = useCraftData();

const page = usePage<{
flash: {
success: string | null;
Expand All @@ -34,6 +37,10 @@
const crumbs = computed(() => page.props.crumbs ?? null);
const sidebarToggle = useTemplateRef('sidebarToggle');
const {announcement, announce} = useAnnouncer();
const fullPageTitle = computed(() => {
const title = props.title?.trim();
return title ? `${title} - ${system.name}` : system.name;
});

watch(successFlash, (newMessage) => announce(newMessage));
watch(errorFlash, (newMessage) => announce(newMessage));
Expand Down Expand Up @@ -95,7 +102,7 @@
</script>

<template>
<Head :title="title" />
<Head :title="fullPageTitle" />
<LiveRegion :debug="true"></LiveRegion>
<div class="cp">
<div class="cp__header">
Expand Down
3 changes: 1 addition & 2 deletions resources/js/pages/SettingsIndexPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,9 @@
<craft-icon
:name="item.icon"
style="font-size: calc(40rem / 16)"
:label="`${item.label} - ${t('Settings')}`"
></craft-icon>
</div>
{{ item.label }}
{{ item.label }}<span class="sr-only"> - {{ t('Settings') }}</span>
</div>
</a>
</li>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/app.blade.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="">
<html xmlns="http://www.w3.org/1999/xhtml" lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
Expand Down
Loading