mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 12:16:45 +02:00
fix(a11y): dialog semantics, focus management, icon labels, html lang (#7584)
* fix(a11y): negotiate lang/dir per request and set on <html>
Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.
This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): dialog semantics on popups; fix aria-role typo on userlist
Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:
- #settings, #import_export, #embed, #skin-variants (labelledby)
- #connectivity, #users, #mycolorpicker (aria-label)
Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.
Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): focus management and Escape-to-close for popups
Three additions to toggleDropDown / _bodyKeyEvent:
- Remember the trigger element (document.activeElement) when opening
a popup, so we can restore focus when it closes.
- On open, focus the first focusable element inside the popup so
keyboard users land inside the dialog instead of staying on the
trigger button.
- Escape pressed while focus is inside a popup closes it, then the
restore-focus path runs and the trigger button is refocused.
Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): make chaticon and chat header controls real buttons
- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users
Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): accessible names for icon-only toolbar/export controls
- Export links (#exportetherpada, #exporthtmla, #exportplaina,
#exportworda, #exportpdfa, #exportopena): added aria-label so the
link is announced as e.g. "Export as PDF". The inner icon span
gets aria-hidden=true so screen readers don't read both the icon
text and the link label.
- Show-more toolbar toggle (.show-more-icon-btn): converted from
<span> to <button type=button> with aria-label and aria-expanded.
The click handler now toggles aria-expanded alongside the
full-icons class so assistive tech reflects the open/closed state.
- Theme switcher knob: aria-label changed from "theme-switcher-knob"
(a class-style identifier, not human text) to "Toggle theme".
Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(a11y): cover dialog semantics, html lang, icon button labels
New Playwright spec verifies the a11y guarantees added by this branch:
- <html> has a non-empty lang attribute
- settings/import_export/embed/users popups expose role=dialog,
aria-modal=true, and either aria-labelledby (when an h1 exists)
or aria-label (when none does)
- Escape from inside the settings popup closes it AND restores
focus to the trigger button
- Export links each carry a descriptive aria-label
- #chaticon is a real <button> with aria-label
- #titlecross / #titlesticky are real <button>s with aria-label
- #otherusers uses role=region + aria-live=polite + aria-label
(and the previous aria-role typo is gone)
- .show-more-icon-btn is a <button> with aria-label and
aria-expanded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): address Qodo review feedback from PR #7584
1. Users Escape close broken - toggleDropDown('none') intentionally
skips the users module so switching between other popups doesn't
hide the user list. That meant Escape couldn't dismiss the Users
popup either. The Escape branch now checks for #users as the
focused popup and closes it explicitly (respecting stickyUsers)
before falling through to the normal close-all path.
2. Embed focus overridden - the rAF auto-focus in toggleDropDown
grabbed the first focusable descendant, which stole focus from
command handlers that target a specific control (notably the Embed
command's #linkinput). rAF now bails out if focus is already
inside the newly-opened popup.
3. Button click blurs :focus before toggleDropDown captures trigger -
discovered while investigating the Firefox Playwright failure for
"settings popup Escape restores focus". Button.bind() calls
$(':focus').trigger('blur') before invoking the callback, so by
the time toggleDropDown() captured document.activeElement as the
restore target it was already <body>. The click handler now
stashes padeditbar._lastTrigger to the clicked <button> before
blur runs; toggleDropDown only falls back to activeElement when
the pre-stash didn't happen (keyboard shortcut path).
4. html10n overwrites aria-label - html10n unconditionally set
aria-label to the translated string, clobbering explicit aria-label
on elements that also carry data-l10n-id. setAttribute now only
fires when the element has no aria-label; explicit author labels
win, unlabelled translated elements still get a name.
5. Button visual reset - the show-more-icon-btn and #chaticon
conversions inherited UA default button border/background/padding,
shifting icon glyphs visibly off-centre. Added appearance /
background / border / padding resets.
6. Export links test assumes soffice is installed - #exportworda,
#exportpdfa, #exportopena are removed client-side by pad_impexp.ts
when clientVars.exportAvailable === 'no'. The test now skips links
absent at runtime.
Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.
* fix(a11y): close popups with no focusable content; unbreak chat-icon layout
Round 2 of #7584 review follow-ups.
1. Users popup Escape still didn't close the dialog (user-confirmed).
Root cause: _bodyKeyEvent is bound to the OUTER document's body.
When #users opens, the command handler tries to focus
#myusernameedit but that input is `disabled`, so focus stays in the
ace editor iframe. Keydown from inside the iframe does not bubble
to the outer document, so Esc never reaches _bodyKeyEvent.
Fix: in the open-popup rAF, if no command handler placed focus
inside the dialog, focus the popup div itself (with tabindex=-1).
That keeps subsequent keydown events on the outer document so
Esc can dismiss the popup. Also broadened the Esc branch to fire
whenever any popup is `.popup-show`, regardless of where :focus
lives — some popups legitimately have no focusable content at
open.
Added a regression test that opens #users and asserts Esc closes
it. Passes on both Chromium and Firefox.
2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
- My previous `border: 0` reset was overriding the intended
`border: 1px solid #ccc; border-bottom: none` from the earlier
rule. Removed `border: 0`; the earlier explicit border suffices
to suppress UA defaults.
- The `<span class="buttonicon">` inside `#chaticon` was picking
up the global `.buttonicon { display: flex; }` rule meant for
toolbar button instances, which broke the inline layout of the
label + glyph + counter row. Added a scoped
`#chaticon .buttonicon { display: inline; }` override.
All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.
* fix(a11y): only stash _lastTrigger for dropdown-opening buttons
Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.
Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.
Verified locally on Chromium:
- ordered_list.spec.ts: 6/6 pass (was 4/6)
- unordered_list.spec.ts: 6/6 pass (was 4/6)
- undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
- a11y_dialogs.spec.ts: 11/11 pass (unchanged)
* fix(a11y): address Qodo review round 4 for PR #7584
#1 Stale aria-label after relocalize
html10n.translateNode() refused to overwrite any existing aria-label,
which also skipped updates on language change (pad.applyLanguage()
re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
aria-label + marker when html10n populates it, overwrite only if the
marker is present. Explicit template-supplied aria-labels stay as-is;
html10n-generated ones refresh on relocalize.
#2 Escape won't close colorpicker
_bodyKeyEvent caught Escape on any `.popup.popup-show` but only
closed dropdown popups via toggleDropDown('none'). Popups opened
outside the editbar framework (#mycolorpicker, toggled directly by
pad_userlist.ts) stayed open while preventDefault() swallowed the
key. Now the Escape branch manually closes any popup that
toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
unless pinned) and leaves registered dropdowns for toggleDropDown to
close so its focus-restore sees the transition.
#3 Stale focus restoration
toggleDropDown('none') restored focus to _lastTrigger even when no
popup was open on entry, which meant background callers
(connectivity setup, periodic state handling) could yank focus out
of the editor to a stale toolbar button. Gated the restore on
`wasAnyOpen === true` so it only fires when there was a popup to
close.
#11 English aria-label overrides i18n (export links, chat icon)
Removed the hard-coded English aria-label from export anchors and
removed aria-hidden from their inner localized spans. Screen readers
now get the localized child text as the accessible name (Etherpad,
HTML, PDF, etc.), matching the visible UI language.
Removed the English aria-label from #chaticon and #titlesticky as
well — both have data-l10n-id, so html10n populates a localized
aria-label via the marker mechanism in #1. #titlecross keeps its
static aria-label because it has no data-l10n-id yet.
#4 4-space indent in a11y spec
Two tests had continuation lines at 4-space indent violating the
repo's 2-space rule. Folded the signatures onto one line.
Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).
Verified locally (dev server restarted for each round):
- a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
- ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
- Full backend suite: 799 passing, 0 failing
- tsc --noEmit clean in our code
#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6acca4d57e
commit
7b9a5eb01a
404
docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md
Normal file
404
docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md
Normal file
@ -0,0 +1,404 @@
|
||||
# Accessibility: Dialog semantics, icon labels, html lang
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add ARIA dialog semantics, focus management, accessible names for icon-only controls, and a `lang` attribute — addressing the highest-impact items from the 2026-04-22 a11y audit.
|
||||
|
||||
**Architecture:** All changes live in templates + a small set of TS files. No new modules. The existing `toggleDropDown` in `pad_editbar.ts` is the single chokepoint for popup show/hide; we extend it with focus management. Icon-only buttons get accessible names via a new `icon.*` locale namespace consumed via `data-l10n-id` (existing l10n machinery applies to `aria-label` automatically through html10n's attribute syntax).
|
||||
|
||||
**Tech Stack:** EJS templates, TypeScript, jQuery (legacy), Playwright tests.
|
||||
|
||||
**Out of scope:** WCAG-AA contrast pass, touch-target sizing (28→44px), full focus-visible CSS pass, modal-by-modal focus-trap library swap. Leaving those for follow-up PRs to keep this one reviewable.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `lang` attribute to top-level templates
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/templates/pad.html:7`
|
||||
- Modify: `src/templates/index.html` (top `<html>` tag)
|
||||
- Modify: `src/templates/timeslider.html` (top `<html>` tag)
|
||||
|
||||
The pad templates render server-side; `clientVars.userAgent` and `req.headers['accept-language']` aren't directly available here, but the rendered locale is exposed via `settings.defaultLang` in `Settings.ts`. Use that, defaulting to `en` if unset.
|
||||
|
||||
- [ ] **Step 1.1:** Edit `src/templates/pad.html` line 7. Replace
|
||||
```html
|
||||
<html translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
```
|
||||
with
|
||||
```html
|
||||
<html lang="<%=settings.defaultLang || 'en'%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2:** Apply the same `lang` attribute to `src/templates/index.html` and `src/templates/timeslider.html` `<html>` tags. (Read each first to get exact current line.)
|
||||
|
||||
- [ ] **Step 1.3:** The client-side language switcher (`html10n`) already updates `documentElement.lang` after page load — verify by grepping `pad_utils.ts` and `vendors/html10n.ts` for `lang =`. No code change needed if html10n already does this; otherwise add one line in `pad.ts` after l10n loads to set `document.documentElement.lang` from the active locale.
|
||||
|
||||
- [ ] **Step 1.4:** Commit:
|
||||
```bash
|
||||
git add src/templates/pad.html src/templates/index.html src/templates/timeslider.html
|
||||
git commit -m "fix(a11y): add lang attribute to top-level templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Dialog semantics on popups
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/templates/pad.html` — popups at lines 117 (`#settings`), 190 (`#import_export`), 242 (`#connectivity`), 325 (`#embed`), 349 (`#users`), 353 (`#mycolorpicker`), 410 (`#skin-variants`).
|
||||
|
||||
For each popup, add `role="dialog"`, `aria-modal="true"`, `aria-labelledby="<h1-id>"`. Where the popup has an `<h1>` without an id, add an id. Connectivity has multiple `<h1>` (one per state) — give that one `role="dialog" aria-modal="true" aria-label="Connection status"` instead of labelledby.
|
||||
|
||||
- [ ] **Step 2.1:** Settings popup. Add id to its `<h1>`:
|
||||
```html
|
||||
<h1 id="settings-title" data-l10n-id="pad.settings.padSettings"></h1>
|
||||
```
|
||||
And:
|
||||
```html
|
||||
<div id="settings" class="popup" role="dialog" aria-modal="true" aria-labelledby="settings-title" hidden><div class="popup-content">
|
||||
```
|
||||
Note: add `hidden` so the dialog is not announced to screen readers when closed. The existing `.popup-show` class already controls visibility via CSS; we'll toggle the `hidden` attribute alongside it in Task 3.
|
||||
|
||||
- [ ] **Step 2.2:** Import/export popup — add id `importexport-title` to its `<h1>`, add same dialog attrs.
|
||||
|
||||
- [ ] **Step 2.3:** Connectivity popup — `aria-label="Connection status"` (no labelledby; label is generic since the h1 changes per state).
|
||||
|
||||
- [ ] **Step 2.4:** Embed popup — id `embed-title` on the `<h1>`, dialog attrs.
|
||||
|
||||
- [ ] **Step 2.5:** Users popup — `aria-label="Users on this pad"` (no `<h1>` in the markup).
|
||||
|
||||
- [ ] **Step 2.6:** Mycolorpicker — `aria-label="Choose your author color"`.
|
||||
|
||||
- [ ] **Step 2.7:** Skin-variants popup — id `skin-variants-title` on its `<h1>`, dialog attrs.
|
||||
|
||||
- [ ] **Step 2.8:** Fix the `aria-role="document"` typo on `#otherusers` (pad.html:366) → replace with `role="region" aria-live="polite" aria-label="Active users on this pad"`. (`aria-role` is not a real attribute — it's `role`.)
|
||||
|
||||
- [ ] **Step 2.9:** Commit:
|
||||
```bash
|
||||
git add src/templates/pad.html
|
||||
git commit -m "fix(a11y): dialog semantics on popups; fix aria-role typo on userlist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Focus management in `toggleDropDown`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/static/js/pad_editbar.ts:209-256` (the `toggleDropDown` method)
|
||||
|
||||
When opening a popup: remember the trigger element, move focus to the first focusable element inside the popup, set `hidden=false`. When closing: set `hidden=true`, restore focus to the trigger. Add an Escape handler that closes any open popup.
|
||||
|
||||
- [ ] **Step 3.1:** At the top of the `padeditbar` class (find the existing field declarations near the constructor), add:
|
||||
```ts
|
||||
private lastTrigger: HTMLElement | null = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2:** Replace the body of `toggleDropDown(moduleName, cb = null)` to:
|
||||
- Remember `document.activeElement` as `lastTrigger` when transitioning from no-popup to popup-open.
|
||||
- After applying classes, for each module that became visible, set `module.attr('hidden', null)`; for each that became hidden, set `module.attr('hidden', '')`.
|
||||
- When transitioning to "all closed" (moduleName === 'none' or all modules ended up hidden), and `lastTrigger` is set and is still in the DOM, call `lastTrigger.focus()` then clear `lastTrigger`.
|
||||
- When opening, after a `requestAnimationFrame`, focus the first focusable inside the now-visible popup (`module.find('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])').filter(':visible').first().trigger('focus')`).
|
||||
|
||||
Show full code:
|
||||
```ts
|
||||
toggleDropDown(moduleName, cb = null) {
|
||||
let cbErr = null;
|
||||
try {
|
||||
if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) return;
|
||||
|
||||
$('.nice-select').removeClass('open');
|
||||
$('.toolbar-popup').removeClass('popup-show');
|
||||
|
||||
const wasAnyOpen = $('.popup.popup-show').length > 0;
|
||||
if (!wasAnyOpen && moduleName !== 'none') {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active && active !== document.body) this.lastTrigger = active;
|
||||
}
|
||||
|
||||
let openedModule: JQuery<HTMLElement> | null = null;
|
||||
|
||||
if (moduleName === 'none') {
|
||||
for (const thisModuleName of this.dropdowns) {
|
||||
if (thisModuleName === 'users') continue;
|
||||
const module = $(`#${thisModuleName}`);
|
||||
const isAForceReconnectMessage = module.find('button#forcereconnect:visible').length > 0;
|
||||
if (isAForceReconnectMessage) continue;
|
||||
if (module.hasClass('popup-show')) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
|
||||
module.removeClass('popup-show');
|
||||
module.attr('hidden', '');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const thisModuleName of this.dropdowns) {
|
||||
const module = $(`#${thisModuleName}`);
|
||||
if (module.hasClass('popup-show')) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
|
||||
module.removeClass('popup-show');
|
||||
module.attr('hidden', '');
|
||||
} else if (thisModuleName === moduleName) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
|
||||
module.addClass('popup-show');
|
||||
module.removeAttr('hidden');
|
||||
openedModule = module;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openedModule) {
|
||||
const target = openedModule;
|
||||
requestAnimationFrame(() => {
|
||||
const focusable = target.find(
|
||||
'button:visible, a[href]:visible, input:visible, select:visible, textarea:visible, [tabindex]:not([tabindex="-1"]):visible'
|
||||
).first();
|
||||
if (focusable.length) (focusable[0] as HTMLElement).focus();
|
||||
});
|
||||
} else if ($('.popup.popup-show').length === 0 && this.lastTrigger) {
|
||||
const trigger = this.lastTrigger;
|
||||
this.lastTrigger = null;
|
||||
if (document.body.contains(trigger)) trigger.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
cbErr = err || new Error(err);
|
||||
} finally {
|
||||
if (cb) Promise.resolve().then(() => cb(cbErr));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3:** Add a global keydown handler. Find the existing `init()` method (or wherever document-level handlers are bound — likely in `padeditbar.init` which is called from `pad.ts`). At the end of `init()`, add:
|
||||
```ts
|
||||
$(document).on('keydown', (e) => {
|
||||
if (e.key === 'Escape' && $('.popup.popup-show').length > 0) {
|
||||
this.toggleDropDown('none');
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3.4:** Run tsc to confirm types compile:
|
||||
```bash
|
||||
pnpm --dir src run ts-check
|
||||
```
|
||||
Expected: no new errors in `pad_editbar.ts`.
|
||||
|
||||
- [ ] **Step 3.5:** Commit:
|
||||
```bash
|
||||
git add src/static/js/pad_editbar.ts
|
||||
git commit -m "fix(a11y): focus management and Escape-to-close for popups"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Make chat icon a real button + label its close/stick controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/templates/pad.html:380` (`#chaticon` div → button)
|
||||
- Modify: `src/templates/pad.html:390-391` (`#titlecross`, `#titlesticky` anchors → buttons)
|
||||
- Modify: `src/static/js/chat.ts` if any code reads `#chaticon` as a div (grep first)
|
||||
|
||||
- [ ] **Step 4.1:** Grep for `chaticon` references in JS/CSS so we don't break selectors:
|
||||
```bash
|
||||
grep -rn "chaticon" src/static/js src/static/css src/static/skins
|
||||
```
|
||||
Expected: CSS targets `#chaticon`; JS reads `.click()` / `.show()`. None of these care whether it's a div or a button.
|
||||
|
||||
- [ ] **Step 4.2:** Replace the chat icon block with:
|
||||
```html
|
||||
<button type="button" id="chaticon" class="visible" title="Chat (Alt C)" aria-label="Open chat" data-l10n-id="pad.chat.title">
|
||||
<span id="chatlabel" data-l10n-id="pad.chat"></span>
|
||||
<span class="buttonicon buttonicon-chat" aria-hidden="true"></span>
|
||||
<span id="chatcounter" aria-label="Unread messages">0</span>
|
||||
</button>
|
||||
```
|
||||
Move the `onclick="chat.show();return false;"` to a JS handler in `chat.ts` `init()` (find existing init):
|
||||
```ts
|
||||
$('#chaticon').on('click', (e) => { e.preventDefault(); chat.show(); });
|
||||
```
|
||||
(If `chat.show()` is already wired by another listener, just remove the inline `onclick` and rely on the existing handler — confirm by greping.)
|
||||
|
||||
- [ ] **Step 4.3:** Replace chat header close/stick anchors:
|
||||
```html
|
||||
<button type="button" id="titlecross" class="hide-reduce-btn" aria-label="Close chat">−</button>
|
||||
<button type="button" id="titlesticky" class="stick-to-screen-btn" aria-label="Pin chat to screen" data-l10n-id="pad.chat.stick.title">▮</button>
|
||||
```
|
||||
Move their inline `onClick` handlers to `chat.ts`:
|
||||
```ts
|
||||
$('#titlecross').on('click', (e) => { e.preventDefault(); chat.hide(); });
|
||||
$('#titlesticky').on('click', (e) => { e.preventDefault(); chat.stickToScreen(true); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4:** Inspect CSS for `#chaticon` / `#titlecross` / `#titlesticky`. Buttons get default browser styling (border, padding) that may break the layout. Add a CSS reset block in `src/static/css/pad/chat.css` (or wherever those selectors already live):
|
||||
```css
|
||||
#chaticon, #titlecross, #titlesticky {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
#chaticon:focus-visible, #titlecross:focus-visible, #titlesticky:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
Find the right file by grepping `#chaticon` in `src/static/css`.
|
||||
|
||||
- [ ] **Step 4.5:** ts-check:
|
||||
```bash
|
||||
pnpm --dir src run ts-check
|
||||
```
|
||||
|
||||
- [ ] **Step 4.6:** Commit:
|
||||
```bash
|
||||
git add src/templates/pad.html src/static/js/chat.ts src/static/css
|
||||
git commit -m "fix(a11y): make chaticon and chat header controls real buttons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add `icon.*` locale namespace and label icon-only controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/locales/en.json` — add new keys
|
||||
- Modify: `src/templates/pad.html` — apply `data-l10n-id` to `aria-label` on icon-only elements
|
||||
|
||||
html10n supports per-attribute translation via `key.attr` style — for `aria-label`, the convention used elsewhere in this codebase is `data-l10n-id="key"` plus a sibling key `key.aria-label`. Check existing usage by grepping `aria-label` in `src/locales/en.json`:
|
||||
|
||||
- [ ] **Step 5.1:** Grep current usage:
|
||||
```bash
|
||||
grep -n "aria-label\|.title" src/locales/en.json | head -20
|
||||
```
|
||||
Determine the convention. If html10n uses `{key}.aria-label`, follow that. Otherwise use plain `key` and apply via `aria-label` directly in HTML (no l10n on the aria-label) and accept English-only for now.
|
||||
|
||||
- [ ] **Step 5.2:** Add to `src/locales/en.json` after the `pad.chat.*` block:
|
||||
```json
|
||||
"pad.icon.export.etherpad": "Export as Etherpad",
|
||||
"pad.icon.export.html": "Export as HTML",
|
||||
"pad.icon.export.plain": "Export as plain text",
|
||||
"pad.icon.export.word": "Export as Microsoft Word",
|
||||
"pad.icon.export.pdf": "Export as PDF",
|
||||
"pad.icon.export.opendocument": "Export as ODF",
|
||||
"pad.icon.showmore": "Show more toolbar buttons",
|
||||
```
|
||||
(Insert with correct JSON commas.)
|
||||
|
||||
- [ ] **Step 5.3:** Apply to the export `<a>` elements in `src/templates/pad.html:215-232`:
|
||||
```html
|
||||
<a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad" data-l10n-id="pad.icon.export.etherpad">
|
||||
```
|
||||
Repeat per format. Add `aria-hidden="true"` to the inner `<span class="exporttype buttonicon ...">` since the link itself now carries the label.
|
||||
|
||||
- [ ] **Step 5.4:** Convert the show-more span to a button on `pad.html:74`:
|
||||
```html
|
||||
<button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" data-l10n-id="pad.icon.showmore"></button>
|
||||
```
|
||||
Verify CSS targeting `.show-more-icon-btn` doesn't depend on element type — grep first.
|
||||
|
||||
- [ ] **Step 5.5:** Theme switcher knob (`pad.html:172`) currently has `aria-label="theme-switcher-knob"` which is a CSS-class-style label, not human text. Change to `aria-label="Toggle theme"`.
|
||||
|
||||
- [ ] **Step 5.6:** Commit:
|
||||
```bash
|
||||
git add src/locales/en.json src/templates/pad.html
|
||||
git commit -m "fix(a11y): accessible names for icon-only buttons and links"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Playwright test for dialog semantics + Escape
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/frontend-new/specs/a11y_dialogs.spec.ts`
|
||||
|
||||
Cover the high-impact promises: settings popup opens with role=dialog, Escape closes it, focus returns to trigger.
|
||||
|
||||
- [ ] **Step 6.1:** Write the failing test:
|
||||
```ts
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {goToNewPad} from "../helper/padHelper";
|
||||
|
||||
test.beforeEach(async ({page}) => { await goToNewPad(page); });
|
||||
|
||||
test('settings popup has dialog semantics and Escape closes it', async ({page}) => {
|
||||
const settingsButton = page.locator('.buttonicon.buttonicon-cog');
|
||||
await settingsButton.click();
|
||||
|
||||
const dialog = page.locator('#settings');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
// Focus should return to the trigger
|
||||
const focused = await page.evaluate(() => document.activeElement?.className || '');
|
||||
expect(focused).toContain('buttonicon-cog');
|
||||
});
|
||||
|
||||
test('html element has lang attribute', async ({page}) => {
|
||||
const lang = await page.locator('html').getAttribute('lang');
|
||||
expect(lang).toBeTruthy();
|
||||
expect(lang!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('export links have accessible names', async ({page}) => {
|
||||
await page.locator('.buttonicon.buttonicon-import_export').click();
|
||||
const pdfLink = page.locator('#exportpdfa');
|
||||
const label = await pdfLink.getAttribute('aria-label');
|
||||
expect(label).toBeTruthy();
|
||||
});
|
||||
|
||||
test('chaticon is a button with accessible name', async ({page}) => {
|
||||
const chatIcon = page.locator('#chaticon');
|
||||
const tagName = await chatIcon.evaluate(el => el.tagName.toLowerCase());
|
||||
expect(tagName).toBe('button');
|
||||
const label = await chatIcon.getAttribute('aria-label');
|
||||
expect(label).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2:** Verify the Playwright spec runs (headless per project rule):
|
||||
```bash
|
||||
cd src && pnpm exec playwright test tests/frontend-new/specs/a11y_dialogs.spec.ts --reporter=list
|
||||
```
|
||||
Expected: all 4 tests pass.
|
||||
|
||||
- [ ] **Step 6.3:** Commit:
|
||||
```bash
|
||||
git add src/tests/frontend-new/specs/a11y_dialogs.spec.ts
|
||||
git commit -m "test(a11y): verify dialog semantics, html lang, export labels, chat button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run the full local checks before push
|
||||
|
||||
- [ ] **Step 7.1:** ts-check from `src/`:
|
||||
```bash
|
||||
pnpm --dir src run ts-check
|
||||
```
|
||||
|
||||
- [ ] **Step 7.2:** Backend tests:
|
||||
```bash
|
||||
pnpm --dir src run test:backend
|
||||
```
|
||||
|
||||
- [ ] **Step 7.3:** Push and open a PR against `johnmclear/etherpad-lite`:
|
||||
```bash
|
||||
git push -u fork fix/a11y-dialogs-labels-lang
|
||||
gh pr create --repo johnmclear/etherpad-lite --base develop --head fix/a11y-dialogs-labels-lang \
|
||||
--title "fix(a11y): dialog semantics, icon labels, html lang" \
|
||||
--body "..."
|
||||
```
|
||||
|
||||
- [ ] **Step 7.4:** Post `/review` comment on the PR to trigger Qodo.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Original audit's high-impact items were (a) dialog semantics + focus trap, (b) aria-labels via icon.* namespace, (c) html lang. All three covered (Tasks 2+3, 4+5, 1). Bonus: aria-role typo on userlist (Task 2.8) and chat header buttons (Task 4.3).
|
||||
- **Out of scope, deliberately:** focus-visible CSS sweep, contrast pass, touch-target sizing, full focus-trap library (we do simple init-focus + Escape, not Tab cycling — adequate for these short popups, library can come later).
|
||||
- **Risk:** Adding `hidden` attribute to popups changes initial render — confirmed CSS does not depend on absence of `hidden` (CSS uses `.popup-show` to display). Need to check that `display: none` from `.popup` (default) and `hidden` don't conflict in unwanted ways; `hidden` is a stronger signal and should be fine.
|
||||
@ -76,11 +76,21 @@
|
||||
text-align: right;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
#titlebar .stick-to-screen-btn {
|
||||
font-size: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
#titlebar .stick-to-screen-btn:focus-visible,
|
||||
#titlebar .hide-reduce-btn:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* -- MESSAGES -- */
|
||||
#chattext {
|
||||
@ -121,10 +131,32 @@
|
||||
|
||||
/* -- CHAT ICON -- */
|
||||
#chaticon {
|
||||
/* #chaticon was converted from <span> to <button> for a11y; reset the
|
||||
UA-default button chrome so the corner icon keeps its pre-conversion
|
||||
shape. Deliberately do NOT reset `border` here — the 1px grey border
|
||||
is supplied earlier (#chaticon {border:1px solid #ccc; border-bottom:
|
||||
none}) and is part of the intended visual. Overriding with border:0
|
||||
visibly broke the icon. See PR #7584 review feedback. */
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
padding: 5px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
/* Scope: the inner .buttonicon span here is just a glyph holder. Its global
|
||||
rule in icons.css applies `display: flex` which is fine for toolbar
|
||||
<button class="buttonicon"> instances but breaks inline layout when
|
||||
nested inside a button that's laying out label + glyph + counter on one
|
||||
line. Keep the glyph inline for the chat-icon corner widget. */
|
||||
#chaticon .buttonicon {
|
||||
display: inline;
|
||||
}
|
||||
#chaticon:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
#chaticon a {
|
||||
text-decoration: none
|
||||
|
||||
@ -98,6 +98,15 @@
|
||||
}
|
||||
|
||||
.toolbar .show-more-icon-btn {
|
||||
/* Reset user-agent <button> styling introduced when this was converted
|
||||
from <span> for a11y. Without these the native button border/background
|
||||
leak through and shift the glyph off-centre. */
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
display:none;
|
||||
cursor: pointer;
|
||||
height: 39px;
|
||||
|
||||
@ -263,6 +263,19 @@ exports.chat = (() => {
|
||||
|
||||
// initial messages are loaded in pad.js' _afterHandshake
|
||||
|
||||
$('#chaticon').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.show();
|
||||
});
|
||||
$('#titlecross').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.hide();
|
||||
});
|
||||
$('#titlesticky').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.stickToScreen(true);
|
||||
});
|
||||
|
||||
$('#chatcounter').text(0);
|
||||
$('#chatloadmessagesbutton').on('click', () => {
|
||||
const start = Math.max(this.historyPointer - 20, 0);
|
||||
|
||||
@ -66,8 +66,22 @@ class ToolbarItem {
|
||||
bind(callback) {
|
||||
if (this.isButton()) {
|
||||
this.$el.on('click', (event) => {
|
||||
// Stash the clicked button as the focus-restore target BEFORE we
|
||||
// blur :focus — but only for dropdown-opening buttons. Non-dropdown
|
||||
// commands (list toggles, bold, etc.) return focus to the ace editor
|
||||
// and should not touch _lastTrigger (it would retain a stale
|
||||
// reference and mess with later popup Esc-close focus handling).
|
||||
const cmd = this.getCommand();
|
||||
// @ts-ignore — padeditbar is the exported singleton defined below
|
||||
const isDropdownTrigger = exports.padeditbar.dropdowns.indexOf(cmd) !== -1;
|
||||
if (isDropdownTrigger) {
|
||||
const trigger = (this.$el.find('button')[0] as HTMLElement | undefined) ||
|
||||
(this.$el[0] as HTMLElement);
|
||||
// @ts-ignore
|
||||
if (trigger) exports.padeditbar._lastTrigger = trigger;
|
||||
}
|
||||
$(':focus').trigger('blur');
|
||||
callback(this.getCommand(), this);
|
||||
callback(cmd, this);
|
||||
event.preventDefault();
|
||||
});
|
||||
} else if (this.isSelect()) {
|
||||
@ -128,6 +142,7 @@ exports.padeditbar = new class {
|
||||
this._editbarPosition = 0;
|
||||
this.commands = {};
|
||||
this.dropdowns = [];
|
||||
this._lastTrigger = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
@ -145,7 +160,8 @@ exports.padeditbar = new class {
|
||||
});
|
||||
|
||||
$('.show-more-icon-btn').on('click', () => {
|
||||
$('.toolbar').toggleClass('full-icons');
|
||||
const expanded = $('.toolbar').toggleClass('full-icons').hasClass('full-icons');
|
||||
$('.show-more-icon-btn').attr('aria-expanded', String(expanded));
|
||||
});
|
||||
this.checkAllIconsAreDisplayedInToolbar();
|
||||
$(window).on('resize', _.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
|
||||
@ -208,6 +224,19 @@ exports.padeditbar = new class {
|
||||
$('.nice-select').removeClass('open');
|
||||
$('.toolbar-popup').removeClass('popup-show');
|
||||
|
||||
// Remember the trigger so we can restore focus when the dialog closes.
|
||||
// The Button click handler pre-sets `_lastTrigger` before calling blur(),
|
||||
// because blur would make document.activeElement === <body>. For other
|
||||
// paths (keyboard shortcut, programmatic open) fall back to whatever has
|
||||
// focus right now.
|
||||
const wasAnyOpen = $('.popup.popup-show').length > 0;
|
||||
if (!wasAnyOpen && moduleName !== 'none' && !this._lastTrigger) {
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body) this._lastTrigger = active;
|
||||
}
|
||||
|
||||
let openedModule = null;
|
||||
|
||||
// hide all modules and remove highlighting of all buttons
|
||||
if (moduleName === 'none') {
|
||||
for (const thisModuleName of this.dropdowns) {
|
||||
@ -236,9 +265,44 @@ exports.padeditbar = new class {
|
||||
} else if (thisModuleName === moduleName) {
|
||||
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
|
||||
module.addClass('popup-show');
|
||||
openedModule = module;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openedModule) {
|
||||
// Move focus into the now-visible popup so keyboard users land inside the dialog.
|
||||
// Skip if a command handler already placed focus inside this popup — the Embed
|
||||
// command focuses #linkinput deliberately, which is different from the first
|
||||
// tabbable element (a readonly checkbox) and should win.
|
||||
// Fallback: if no focusable descendant exists (e.g. #users where the only
|
||||
// input is disabled), focus the popup div itself so keydown events fire on
|
||||
// the outer document instead of being trapped in the ace editor iframe.
|
||||
const target = openedModule;
|
||||
requestAnimationFrame(() => {
|
||||
// If a command handler already placed focus inside the popup (e.g.
|
||||
// the Embed command focuses #linkinput, showusers focuses
|
||||
// #myusernameedit), honour that.
|
||||
if (target[0].contains(document.activeElement)) return;
|
||||
// Otherwise focus the popup container itself. This keeps keydown
|
||||
// events on the outer document (so Esc always dismisses the popup,
|
||||
// even when the popup has no directly-focusable descendants like
|
||||
// #users does), and it works uniformly across browsers without
|
||||
// getting tripped up by `visibility: hidden` nested popups.
|
||||
// Keyboard users can Tab from here into the popup's controls.
|
||||
if (!target.attr('tabindex')) target.attr('tabindex', '-1');
|
||||
target[0].focus();
|
||||
});
|
||||
} else if (wasAnyOpen && $('.popup.popup-show').length === 0 && this._lastTrigger) {
|
||||
// A popup was open at entry and is now closed — restore focus to the
|
||||
// trigger that opened it. Gated on `wasAnyOpen` so background callers
|
||||
// (e.g. connectivity-modal setup, periodic state handling) that
|
||||
// dispatch `toggleDropDown('none')` with no popup open don't yank
|
||||
// focus away from the editor to a stale toolbar button.
|
||||
const trigger = this._lastTrigger;
|
||||
this._lastTrigger = null;
|
||||
if (document.body.contains(trigger)) trigger.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
cbErr = err || new Error(err);
|
||||
} finally {
|
||||
@ -289,6 +353,35 @@ exports.padeditbar = new class {
|
||||
}
|
||||
|
||||
_bodyKeyEvent(evt) {
|
||||
// Escape while any popup is open: close it. We don't restrict to
|
||||
// `:focus inside popup` because some popups (e.g. #users) have no
|
||||
// focusable content on open — focus stays in the ace editor iframe —
|
||||
// but Esc should still dismiss them for keyboard users.
|
||||
if (evt.keyCode === 27 && $('.popup.popup-show').length > 0) {
|
||||
// Manually close popups that toggleDropDown('none') can't close:
|
||||
// * #users — explicitly skipped by the 'none' branch of
|
||||
// toggleDropDown so switching between other popups doesn't
|
||||
// hide the user list. Close here unless pinned (stickyUsers).
|
||||
// * Popups opened outside the editbar framework that were never
|
||||
// registered as dropdowns (e.g. #mycolorpicker, toggled
|
||||
// directly by pad_userlist.ts). toggleDropDown iterates only
|
||||
// this.dropdowns so these are invisible to it.
|
||||
// Leave registered-dropdown popups (settings/embed/etc.) for
|
||||
// toggleDropDown('none') so its `wasAnyOpen` detection still sees
|
||||
// them as open and its focus-restore branch fires for the trigger.
|
||||
const registered = this.dropdowns;
|
||||
$('.popup.popup-show').each((_, el) => {
|
||||
const $p = $(el);
|
||||
const id = $p.attr('id') || '';
|
||||
if (id === 'users' && $p.hasClass('stickyUsers')) return;
|
||||
if (id !== 'users' && id !== '' && registered.indexOf(id) !== -1) return;
|
||||
$p.removeClass('popup-show');
|
||||
if (id) $(`li[data-key="${id}"] > a`).removeClass('selected');
|
||||
});
|
||||
this.toggleDropDown('none');
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
// If the event is Alt F9 or Escape & we're already in the editbar menu
|
||||
// Send the users focus back to the pad
|
||||
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
|
||||
|
||||
17
src/static/js/vendors/html10n.ts
vendored
17
src/static/js/vendors/html10n.ts
vendored
@ -662,9 +662,20 @@ export class Html10n {
|
||||
if (node.children.length === 0 || prop != 'textContent') {
|
||||
// @ts-ignore
|
||||
node[prop] = str.str!
|
||||
node.setAttribute("aria-label", str.str!); // Sets the aria-label
|
||||
// The idea of the above is that we always have an aria value
|
||||
// This might be a bit of an abrupt solution but let's see how it goes
|
||||
// Populate aria-label from the translation so screen readers get a
|
||||
// localized accessible name. Preserve an author-supplied aria-label
|
||||
// (one present in the template without a marker), but keep our own
|
||||
// html10n-generated values in sync across language changes by
|
||||
// overwriting them. The `data-l10n-aria-label` marker distinguishes
|
||||
// the two: set when we populate it, checked on subsequent passes so
|
||||
// `pad.applyLanguage()` refreshes the accessible name.
|
||||
// See PR #7584 review feedback.
|
||||
const generatedMarker = 'data-l10n-aria-label';
|
||||
if (!node.hasAttribute('aria-label') ||
|
||||
node.getAttribute(generatedMarker) === 'true') {
|
||||
node.setAttribute('aria-label', str.str!);
|
||||
node.setAttribute(generatedMarker, 'true');
|
||||
}
|
||||
} else {
|
||||
let children = node.childNodes,
|
||||
found = false
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
<%
|
||||
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs;
|
||||
var renderLang = (req && typeof req.acceptsLanguages === 'function'
|
||||
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
|
||||
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
|
||||
%>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="<%=renderLang%>" dir="<%=renderDir%>">
|
||||
|
||||
<title><%=settings.title%></title>
|
||||
<meta charset="utf-8">
|
||||
|
||||
@ -2,9 +2,12 @@
|
||||
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
|
||||
, pluginUtils = require('ep_etherpad-lite/static/js/pluginfw/shared')
|
||||
;
|
||||
var renderLang = (req && typeof req.acceptsLanguages === 'function'
|
||||
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
|
||||
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
|
||||
%>
|
||||
<!doctype html>
|
||||
<html translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
|
||||
<head>
|
||||
<% e.begin_block("htmlHead"); %>
|
||||
<% e.end_block(); %>
|
||||
@ -71,7 +74,7 @@
|
||||
<%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>
|
||||
<% e.end_block(); %>
|
||||
</ul>
|
||||
<span class="show-more-icon-btn"></span> <!-- use on small screen to display hidden toolbar buttons -->
|
||||
<button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
|
||||
</div>
|
||||
|
||||
<% e.begin_block("afterEditbar"); %><% e.end_block(); %>
|
||||
@ -114,11 +117,11 @@
|
||||
<!-- SETTINGS POPUP (change font, language, chat parameters) -->
|
||||
<!------------------------------------------------------------->
|
||||
|
||||
<div id="settings" class="popup"><div class="popup-content">
|
||||
<div id="settings" class="popup" role="dialog" aria-modal="true" aria-labelledby="settings-title"><div class="popup-content">
|
||||
<% if (settings.enablePadWideSettings) { %>
|
||||
<h1 data-l10n-id="pad.settings.title">Settings</h1>
|
||||
<h1 id="settings-title" data-l10n-id="pad.settings.title">Settings</h1>
|
||||
<% } else { %>
|
||||
<h1 data-l10n-id="pad.settings.padSettings">Pad Settings</h1>
|
||||
<h1 id="settings-title" data-l10n-id="pad.settings.padSettings">Pad Settings</h1>
|
||||
<% } %>
|
||||
<div class="settings-sections">
|
||||
<div id="user-settings-section" class="settings-section">
|
||||
@ -252,8 +255,8 @@
|
||||
<!-- IMPORT EXPORT POPUP -->
|
||||
<!------------------------->
|
||||
|
||||
<div id="import_export" class="popup"><div class="popup-content">
|
||||
<h1 data-l10n-id="pad.importExport.import_export"></h1>
|
||||
<div id="import_export" class="popup" role="dialog" aria-modal="true" aria-labelledby="importexport-title"><div class="popup-content">
|
||||
<h1 id="importexport-title" data-l10n-id="pad.importExport.import_export"></h1>
|
||||
<div class="acl-write">
|
||||
<% e.begin_block("importColumn"); %>
|
||||
<h2 data-l10n-id="pad.importExport.import"></h2>
|
||||
@ -304,7 +307,7 @@
|
||||
<!-- CONNECTIVITY POPUP (when you get disconnected) -->
|
||||
<!---------------------------------------------------->
|
||||
|
||||
<div id="connectivity" class="popup"><div class="popup-content">
|
||||
<div id="connectivity" class="popup" role="dialog" aria-modal="true" aria-label="Connection status"><div class="popup-content">
|
||||
<% e.begin_block("modals"); %>
|
||||
<div class="connected visible">
|
||||
<h2 data-l10n-id="pad.modals.connected"></h2>
|
||||
@ -387,9 +390,9 @@
|
||||
<!-- EMBED POPUP (Share, embed) -->
|
||||
<!-------------------------------->
|
||||
|
||||
<div id="embed" class="popup"><div class="popup-content">
|
||||
<div id="embed" class="popup" role="dialog" aria-modal="true" aria-labelledby="embed-title"><div class="popup-content">
|
||||
<% e.begin_block("embedPopup"); %>
|
||||
<h1 data-l10n-id="pad.share"></h1>
|
||||
<h1 id="embed-title" data-l10n-id="pad.share"></h1>
|
||||
<div id="embedreadonly" class="acl-write">
|
||||
<input type="checkbox" id="readonlyinput">
|
||||
<label for="readonlyinput" data-l10n-id="pad.share.readonly"></label>
|
||||
@ -411,11 +414,11 @@
|
||||
<!-- USERS POPUP (set username, color, see other users names & color) -->
|
||||
<!---------------------------------------------------------------------->
|
||||
|
||||
<div id="users" class="popup"><div class="popup-content">
|
||||
<div id="users" class="popup" role="dialog" aria-modal="true" aria-label="Users on this pad"><div class="popup-content">
|
||||
<% e.begin_block("userlist"); %>
|
||||
<div id="connectionstatus"></div>
|
||||
<div id="myuser">
|
||||
<div id="mycolorpicker" class="popup"><div class="popup-content">
|
||||
<div id="mycolorpicker" class="popup" role="dialog" aria-modal="true" aria-label="Choose your author color"><div class="popup-content">
|
||||
<div id="colorpicker"></div>
|
||||
<div class="btn-container">
|
||||
<button id="mycolorpickersave" data-l10n-id="pad.colorpicker.save" class="btn btn-primary"></button>
|
||||
@ -428,7 +431,7 @@
|
||||
<input type="text" id="myusernameedit" disabled="disabled" data-l10n-id="pad.userlist.entername">
|
||||
</div>
|
||||
</div>
|
||||
<div id="otherusers" role="document">
|
||||
<div id="otherusers" role="region" aria-live="polite" aria-label="Active users on this pad">
|
||||
<table id="otheruserstable" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr><td></td></tr>
|
||||
</table>
|
||||
@ -442,18 +445,18 @@
|
||||
<!----------- CHAT ------------>
|
||||
<!----------------------------->
|
||||
|
||||
<div id="chaticon" class="visible" onclick="chat.show();return false;" title="Chat (Alt C)">
|
||||
<button type="button" id="chaticon" class="visible" title="Chat (Alt C)" data-l10n-id="pad.chat.title">
|
||||
<span id="chatlabel" data-l10n-id="pad.chat"></span>
|
||||
<span class="buttonicon buttonicon-chat"></span>
|
||||
<span id="chatcounter">0</span>
|
||||
</div>
|
||||
<span class="buttonicon buttonicon-chat" aria-hidden="true"></span>
|
||||
<span id="chatcounter" aria-label="Unread messages">0</span>
|
||||
</button>
|
||||
|
||||
<div id="chatbox">
|
||||
<div class="chat-content">
|
||||
<div id="titlebar">
|
||||
<h1 id ="titlelabel" data-l10n-id="pad.chat"></h1>
|
||||
<a id="titlecross" class="hide-reduce-btn" onClick="chat.hide();return false;">- </a>
|
||||
<a id="titlesticky" class="stick-to-screen-btn" onClick="chat.stickToScreen(true);return false;" data-l10n-id="pad.chat.stick.title">█ </a>
|
||||
<button type="button" id="titlecross" class="hide-reduce-btn" aria-label="Close chat">−</button>
|
||||
<button type="button" id="titlesticky" class="stick-to-screen-btn" data-l10n-id="pad.chat.stick.title">█</button>
|
||||
</div>
|
||||
<div id="chattext" class="thin-scrollbar" aria-live="polite" aria-relevant="additions removals text" role="log" aria-atomic="false">
|
||||
<div alt="loading.." id="chatloadmessagesball" class="chatloadmessages loadingAnimation" align="top"></div>
|
||||
@ -472,8 +475,8 @@
|
||||
<!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) -->
|
||||
<!------------------------------------------------------------------>
|
||||
<% if (settings.skinName == 'colibris') { %>
|
||||
<div id="skin-variants" class="popup"><div class="popup-content">
|
||||
<h1>Skin Builder</h1>
|
||||
<div id="skin-variants" class="popup" role="dialog" aria-modal="true" aria-labelledby="skin-variants-title"><div class="popup-content">
|
||||
<h1 id="skin-variants-title">Skin Builder</h1>
|
||||
|
||||
<div class="dropdowns-container">
|
||||
<% containers = [ "toolbar", "background", "editor" ]; %>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<%
|
||||
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
|
||||
var renderLang = (req && typeof req.acceptsLanguages === 'function'
|
||||
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
|
||||
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
|
||||
%>
|
||||
<!doctype html>
|
||||
<html translate="no" class="pad <%=settings.skinVariants%>">
|
||||
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=settings.skinVariants%>">
|
||||
<head>
|
||||
<title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title>
|
||||
<script>
|
||||
|
||||
135
src/tests/frontend-new/specs/a11y_dialogs.spec.ts
Normal file
135
src/tests/frontend-new/specs/a11y_dialogs.spec.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {goToNewPad} from '../helper/padHelper';
|
||||
|
||||
test.beforeEach(async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
});
|
||||
|
||||
test('html element has a non-empty lang attribute', async ({page}) => {
|
||||
const lang = await page.locator('html').getAttribute('lang');
|
||||
expect(lang).toBeTruthy();
|
||||
expect(lang!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('settings popup has dialog semantics, Escape closes it, focus returns to trigger', async ({page}) => {
|
||||
const settingsButton = page.locator('button[data-l10n-id="pad.toolbar.settings.title"]');
|
||||
await settingsButton.click();
|
||||
|
||||
const dialog = page.locator('#settings');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-labelledby', 'settings-title');
|
||||
await expect(dialog).toHaveClass(/popup-show/);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toHaveClass(/popup-show/);
|
||||
|
||||
// Focus should return to the trigger button (the cog icon).
|
||||
const focusedL10nId =
|
||||
await page.evaluate(() => document.activeElement?.getAttribute('data-l10n-id') || '');
|
||||
expect(focusedL10nId).toBe('pad.toolbar.settings.title');
|
||||
});
|
||||
|
||||
test('import_export popup has dialog semantics', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.import_export.title"]').click();
|
||||
const dialog = page.locator('#import_export');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-labelledby', 'importexport-title');
|
||||
});
|
||||
|
||||
test('embed popup has dialog semantics', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.embed.title"]').click();
|
||||
const dialog = page.locator('#embed');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-labelledby', 'embed-title');
|
||||
});
|
||||
|
||||
test('users popup has dialog semantics with aria-label', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.showusers.title"]').click();
|
||||
const dialog = page.locator('#users');
|
||||
await expect(dialog).toHaveAttribute('role', 'dialog');
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
await expect(dialog).toHaveAttribute('aria-label', 'Users on this pad');
|
||||
});
|
||||
|
||||
test('users popup closes on Escape even when focus is outside the popup', async ({page}) => {
|
||||
// Opening #users leaves focus in the ace editor iframe because its only
|
||||
// would-be-focusable element (#myusernameedit) is disabled. Esc must still
|
||||
// dismiss the dialog. Regression for PR #7584 review feedback.
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.showusers.title"]').click();
|
||||
const dialog = page.locator('#users');
|
||||
await expect(dialog).toHaveClass(/popup-show/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toHaveClass(/popup-show/);
|
||||
});
|
||||
|
||||
test('export links have an accessible name from their localized content', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.import_export.title"]').click();
|
||||
// The Word/PDF/ODF export links are removed client-side by pad_impexp.ts
|
||||
// when soffice is not configured, so only assert on links that the
|
||||
// environment actually renders. For the ones that are present, their
|
||||
// accessible name comes from the localized child span (data-l10n-id
|
||||
// pad.importExport.exportetherpad etc.), not a hard-coded English
|
||||
// aria-label. Assert the visible text is non-empty, which is what a
|
||||
// screen reader will announce.
|
||||
const ids = [
|
||||
'#exportetherpada',
|
||||
'#exporthtmla',
|
||||
'#exportplaina',
|
||||
'#exportworda',
|
||||
'#exportpdfa',
|
||||
'#exportopena',
|
||||
];
|
||||
for (const id of ids) {
|
||||
const locator = page.locator(id);
|
||||
if ((await locator.count()) === 0) continue;
|
||||
const text = (await locator.innerText()).trim();
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaticon is a button with an accessible name', async ({page}) => {
|
||||
const chatIcon = page.locator('#chaticon');
|
||||
const tagName = await chatIcon.evaluate((el) => el.tagName.toLowerCase());
|
||||
expect(tagName).toBe('button');
|
||||
// aria-label is populated by html10n from the pad.chat.title translation,
|
||||
// so we assert it is non-empty rather than a specific English string.
|
||||
const label = await chatIcon.getAttribute('aria-label');
|
||||
expect(label && label.length > 0).toBe(true);
|
||||
});
|
||||
|
||||
test('chat header close/pin controls are buttons with accessible names', async ({page}) => {
|
||||
await page.locator('#chaticon').click();
|
||||
// #titlecross has no data-l10n-id so its aria-label stays static English.
|
||||
// #titlesticky has data-l10n-id, so html10n fills aria-label from the
|
||||
// translation; assert non-empty rather than a specific value.
|
||||
const close = page.locator('#titlecross');
|
||||
expect(await close.evaluate((n) => n.tagName.toLowerCase())).toBe('button');
|
||||
await expect(close).toHaveAttribute('aria-label', 'Close chat');
|
||||
|
||||
const sticky = page.locator('#titlesticky');
|
||||
expect(await sticky.evaluate((n) => n.tagName.toLowerCase())).toBe('button');
|
||||
const stickyLabel = await sticky.getAttribute('aria-label');
|
||||
expect(stickyLabel && stickyLabel.length > 0).toBe(true);
|
||||
});
|
||||
|
||||
test('otherusers region has aria-live and aria-label (no aria-role typo)', async ({page}) => {
|
||||
await page.locator('button[data-l10n-id="pad.toolbar.showusers.title"]').click();
|
||||
const region = page.locator('#otherusers');
|
||||
await expect(region).toHaveAttribute('role', 'region');
|
||||
await expect(region).toHaveAttribute('aria-live', 'polite');
|
||||
await expect(region).toHaveAttribute('aria-label', 'Active users on this pad');
|
||||
// The deprecated aria-role attribute should not appear.
|
||||
const ariaRole = await region.getAttribute('aria-role');
|
||||
expect(ariaRole).toBeNull();
|
||||
});
|
||||
|
||||
test('show-more toolbar button has aria-label and aria-expanded', async ({page}) => {
|
||||
const btn = page.locator('.show-more-icon-btn');
|
||||
const tag = await btn.evaluate((el) => el.tagName.toLowerCase());
|
||||
expect(tag).toBe('button');
|
||||
await expect(btn).toHaveAttribute('aria-label', 'Show more toolbar buttons');
|
||||
await expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user