diff --git a/.github/instructions/a11y.instructions.md b/.github/instructions/a11y.instructions.md new file mode 100644 index 00000000000..aa3a8ef2a87 --- /dev/null +++ b/.github/instructions/a11y.instructions.md @@ -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 ``. +- 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. diff --git a/resources/js/layout/AppLayout.vue b/resources/js/layout/AppLayout.vue index 108da783b1d..568c723e3cc 100644 --- a/resources/js/layout/AppLayout.vue +++ b/resources/js/layout/AppLayout.vue @@ -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'; @@ -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; @@ -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)); @@ -95,7 +102,7 @@ </script> <template> - <Head :title="title" /> + <Head :title="fullPageTitle" /> <LiveRegion :debug="true"></LiveRegion> <div class="cp"> <div class="cp__header"> diff --git a/resources/js/pages/SettingsIndexPage.vue b/resources/js/pages/SettingsIndexPage.vue index 0af53579275..ad51e5ec55a 100644 --- a/resources/js/pages/SettingsIndexPage.vue +++ b/resources/js/pages/SettingsIndexPage.vue @@ -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> diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 4caf7eac053..00d9d300a1c 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -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">