Skip to content

fix(a11y): dialog semantics, focus management, icon labels, html lang#7584

Open
JohnMcLear wants to merge 9 commits intoether:developfrom
JohnMcLear:fix/a11y-dialogs-labels-lang
Open

fix(a11y): dialog semantics, focus management, icon labels, html lang#7584
JohnMcLear wants to merge 9 commits intoether:developfrom
JohnMcLear:fix/a11y-dialogs-labels-lang

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

Summary

Highest-impact accessibility fixes from a fresh audit of the pad surface. Four themes:

  1. Dialog semantics — every .popup (#settings, #import_export, #connectivity, #embed, #users, #mycolorpicker, #skin-variants) now exposes role="dialog", aria-modal="true", and either aria-labelledby (when an <h1> is present) or aria-label. Fixed the invalid aria-role="document" on #otherusers (it's role, not aria-role); now role="region" + aria-live="polite" so collaborator joins/leaves get announced.

  2. Focus managementtoggleDropDown now remembers the trigger button, focuses the first focusable element inside the popup on open, and restores focus to the trigger on close. Escape while focus is inside an open popup closes it (previously did nothing — users had to click outside).

  3. Real buttons for icon-only controls

    • <div id="chaticon" onclick="…"><button type="button" id="chaticon" aria-label="Open chat">
    • <a id="titlecross" onClick="…"><button aria-label="Close chat">
    • <a id="titlesticky" onClick="…"><button aria-label="Pin chat to screen">
    • <span class="show-more-icon-btn"><button aria-label="Show more toolbar buttons" aria-expanded> (toggles aria-expanded on click)
    • All six export links (#exportetherpada#exportopena) get a descriptive aria-label; the inner icon spans get aria-hidden="true" so SR doesn't double-read.
    • Theme switcher knob: aria-label="theme-switcher-knob" (a class-style identifier) → "Toggle theme" (human text).
    • Decorative chat-bubble glyph and #chatcounter are now properly labelled (aria-hidden on the icon, aria-label="Unread messages" on the counter).
  4. <html> lang/dir negotiated per requestpad.html, index.html, timeslider.html now render with lang and dir matching the client's Accept-Language (negotiated against i18n.availableLangs), falling back to en/ltr. Client-side l10n.ts already refines both attributes after html10n loads, so this just gives screen readers a correct language hint during the brief pre-localization window.

Out of scope (deliberate)

  • Full focus-trap with Tab cycling — present popups are short, so init-focus + Escape is adequate. A library swap can come later.
  • WCAG-AA contrast pass, 44×44 touch targets, full :focus-visible sweep.
  • aria-label localization on container dialogs — Etherpad's html10n only translates a fixed attribute list (title/alt/placeholder/value/innerHTML/textContent), not aria-label. Dialog labels are English-only until html10n grows that affordance.

Test plan

  • CI: ts-check passes (modified TS files have // @ts-nocheck so no type regressions possible)
  • CI: existing Playwright suite still green (chat/userlist tests use #chaticon/#titlecross selectors which are unchanged)
  • CI: new spec tests/frontend-new/specs/a11y_dialogs.spec.ts passes — covers <html lang>, dialog ARIA, Escape-closes-and-restores-focus, export labels, chat button conversion, userlist region, show-more button
  • Manual: tab through the toolbar, open Settings, press Escape, focus returns to the cog
  • Manual: with VoiceOver/NVDA, opening Settings announces "Pad Settings dialog"

🤖 Generated with Claude Code

@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 23, 2026

Code Review by Qodo

🐞 Bugs (10) 📘 Rule violations (2) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Stale aria-label after relocalize 🐞 Bug ≡ Correctness ⭐ New
Description
html10n.translateNode() now refuses to overwrite an existing aria-label, but html10n itself sets
aria-label during the first localization pass, so later html10n.localize() calls (triggered by
language changes) will not update accessible names. This can leave screen readers announcing labels
in the old language even though the visible text has been translated, because aria-label overrides
text content for the accessible name.
Code

src/static/js/vendors/html10n.ts[R665-673]

+      // Populate aria-label from the translation so screen readers always get
+      // a localized accessible name, but do not overwrite an explicit
+      // aria-label that an author has already set. This lets templates use
+      // static English aria-labels for icon-only controls (export links,
+      // chat icon, close/pin buttons) without losing them at localization
+      // time. See PR #7584 review feedback.
+      if (!node.hasAttribute('aria-label')) {
+        node.setAttribute('aria-label', str.str!);
+      }
Evidence
On the first localization pass, html10n sets aria-label from the translated string. After this PR,
subsequent localization passes skip updating aria-label because the attribute already exists, even
though the language has changed. Etherpad does re-run localization when language changes (via
pad.applyLanguage()), so this can result in stale accessible names.

src/static/js/vendors/html10n.ts[661-673]
src/static/js/pad.ts[570-573]
src/static/js/pad.ts[783-791]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`html10n.translateNode()` sets `aria-label` from the translated string. After the first translation, the attribute exists, and with the new guard (`if (!node.hasAttribute('aria-label'))`) subsequent language changes will not update the aria-label. Because `aria-label` overrides an element’s text for accessible-name calculation, screen readers can announce stale (previous-language) labels.

## Issue Context
Etherpad re-localizes at runtime (language changes call `html10n.localize()` via `pad.applyLanguage()`), so `translateNode()` is expected to be idempotent and update derived attributes.

## Fix Focus Areas
- src/static/js/vendors/html10n.ts[661-673]
- src/static/js/pad.ts[570-573]

## Suggested fix
Change the guard to only preserve **author-supplied** aria-labels, while still allowing updates to aria-labels previously generated by html10n. One simple pattern:

- When html10n sets aria-label, also set a marker attribute (e.g. `data-l10n-aria-label="true"`).
- On subsequent translations, overwrite aria-label if the marker is present.

Example:
```ts
const generatedAttr = 'data-l10n-aria-label';
if (!node.hasAttribute('aria-label') || node.getAttribute(generatedAttr) === 'true') {
 node.setAttribute('aria-label', str.str!);
 node.setAttribute(generatedAttr, 'true');
}
```

This preserves explicit template-provided aria-labels, but keeps generated aria-labels in sync with language switches.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Escape won't close colorpicker 🐞 Bug ≡ Correctness
Description
padeditbar._bodyKeyEvent() intercepts Escape whenever any .popup has .popup-show, but it only
closes dropdown popups via toggleDropDown('none') (and special-cases #users). Popups that are
opened outside toggleDropDown (such as #mycolorpicker) keep .popup-show, so Escape becomes a
no-op while still calling preventDefault() and returning early.
Code

src/static/js/pad_editbar.ts[R347-363]

+    // 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) {
+      // `toggleDropDown('none')` intentionally skips the users popup so
+      // switching between other popups doesn't hide the user list. For
+      // Escape we want the users popup to close too (unless pinned).
+      const openPopup = $('.popup.popup-show').first();
+      if (openPopup.attr('id') === 'users' && !openPopup.hasClass('stickyUsers')) {
+        openPopup.removeClass('popup-show');
+        $('li[data-key=users] > a').removeClass('selected');
+      }
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
The new Escape handler closes #users explicitly and then calls toggleDropDown('none'), which
only iterates this.dropdowns and therefore cannot close #mycolorpicker. Separately, the color
picker is shown by directly adding the popup-show class, so it satisfies the Escape handler’s
$('.popup.popup-show').length > 0 condition but is never removed.

src/static/js/pad_editbar.ts[346-363]
src/static/js/pad_editbar.ts[235-266]
src/static/js/pad_userlist.ts[587-616]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape handling in `pad_editbar._bodyKeyEvent()` triggers for any `.popup.popup-show`, but the close logic only affects dropdown popups (and partially `#users`). Popups opened by other code paths (e.g. `#mycolorpicker` from `pad_userlist.ts`) remain open, so Escape is swallowed (preventDefault + early return) without dismissing the visible popup.
### Issue Context
`#mycolorpicker` is opened by directly adding `.popup-show` and is not part of `padeditbar.dropdowns`, so `toggleDropDown('none')` cannot close it.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[346-363]
- src/static/js/pad_editbar.ts[235-266]
- src/static/js/pad_userlist.ts[587-616]
### Suggested fix
Update the Escape branch to remove `.popup-show` from *all* open popups that should be dismissible via Escape (at least including `#mycolorpicker`, and excluding pinned/sticky cases such as `#users.stickyUsers`). For example:
- Identify all `$('.popup.popup-show')` elements.
- Filter out popups that should remain open (e.g. `#users.stickyUsers`).
- Remove `.popup-show` from the remaining open popups.
- Clear any corresponding toolbar `selected` states as needed.
- Then call `toggleDropDown('none')` (or equivalent) purely for shared cleanup + focus restore, ensuring it does not re-swallow Escape when no popups remain.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Stale focus restoration 🐞 Bug ≡ Correctness
Description
padeditbar.toggleDropDown('none') now restores focus to this._lastTrigger whenever no popups are
open, even if no popup was previously open. Because _lastTrigger is set on every toolbar button
click, background calls to toggleDropDown('none') (e.g., connection-state handling) can unexpectedly
move focus from the editor to a stale toolbar button.
Code

src/static/js/pad_editbar.ts[R268-287]

+      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.
+        const target = openedModule;
+        requestAnimationFrame(() => {
+          if (target[0].contains(document.activeElement)) return;
+          const focusable = target.find(
+              'button:visible, a[href]:visible, input:not([disabled]):visible, ' +
+              'select:not([disabled]):visible, textarea:not([disabled]):visible, ' +
+              '[tabindex]:not([tabindex="-1"]):visible').first();
+          if (focusable.length) focusable[0].focus();
+        });
+      } else if ($('.popup.popup-show').length === 0 && this._lastTrigger) {
+        // All popups closed — restore focus to the element that opened the first one.
+        const trigger = this._lastTrigger;
+        this._lastTrigger = null;
+        if (document.body.contains(trigger)) trigger.focus();
+      }
Evidence
_lastTrigger is set for every toolbar button click, not just dropdown triggers, and it is only
cleared when toggleDropDown() decides to restore focus. Separately, multiple non-UI flows call
toggleDropDown('none') even when no popup is open (connectivity modal setup, and periodic
connection-state handling), which can trigger the unconditional focus-restore block and yank focus
away from the editor.

src/static/js/pad_editbar.ts[66-81]
src/static/js/pad_editbar.ts[210-287]
src/static/js/pad_modals.ts[35-44]
src/static/js/pad.ts[883-888]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`toggleDropDown('none')` restores focus to `this._lastTrigger` even when no popup was previously open, which can steal focus during programmatic calls (e.g., connectivity state changes).
### Issue Context
- `_lastTrigger` is set on every toolbar button click.
- Multiple code paths call `toggleDropDown('none')` as a cleanup step regardless of whether a popup is open.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-81]
- src/static/js/pad_editbar.ts[210-287]
### Suggested fix
- Gate the focus-restore block so it only runs when a popup was actually open at the start of the function (e.g., `wasAnyOpen === true`) and is now closed.
- Consider clearing `_lastTrigger` when handling non-dropdown toolbar commands, or only setting `_lastTrigger` for dropdown-opening commands, to avoid stale values lingering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (5)
4. 4-space indent in a11y spec 📘 Rule violation ⚙ Maintainability
Description
The new Playwright spec introduces 4-space indentation in wrapped function arguments, violating the
repository's 2-space indentation requirement. This can reduce formatting consistency and trigger
lint/format tooling conflicts.
Code

src/tests/frontend-new/specs/a11y_dialogs.spec.ts[R14-16]

+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"]');
Evidence
Compliance requires no tabs and consistent 2-space indentation. The added test uses 4 spaces before
async ({page}) => { as a continuation indent, which is an indentation width other than two spaces.

src/tests/frontend-new/specs/a11y_dialogs.spec.ts[14-16]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new Playwright spec contains indentation using 4 spaces (not a 2-space multiple per indentation level as required by the repo style rule).
## Issue Context
The compliance checklist requires 2-space indentation and forbids inconsistent indentation widths in new/modified code.
## Fix Focus Areas
- src/tests/frontend-new/specs/a11y_dialogs.spec.ts[14-16]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Focus restore captures wrong trigger 🐞 Bug ≡ Correctness
Description
toggleDropDown() tries to remember the trigger via document.activeElement, but toolbar clicks
blur the focused element before executing the command, so _lastTrigger is frequently unset or not
the actual opener and focus is not reliably restored on close/Escape.
Code

src/static/js/pad_editbar.ts[R213-218]

+      // Remember the trigger so we can restore focus when the dialog closes.
+      const wasAnyOpen = $('.popup.popup-show').length > 0;
+      if (!wasAnyOpen && moduleName !== 'none') {
+        const active = document.activeElement;
+        if (active && active !== document.body) this._lastTrigger = active;
+      }
Evidence
Toolbar item clicks explicitly blur the focused element before invoking the dropdown command, but
toggleDropDown() records the trigger *after* that blur using document.activeElement and skips
saving if it is document.body, so _lastTrigger is often null. This undermines the PR’s promised
“restore focus to the trigger” behavior.

src/static/js/pad_editbar.ts[66-72]
src/static/js/pad_editbar.ts[202-218]
src/static/js/pad_editbar.ts[255-270]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`toggleDropDown()` stores the trigger based on `document.activeElement`, but toolbar click handling blurs the currently focused element before calling the command, so `document.activeElement` is often `body` (or otherwise not the intended trigger). This makes focus restoration unreliable.
### Issue Context
The toolbar click handler intentionally blurs `:focus` before executing the dropdown command. `toggleDropDown()` should therefore not depend on `document.activeElement` at that moment.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-72]
- src/static/js/pad_editbar.ts[202-218]
### Suggested fix
- Capture the actual trigger element from the originating UI event (e.g., the clicked button) and store it before blurring, then pass it through to `toggleDropDown()` (or store it on the padeditbar instance before invoking the command).
- Alternatively, derive the trigger deterministically from `moduleName` (e.g., locate `li[data-key=<moduleName>] button`) instead of using `document.activeElement`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Users Escape close broken 🐞 Bug ≡ Correctness
Description
Pressing Escape inside the Users popup calls toggleDropDown('none'), but toggleDropDown('none')
explicitly skips the users module, so the Users dialog won’t close. Because the handler
preventsDefault() and returns, the existing Escape behavior won’t run either, leaving keyboard users
stuck in the Users popup.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
The new Escape branch always calls toggleDropDown('none') when focus is inside a visible popup;
toggleDropDown('none') then skips closing the 'users' popup, so Escape can’t dismiss it.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[232-236]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but `toggleDropDown('none')` intentionally skips the `users` dropdown, so Escape does not close the Users popup.
### Issue Context
The new Escape handler is meant to close any open popup and restore focus. The Users popup is treated specially in `toggleDropDown('none')` and is skipped.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[329-336]
- src/static/js/pad_editbar.ts[232-246]
### Implementation notes
One of:
1) Remove the `users` skip in the `moduleName === 'none'` branch, but preserve the `stickyUsers` behavior (do not close if sticky), or
2) In the Escape handler, detect if `#users` is open and not sticky, and explicitly close it (remove `popup-show`, remove `.selected` from the toolbar button) instead of calling `toggleDropDown('none')`.
Ensure focus restoration still works after close.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Embed focus overridden 🐞 Bug ≡ Correctness
Description
toggleDropDown() now focuses the first focusable element in the opened popup on the next animation
frame, which overrides existing command-specific focus logic. In the Embed popup this steals focus
from #linkinput (which the embed command intentionally focuses/selects) and moves it to the readonly
checkbox instead.
Code

src/static/js/pad_editbar.ts[R264-273]

+      if (openedModule) {
+        // Move focus into the now-visible popup so keyboard users land inside the dialog.
+        const target = openedModule;
+        requestAnimationFrame(() => {
+          const focusable = target.find(
+              'button:visible, a[href]:visible, input:not([disabled]):visible, ' +
+              'select:not([disabled]):visible, textarea:not([disabled]):visible, ' +
+              '[tabindex]:not([tabindex="-1"]):visible').first();
+          if (focusable.length) focusable[0].focus();
+        });
Evidence
The embed command focuses and selects #linkinput right after opening the popup, but toggleDropDown
schedules an rAF focus to the first focusable element. In the Embed markup, the first focusable is
#readonlyinput (a checkbox) before #linkinput, so the rAF callback will pull focus away from
#linkinput.

src/static/js/pad_editbar.ts[264-273]
src/static/js/pad_editbar.ts[429-433]
src/templates/pad.html[328-338]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`toggleDropDown()` auto-focuses the first focusable control in the opened popup via `requestAnimationFrame()`. This can override existing command handlers that deliberately focus a particular element (notably the Embed command focuses/selects `#linkinput`).
### Issue Context
In `pad.html`, the Embed dialog has `#readonlyinput` before `#linkinput`, so the auto-focus targets `#readonlyinput`, stealing focus from `#linkinput`.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[264-273]
- src/static/js/pad_editbar.ts[429-433]
- src/templates/pad.html[328-338]
### Implementation notes
Modify the rAF auto-focus behavior to be conditional, for example:
- In the rAF callback, only focus the first element if focus is still on the trigger/body (or not already inside the opened popup), OR
- Allow callers to opt out (e.g., `toggleDropDown(moduleName, {autoFocus: false})`) and use that for `embed`, OR
- Prefer a more specific focus target for certain dialogs (Embed -> `#linkinput`).
Preserve the new general behavior for dialogs that don’t explicitly manage focus.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. L10n overwrites aria-labels 🐞 Bug ☼ Reliability
Description
html10n unconditionally sets aria-label to the translated string for any node it translates, so
elements with data-l10n-id ending in .title will have their aria-label overwritten. This makes the
new Playwright assertions for chat button/pin label incorrect and potentially makes the in-HTML
aria-label values ineffective at runtime.
Code

src/tests/frontend-new/specs/a11y_dialogs.spec.ts[R72-89]

+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');
+  await expect(chatIcon).toHaveAttribute('aria-label', 'Open chat');
+});
+
+test('chat header close/pin controls are buttons with labels', async ({page}) => {
+  await page.locator('#chaticon').click();
+  for (const [id, label, tag] of [
+    ['#titlecross', 'Close chat', 'button'],
+    ['#titlesticky', 'Pin chat to screen', 'button'],
+  ] as const) {
+    const el = page.locator(id);
+    const tagName = await el.evaluate((node) => node.tagName.toLowerCase());
+    expect(tagName).toBe(tag);
+    await expect(el).toHaveAttribute('aria-label', label);
+  }
Evidence
html10n sets aria-label to the translation string whenever it applies a translation (including
when translating the title attribute). #chaticon and #titlesticky both have data-l10n-id keys
that end with .title, so html10n will overwrite their aria-labels with the localized strings from
locales (en: "Open the chat for this pad.", "Stick chat to screen"), which will not match the test’s
expected "Open chat" / "Pin chat to screen".

src/static/js/vendors/html10n.ts[641-667]
src/locales/en.json[155-163]
src/templates/pad.html[383-395]
src/tests/frontend-new/specs/a11y_dialogs.spec.ts[72-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new tests assert hard-coded English `aria-label` values for elements that html10n will later overwrite because they have `data-l10n-id` values ending in `.title`.
### Issue Context
In this repo’s html10n implementation, translating an element sets both the translated property (e.g., `title`) and also sets `aria-label` to the same translated string.
### Fix Focus Areas
- src/static/js/vendors/html10n.ts[661-667]
- src/templates/pad.html[383-395]
- src/tests/frontend-new/specs/a11y_dialogs.spec.ts[72-90]
### Implementation notes
Pick one consistent approach:
1) **Rely on html10n for aria-label**: remove the hard-coded `aria-label` attributes from elements that have `data-l10n-id="*.title"`, and update the Playwright tests to assert that `aria-label` is non-empty (or equals the element’s localized `title`) rather than matching a fixed string.
OR
2) **Preserve explicit aria-label**: change html10n so it does **not** overwrite an existing `aria-label` attribute (only set it if missing). Then the tests can keep asserting the explicit labels.
Either way, ensure tests are stable across locales (avoid asserting fixed English strings if localization can run).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

9. Popup behavior undocumented 📘 Rule violation ⚙ Maintainability ⭐ New
Description
This PR changes user-facing popup behavior (Escape-to-close, focus restore), but it does not update
any user/developer documentation under doc/. Lack of documentation increases support burden and
makes the behavioral change easy to miss for downstream deployers.
Code

src/static/js/pad_editbar.ts[R352-367]

+    // 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) {
+      // `toggleDropDown('none')` intentionally skips the users popup so
+      // switching between other popups doesn't hide the user list. For
+      // Escape we want the users popup to close too (unless pinned).
+      const openPopup = $('.popup.popup-show').first();
+      if (openPopup.attr('id') === 'users' && !openPopup.hasClass('stickyUsers')) {
+        openPopup.removeClass('popup-show');
+        $('li[data-key=users] > a').removeClass('selected');
+      }
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
Evidence
The checklist requires documentation updates alongside behavior changes. The added Escape handling
changes how popups are dismissed, but the PR only adds an internal plan document under docs/ and
does not update documentation in doc/ as required.

src/static/js/pad_editbar.ts[352-367]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
User-facing behavior changes (Escape-to-close and focus restoration for popups) were introduced without corresponding documentation updates in `doc/`.

## Issue Context
The compliance checklist requires updating relevant documentation when behavior changes. This PR modifies popup dismissal behavior via keyboard and focus management.

## Fix Focus Areas
- src/static/js/pad_editbar.ts[352-367]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Focus restore overridden 🐞 Bug ☼ Reliability
Description
toggleDropDown() restores focus to the trigger when all popups close, but triggerCommand()
always calls padeditor.ace.focus() after every toolbar command. Closing a popup by clicking its
toolbar button therefore ends with focus in the editor, not on the trigger element.
Code

src/static/js/pad_editbar.ts[R291-296]

+      } else if ($('.popup.popup-show').length === 0 && this._lastTrigger) {
+        // All popups closed — restore focus to the element that opened the first one.
+        const trigger = this._lastTrigger;
+        this._lastTrigger = null;
+        if (document.body.contains(trigger)) trigger.focus();
+      }
Evidence
The focus restore runs synchronously inside toggleDropDown() when no popups remain, but
triggerCommand() runs immediately after the command callback returns and unconditionally focuses
the Ace editor, which will steal focus back from the trigger in click-to-close paths.

src/static/js/pad_editbar.ts[291-296]
src/static/js/pad_editbar.ts[203-208]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`toggleDropDown()` now restores focus to `this._lastTrigger` when popups close, but `triggerCommand()` unconditionally focuses the Ace editor after executing any command. When the close action is triggered via toolbar click (a common path), the editor focus overrides the intended focus restoration.
### Issue Context
Escape-to-close paths don’t run through `triggerCommand()`, so tests can still pass, but click-to-close (or command-to-close) will not leave focus on the trigger.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[203-208]
- src/static/js/pad_editbar.ts[291-296]
### Suggested fix
Make focus restoration occur *after* `triggerCommand()`’s editor focus, or prevent the editor focus for dropdown open/close commands. Options:
1. Defer the `trigger.focus()` restoration using `requestAnimationFrame()`/`setTimeout(0)` so it runs after `triggerCommand()` returns.
2. In `triggerCommand()`, skip `padeditor.ace.focus()` for dropdown commands (or when a popup was just closed and `_lastTrigger` was used).
Ensure the final focused element after a click-to-close is the trigger, consistent with the PR’s intended focus management behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. English aria-label overrides i18n 🐞 Bug ≡ Correctness
Description
Several controls now have fixed English aria-label values while their existing localized text nodes
are marked aria-hidden (e.g., export links), causing assistive tech to announce English-only names
even when the UI is localized. This makes the accessible name language inconsistent with the
localized visible labels and with the negotiated <html lang>.
Code

src/templates/pad.html[R283-300]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
            </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
+                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml" aria-hidden="true"></span>
            </a>
-              <a id="exportplaina" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file" id="exportplain" data-l10n-id="pad.importExport.exportplain"></span>
+              <a id="exportplaina" target="_blank" class="exportlink" aria-label="Export as plain text">
+                <span class="exporttype buttonicon buttonicon-file" id="exportplain" data-l10n-id="pad.importExport.exportplain" aria-hidden="true"></span>
            </a>
-              <a id="exportworda" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-word" id="exportword" data-l10n-id="pad.importExport.exportword"></span>
+              <a id="exportworda" target="_blank" class="exportlink" aria-label="Export as Microsoft Word">
+                <span class="exporttype buttonicon buttonicon-file-word" id="exportword" data-l10n-id="pad.importExport.exportword" aria-hidden="true"></span>
            </a>
-              <a id="exportpdfa" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-pdf" id="exportpdf" data-l10n-id="pad.importExport.exportpdf"></span>
+              <a id="exportpdfa" target="_blank" class="exportlink" aria-label="Export as PDF">
+                <span class="exporttype buttonicon buttonicon-file-pdf" id="exportpdf" data-l10n-id="pad.importExport.exportpdf" aria-hidden="true"></span>
            </a>
-              <a id="exportopena" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-alt" id="exportopen" data-l10n-id="pad.importExport.exportopen"></span>
+              <a id="exportopena" target="_blank" class="exportlink" aria-label="Export as ODF (Open Document Format)">
+                <span class="exporttype buttonicon buttonicon-file-alt" id="exportopen" data-l10n-id="pad.importExport.exportopen" aria-hidden="true"></span>
            </a>
Evidence
The export link text is provided via data-l10n-id keys (localized), but the span is now aria-hidden
and the parent <a> has a hard-coded English aria-label. Similarly, chat has localized text via
data-l10n-id, but the button’s explicit aria-label overrides it; html10n was also changed to avoid
overwriting explicit aria-labels, so these labels will stay English even when the title/text
translations change.

src/templates/pad.html[283-300]
src/templates/pad.html[448-460]
src/locales/en.json[107-116]
src/locales/en.json[163-172]
src/static/js/vendors/html10n.ts[641-673]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Some newly-added `aria-label` attributes are hard-coded in English and override (or replace) existing localized text that was previously available to assistive technology.
### Issue Context
- Export link labels come from localized strings (`data-l10n-id="pad.importExport.*"`), but those spans are now `aria-hidden="true"` and the `<a>` has an English `aria-label`.
- Chat button has localized child text but an English `aria-label` that overrides it.
- `html10n` was updated to not overwrite existing `aria-label`s, so these will not become localized later.
### Fix Focus Areas
- src/templates/pad.html[283-300]
- src/templates/pad.html[448-460]
- src/static/js/vendors/html10n.ts[641-673]
### Suggested fix options (pick one)
1) **Prefer existing localized visible text**: remove the hard-coded `aria-label` and remove `aria-hidden="true"` from the localized text span(s) so the accessible name is localized.
2) **Make aria-label localizable via html10n**: introduce translated keys that target a supported attribute (e.g., `.title`) on the *same element that needs the name*, remove the explicit `aria-label`, and rely on html10n’s `aria-label` population when absent.
3) **Extend html10n attribute support**: add `aria-label` to the allowed attribute list and use `data-l10n-id="*.aria-label"` so templates can provide localized aria-label strings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
12. Buttons missing visual reset 🐞 Bug ⚙ Maintainability
Description
The show-more toolbar control and chat icon are now <button> elements but their CSS does not clear
default button borders/appearance, which can cause unintended borders/backgrounds and spacing
differences across browsers. This risks visible UI regressions compared to the previous non-button
elements.
Code

src/templates/pad.html[R74-78]

      <%- 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>
Evidence
The template converts the show-more control and chat icon to <button>. Existing CSS for these
selectors does not set border: 0, background: transparent (for show-more), or `appearance:
none`, so default UA button styling can leak through.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/templates/pad.html[383-387]
src/static/css/pad/chat.css[132-141]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New `<button>` elements may render with UA default borders/backgrounds/appearance that weren’t present on the previous `<span>/<div>`.
### Issue Context
- `.show-more-icon-btn` is now a button in the toolbar.
- `#chaticon` is now a button.
Current CSS doesn’t fully reset native button styling for these.
### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/templates/pad.html[383-387]
- src/static/css/pad/chat.css[132-141]
### Implementation notes
Add CSS such as:
- `.show-more-icon-btn { border: 0; background: transparent; padding: 0; appearance: none; }`
- `#chaticon { border: 0; appearance: none; }`
Ensure hover/focus styles remain visible and consistent with the rest of the UI.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit c2062fe

Results up to commit f8548b2


🐞 Bugs (9) 📘 Rule violations (1) 📎 Requirement gaps (0)


Action required
1. Escape won't close colorpicker 🐞 Bug ≡ Correctness ⭐ New
Description
padeditbar._bodyKeyEvent() intercepts Escape whenever any .popup has .popup-show, but it only
closes dropdown popups via toggleDropDown('none') (and special-cases #users). Popups that are
opened outside toggleDropDown (such as #mycolorpicker) keep .popup-show, so Escape becomes a
no-op while still calling preventDefault() and returning early.
Code

src/static/js/pad_editbar.ts[R347-363]

+    // 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) {
+      // `toggleDropDown('none')` intentionally skips the users popup so
+      // switching between other popups doesn't hide the user list. For
+      // Escape we want the users popup to close too (unless pinned).
+      const openPopup = $('.popup.popup-show').first();
+      if (openPopup.attr('id') === 'users' && !openPopup.hasClass('stickyUsers')) {
+        openPopup.removeClass('popup-show');
+        $('li[data-key=users] > a').removeClass('selected');
+      }
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
The new Escape handler closes #users explicitly and then calls toggleDropDown('none'), which
only iterates this.dropdowns and therefore cannot close #mycolorpicker. Separately, the color
picker is shown by directly adding the popup-show class, so it satisfies the Escape handler’s
$('.popup.popup-show').length > 0 condition but is never removed.

src/static/js/pad_editbar.ts[346-363]
src/static/js/pad_editbar.ts[235-266]
src/static/js/pad_userlist.ts[587-616]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Escape handling in `pad_editbar._bodyKeyEvent()` triggers for any `.popup.popup-show`, but the close logic only affects dropdown popups (and partially `#users`). Popups opened by other code paths (e.g. `#mycolorpicker` from `pad_userlist.ts`) remain open, so Escape is swallowed (preventDefault + early return) without dismissing the visible popup.

### Issue Context
`#mycolorpicker` is opened by directly adding `.popup-show` and is not part of `padeditbar.dropdowns`, so `toggleDropDown('none')` cannot close it.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[346-363]
- src/static/js/pad_editbar.ts[235-266]
- src/static/js/pad_userlist.ts[587-616]

### Suggested fix
Update the Escape branch to remove `.popup-show` from *all* open popups that should be dismissible via Escape (at least including `#mycolorpicker`, and excluding pinned/sticky cases such as `#users.stickyUsers`). For example:
- Identify all `$('.popup.popup-show')` elements.
- Filter out popups that should remain open (e.g. `#users.stickyUsers`).
- Remove `.popup-show` from the remaining open popups.
- Clear any corresponding toolbar `selected` states as needed.
- Then call `toggleDropDown('none')` (or equivalent) purely for shared cleanup + focus restore, ensuring it does not re-swallow Escape when no popups remain.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Stale focus restoration 🐞 Bug ≡ Correctness
Description
padeditbar.toggleDropDown('none') now restores focus to this._lastTrigger whenever no popups are
open, even if no popup was previously open. Because _lastTrigger is set on every toolbar button
click, background calls to toggleDropDown('none') (e.g., connection-state handling) can unexpectedly
move focus from the editor to a stale toolbar button.
Code

src/static/js/pad_editbar.ts[R268-287]

+      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.
+        const target = openedModule;
+        requestAnimationFrame(() => {
+          if (target[0].contains(document.activeElement)) return;
+          const focusable = target.find(
+              'button:visible, a[href]:visible, input:not([disabled]):visible, ' +
+              'select:not([disabled]):visible, textarea:not([disabled]):visible, ' +
+              '[tabindex]:not([tabindex="-1"]):visible').first();
+          if (focusable.length) focusable[0].focus();
+        });
+      } else if ($('.popup.popup-show').length === 0 && this._lastTrigger) {
+        // All popups closed — restore focus to the element that opened the first one.
+        const trigger = this._lastTrigger;
+        this._lastTrigger = null;
+        if (document.body.contains(trigger)) trigger.focus();
+      }
Evidence
_lastTrigger is set for every toolbar button click, not just dropdown triggers, and it is only
cleared when toggleDropDown() decides to restore focus. Separately, multiple non-UI flows call
toggleDropDown('none') even when no popup is open (connectivity modal setup, and periodic
connection-state handling), which can trigger the unconditional focus-restore block and yank focus
away from the editor.

src/static/js/pad_editbar.ts[66-81]
src/static/js/pad_editbar.ts[210-287]
src/static/js/pad_modals.ts[35-44]
src/static/js/pad.ts[883-888]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`toggleDropDown('none')` restores focus to `this._lastTrigger` even when no popup was previously open, which can steal focus during programmatic calls (e.g., connectivity state changes).
### Issue Context
- `_lastTrigger` is set on every toolbar button click.
- Multiple code paths call `toggleDropDown('none')` as a cleanup step regardless of whether a popup is open.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-81]
- src/static/js/pad_editbar.ts[210-287]
### Suggested fix
- Gate the focus-restore block so it only runs when a popup was actually open at the start of the function (e.g., `wasAnyOpen === true`) and is now closed.
- Consider clearing `_lastTrigger` when handling non-dropdown toolbar commands, or only setting `_lastTrigger` for dropdown-opening commands, to avoid stale values lingering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. 4-space indent in a11y spec 📘 Rule violation ⚙ Maintainability
Description
The new Playwright spec introduces 4-space indentation in wrapped function arguments, violating the
repository's 2-space indentation requirement. This can reduce formatting consistency and trigger
lint/format tooling conflicts.
Code

src/tests/frontend-new/specs/a11y_dialogs.spec.ts[R14-16]

+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"]');
Evidence
Compliance requires no tabs and consistent 2-space indentation. The added test uses 4 spaces before
async ({page}) => { as a continuation indent, which is an indentation width other than two spaces.

src/tests/frontend-new/specs/a11y_dialogs.spec.ts[14-16]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new Playwright spec contains indentation using 4 spaces (not a 2-space multiple per indentation level as required by the repo style rule).
## Issue Context
The compliance checklist requires 2-space indentation and forbids inconsistent indentation widths in new/modified code.
## Fix Focus Areas
- src/tests/frontend-new/specs/a11y_dialogs.spec.ts[14-16]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (4)
4. Focus restore captures wrong trigger 🐞 Bug ≡ Correctness
Description
toggleDropDown() tries to remember the trigger via document.activeElement, but toolbar clicks
blur the focused element before executing the command, so _lastTrigger is frequently unset or not
the actual opener and focus is not reliably restored on close/Escape.
Code

src/static/js/pad_editbar.ts[R213-218]

+      // Remember the trigger so we can restore focus when the dialog closes.
+      const wasAnyOpen = $('.popup.popup-show').length > 0;
+      if (!wasAnyOpen && moduleName !== 'none') {
+        const active = document.activeElement;
+        if (active && active !== document.body) this._lastTrigger = active;
+      }
Evidence
Toolbar item clicks explicitly blur the focused element before invoking the dropdown command, but
toggleDropDown() records the trigger *after* that blur using document.activeElement and skips
saving if it is document.body, so _lastTrigger is often null. This undermines the PR’s promised
“restore focus to the trigger” behavior.

src/static/js/pad_editbar.ts[66-72]
src/static/js/pad_editbar.ts[202-218]
src/static/js/pad_editbar.ts[255-270]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`toggleDropDown()` stores the trigger based on `document.activeElement`, but toolbar click handling blurs the currently focused element before calling the command, so `document.activeElement` is often `body` (or otherwise not the intended trigger). This makes focus restoration unreliable.
### Issue Context
The toolbar click handler intentionally blurs `:focus` before executing the dropdown command. `toggleDropDown()` should therefore not depend on `document.activeElement` at that moment.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-72]
- src/static/js/pad_editbar.ts[202-218]
### Suggested fix
- Capture the actual trigger element from the originating UI event (e.g., the clicked button) and store it before blurring, then pass it through to `toggleDropDown()` (or store it on the padeditbar instance before invoking the command).
- Alternatively, derive the trigger deterministically from `moduleName` (e.g., locate `li[data-key=<moduleName>] button`) instead of using `document.activeElement`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Users Escape close broken 🐞 Bug ≡ Correctness
Description
Pressing Escape inside the Users popup calls toggleDropDown('none'), but toggleDropDown('none')
explicitly skips the users module, so the Users dialog won’t close. Because the handler
preventsDefault() and returns, the existing Escape behavior won’t run either, leaving keyboard users
stuck in the Users popup.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
The new Escape branch always calls toggleDropDown('none') when focus is inside a visible popup;
toggleDropDown('none') then skips closing the 'users' popup, so Escape can’t dismiss it.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[232-236]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but `toggleDropDown('none')` intentionally skips the `users` dropdown, so Escape does not close the Users popup.
### Issue Context
The new Escape handler is meant to close any open popup and restore focus. The Users popup is treated specially in `toggleDropDown('none')` and is sk...

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Accessibility: dialog semantics, focus management, icon labels, html lang

🐞 Bug fix ✨ Enhancement 🧪 Tests

Grey Divider

Walkthroughs

Description
• Add lang and dir attributes to HTML templates negotiated from Accept-Language header
• Implement dialog semantics (role, aria-modal, aria-labelledby/aria-label) on all popups
• Add focus management: remember trigger element, focus first focusable in popup, restore focus on
  close
• Enable Escape key to close open popups and restore focus to trigger button
• Convert icon-only controls to semantic buttons with accessible names (chat icon, close/pin
  buttons, show-more)
• Add aria-labels to export links and fix aria-role typo on userlist region
• Add comprehensive Playwright tests for dialog semantics, focus restoration, and accessible names
Diagram
flowchart LR
  A["HTML Templates<br/>pad.html, index.html,<br/>timeslider.html"] -->|"Add lang/dir<br/>from Accept-Language"| B["Server-rendered<br/>lang attribute"]
  C["Popup Elements<br/>settings, import_export,<br/>embed, users, etc"] -->|"Add role=dialog<br/>aria-modal=true<br/>aria-labelledby/label"| D["Dialog Semantics"]
  E["toggleDropDown<br/>in pad_editbar.ts"] -->|"Remember trigger,<br/>focus first focusable,<br/>restore on close"| F["Focus Management"]
  G["Escape Key Handler<br/>in _bodyKeyEvent"] -->|"Close popup &<br/>restore focus"| F
  H["Icon-only Controls<br/>chaticon, titlecross,<br/>titlesticky, show-more"] -->|"Convert to buttons<br/>Add aria-labels"| I["Semantic Buttons"]
  J["Export Links &<br/>Userlist Region"] -->|"Add aria-labels<br/>Fix aria-role typo"| K["Accessible Names"]
  L["Playwright Tests"] -->|"Verify dialog ARIA,<br/>Escape behavior,<br/>focus restoration"| M["Test Coverage"]
Loading

Grey Divider

File Changes

1. src/templates/pad.html ✨ Enhancement +36/-33

Add dialog ARIA semantics and accessible names to all popups

• Add lang and dir attributes to <html> tag negotiated from Accept-Language header
• Add dialog semantics (role="dialog", aria-modal="true", aria-labelledby/aria-label) to all
 popup divs (#settings, #import_export, #connectivity, #embed, #users, #mycolorpicker,
 #skin-variants)
• Add unique IDs to popup heading elements for aria-labelledby references
• Convert #chaticon from div to button with aria-label="Open chat"
• Convert #titlecross and #titlesticky from anchors to buttons with descriptive aria-labels
• Convert .show-more-icon-btn from span to button with aria-label and aria-expanded attributes
• Add aria-label to all six export links with descriptive text; add aria-hidden="true" to icon
 spans
• Fix aria-role="document" typo on #otherusers to role="region" with aria-live="polite" and
 aria-label
• Update theme switcher knob aria-label from class-style identifier to human-readable text
• Add aria-label="Unread messages" to chat counter and aria-hidden="true" to chat bubble icon

src/templates/pad.html


2. src/static/js/pad_editbar.ts ✨ Enhancement +37/-1

Implement focus management and Escape-to-close for popups

• Add _lastTrigger field to remember the element that opened a popup for focus restoration
• Enhance toggleDropDown() to capture active element before opening popup and restore focus when
 all popups close
• Add focus movement logic to focus first focusable element inside newly opened popup using
 requestAnimationFrame
• Add Escape key handler in _bodyKeyEvent() to close open popups and trigger focus restoration
• Update .show-more-icon-btn click handler to toggle and sync aria-expanded attribute with
 visibility state

src/static/js/pad_editbar.ts


3. src/static/js/chat.ts ✨ Enhancement +13/-0

Wire up chat button click handlers in JavaScript

• Add click event handlers for #chaticon, #titlecross, and #titlesticky to replace inline
 onclick attributes
• Handlers call chat.show(), chat.hide(), and chat.stickToScreen(true) respectively with
 preventDefault

src/static/js/chat.ts


View more (5)
4. src/static/css/pad/chat.css ✨ Enhancement +16/-0

Style chat buttons and add focus-visible outlines

• Add CSS reset for button styling on #titlebar .stick-to-screen-btn and `#titlebar
 .hide-reduce-btn` (background, border, padding, font, line-height)
• Add :focus-visible outline styling for chat header buttons
• Add CSS reset for #chaticon button (background, border, padding, font, color)
• Add :focus-visible outline styling for chat icon button

src/static/css/pad/chat.css


5. src/templates/index.html ✨ Enhancement +7/-1

Add lang/dir negotiation to index.html template

• Add server-side language negotiation logic at template top using req.acceptsLanguages() against
 available languages
• Add lang and dir attributes to <html> tag based on negotiated language, defaulting to
 en/ltr

src/templates/index.html


6. src/templates/timeslider.html ✨ Enhancement +4/-1

Add lang/dir negotiation to timeslider.html template

• Add server-side language negotiation logic using req.acceptsLanguages() against available
 languages
• Add lang and dir attributes to <html> tag based on negotiated language, defaulting to
 en/ltr

src/templates/timeslider.html


7. src/tests/frontend-new/specs/a11y_dialogs.spec.ts 🧪 Tests +110/-0

Add comprehensive accessibility tests for dialogs and labels

• Create new Playwright test suite covering accessibility improvements
• Test that <html> element has non-empty lang attribute
• Test settings popup has dialog semantics, Escape closes it, and focus returns to trigger button
• Test import_export, embed, and users popups have correct dialog ARIA attributes
• Test all six export links have descriptive aria-label attributes
• Test chaticon is a button with aria-label="Open chat"
• Test chat header close/pin controls are buttons with appropriate labels
• Test otherusers region has role="region", aria-live="polite", and no deprecated aria-role
 attribute
• Test show-more toolbar button has aria-label and aria-expanded attributes

src/tests/frontend-new/specs/a11y_dialogs.spec.ts


8. docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md 📝 Documentation +404/-0

Add detailed implementation plan documentation

• Create detailed implementation plan document with 7 major tasks
• Document architecture decisions and out-of-scope items
• Provide step-by-step instructions for each task with code examples
• Include test plan and self-review notes
• Specify commit messages for each task

docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 23, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Users Escape close broken 🐞 Bug ≡ Correctness
Description
Pressing Escape inside the Users popup calls toggleDropDown('none'), but toggleDropDown('none')
explicitly skips the users module, so the Users dialog won’t close. Because the handler
preventsDefault() and returns, the existing Escape behavior won’t run either, leaving keyboard users
stuck in the Users popup.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
The new Escape branch always calls toggleDropDown('none') when focus is inside a visible popup;
toggleDropDown('none') then skips closing the 'users' popup, so Escape can’t dismiss it.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[232-236]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Escape-to-close calls `toggleDropDown('none')`, but `toggleDropDown('none')` intentionally skips the `users` dropdown, so Escape does not close the Users popup.

### Issue Context
The new Escape handler is meant to close any open popup and restore focus. The Users popup is treated specially in `toggleDropDown('none')` and is skipped.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[329-336]
- src/static/js/pad_editbar.ts[232-246]

### Implementation notes
One of:
1) Remove the `users` skip in the `moduleName === 'none'` branch, but preserve the `stickyUsers` behavior (do not close if sticky), or
2) In the Escape handler, detect if `#users` is open and not sticky, and explicitly close it (remove `popup-show`, remove `.selected` from the toolbar button) instead of calling `toggleDropDown('none')`.
Ensure focus restoration still works after close.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Embed focus overridden 🐞 Bug ≡ Correctness
Description
toggleDropDown() now focuses the first focusable element in the opened popup on the next animation
frame, which overrides existing command-specific focus logic. In the Embed popup this steals focus
from #linkinput (which the embed command intentionally focuses/selects) and moves it to the readonly
checkbox instead.
Code

src/static/js/pad_editbar.ts[R264-273]

+      if (openedModule) {
+        // Move focus into the now-visible popup so keyboard users land inside the dialog.
+        const target = openedModule;
+        requestAnimationFrame(() => {
+          const focusable = target.find(
+              'button:visible, a[href]:visible, input:not([disabled]):visible, ' +
+              'select:not([disabled]):visible, textarea:not([disabled]):visible, ' +
+              '[tabindex]:not([tabindex="-1"]):visible').first();
+          if (focusable.length) focusable[0].focus();
+        });
Evidence
The embed command focuses and selects #linkinput right after opening the popup, but toggleDropDown
schedules an rAF focus to the first focusable element. In the Embed markup, the first focusable is
#readonlyinput (a checkbox) before #linkinput, so the rAF callback will pull focus away from
#linkinput.

src/static/js/pad_editbar.ts[264-273]
src/static/js/pad_editbar.ts[429-433]
src/templates/pad.html[328-338]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`toggleDropDown()` auto-focuses the first focusable control in the opened popup via `requestAnimationFrame()`. This can override existing command handlers that deliberately focus a particular element (notably the Embed command focuses/selects `#linkinput`).

### Issue Context
In `pad.html`, the Embed dialog has `#readonlyinput` before `#linkinput`, so the auto-focus targets `#readonlyinput`, stealing focus from `#linkinput`.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[264-273]
- src/static/js/pad_editbar.ts[429-433]
- src/templates/pad.html[328-338]

### Implementation notes
Modify the rAF auto-focus behavior to be conditional, for example:
- In the rAF callback, only focus the first element if focus is still on the trigger/body (or not already inside the opened popup), OR
- Allow callers to opt out (e.g., `toggleDropDown(moduleName, {autoFocus: false})`) and use that for `embed`, OR
- Prefer a more specific focus target for certain dialogs (Embed -> `#linkinput`).
Preserve the new general behavior for dialogs that don’t explicitly manage focus.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. L10n overwrites aria-labels 🐞 Bug ☼ Reliability
Description
html10n unconditionally sets aria-label to the translated string for any node it translates, so
elements with data-l10n-id ending in .title will have their aria-label overwritten. This makes the
new Playwright assertions for chat button/pin label incorrect and potentially makes the in-HTML
aria-label values ineffective at runtime.
Code

src/tests/frontend-new/specs/a11y_dialogs.spec.ts[R72-89]

+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');
+  await expect(chatIcon).toHaveAttribute('aria-label', 'Open chat');
+});
+
+test('chat header close/pin controls are buttons with labels', async ({page}) => {
+  await page.locator('#chaticon').click();
+  for (const [id, label, tag] of [
+    ['#titlecross', 'Close chat', 'button'],
+    ['#titlesticky', 'Pin chat to screen', 'button'],
+  ] as const) {
+    const el = page.locator(id);
+    const tagName = await el.evaluate((node) => node.tagName.toLowerCase());
+    expect(tagName).toBe(tag);
+    await expect(el).toHaveAttribute('aria-label', label);
+  }
Evidence
html10n sets aria-label to the translation string whenever it applies a translation (including
when translating the title attribute). #chaticon and #titlesticky both have data-l10n-id keys
that end with .title, so html10n will overwrite their aria-labels with the localized strings from
locales (en: "Open the chat for this pad.", "Stick chat to screen"), which will not match the test’s
expected "Open chat" / "Pin chat to screen".

src/static/js/vendors/html10n.ts[641-667]
src/locales/en.json[155-163]
src/templates/pad.html[383-395]
src/tests/frontend-new/specs/a11y_dialogs.spec.ts[72-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The new tests assert hard-coded English `aria-label` values for elements that html10n will later overwrite because they have `data-l10n-id` values ending in `.title`.

### Issue Context
In this repo’s html10n implementation, translating an element sets both the translated property (e.g., `title`) and also sets `aria-label` to the same translated string.

### Fix Focus Areas
- src/static/js/vendors/html10n.ts[661-667]
- src/templates/pad.html[383-395]
- src/tests/frontend-new/specs/a11y_dialogs.spec.ts[72-90]

### Implementation notes
Pick one consistent approach:
1) **Rely on html10n for aria-label**: remove the hard-coded `aria-label` attributes from elements that have `data-l10n-id="*.title"`, and update the Playwright tests to assert that `aria-label` is non-empty (or equals the element’s localized `title`) rather than matching a fixed string.

OR
2) **Preserve explicit aria-label**: change html10n so it does **not** overwrite an existing `aria-label` attribute (only set it if missing). Then the tests can keep asserting the explicit labels.

Either way, ensure tests are stable across locales (avoid asserting fixed English strings if localization can run).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Buttons missing visual reset 🐞 Bug ⚙ Maintainability
Description
The show-more toolbar control and chat icon are now <button> elements but their CSS does not clear
default button borders/appearance, which can cause unintended borders/backgrounds and spacing
differences across browsers. This risks visible UI regressions compared to the previous non-button
elements.
Code

src/templates/pad.html[R74-78]

          <%- 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>
Evidence
The template converts the show-more control and chat icon to <button>. Existing CSS for these
selectors does not set border: 0, background: transparent (for show-more), or `appearance:
none`, so default UA button styling can leak through.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/templates/pad.html[383-387]
src/static/css/pad/chat.css[132-141]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
New `<button>` elements may render with UA default borders/backgrounds/appearance that weren’t present on the previous `<span>/<div>`.

### Issue Context
- `.show-more-icon-btn` is now a button in the toolbar.
- `#chaticon` is now a button.
Current CSS doesn’t fully reset native button styling for these.

### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/templates/pad.html[383-387]
- src/static/css/pad/chat.css[132-141]

### Implementation notes
Add CSS such as:
- `.show-more-icon-btn { border: 0; background: transparent; padding: 0; appearance: none; }`
- `#chaticon { border: 0; appearance: none; }`
Ensure hover/focus styles remain visible and consistent with the rest of the UI.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/static/js/pad_editbar.ts Outdated
Comment on lines +330 to +336
// Escape from inside any open popup: close the popup and let
// toggleDropDown('none') restore focus to the trigger.
if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
this.toggleDropDown('none');
evt.preventDefault();
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Users escape close broken 🐞 Bug ≡ Correctness

Pressing Escape inside the Users popup calls toggleDropDown('none'), but toggleDropDown('none')
explicitly skips the users module, so the Users dialog won’t close. Because the handler
preventsDefault() and returns, the existing Escape behavior won’t run either, leaving keyboard users
stuck in the Users popup.
Agent Prompt
### Issue description
Escape-to-close calls `toggleDropDown('none')`, but `toggleDropDown('none')` intentionally skips the `users` dropdown, so Escape does not close the Users popup.

### Issue Context
The new Escape handler is meant to close any open popup and restore focus. The Users popup is treated specially in `toggleDropDown('none')` and is skipped.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[329-336]
- src/static/js/pad_editbar.ts[232-246]

### Implementation notes
One of:
1) Remove the `users` skip in the `moduleName === 'none'` branch, but preserve the `stickyUsers` behavior (do not close if sticky), or
2) In the Escape handler, detect if `#users` is open and not sticky, and explicitly close it (remove `popup-show`, remove `.selected` from the toolbar button) instead of calling `toggleDropDown('none')`.
Ensure focus restoration still works after close.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +264 to +273
if (openedModule) {
// Move focus into the now-visible popup so keyboard users land inside the dialog.
const target = openedModule;
requestAnimationFrame(() => {
const focusable = target.find(
'button:visible, a[href]:visible, input:not([disabled]):visible, ' +
'select:not([disabled]):visible, textarea:not([disabled]):visible, ' +
'[tabindex]:not([tabindex="-1"]):visible').first();
if (focusable.length) focusable[0].focus();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Embed focus overridden 🐞 Bug ≡ Correctness

toggleDropDown() now focuses the first focusable element in the opened popup on the next animation
frame, which overrides existing command-specific focus logic. In the Embed popup this steals focus
from #linkinput (which the embed command intentionally focuses/selects) and moves it to the readonly
checkbox instead.
Agent Prompt
### Issue description
`toggleDropDown()` auto-focuses the first focusable control in the opened popup via `requestAnimationFrame()`. This can override existing command handlers that deliberately focus a particular element (notably the Embed command focuses/selects `#linkinput`).

### Issue Context
In `pad.html`, the Embed dialog has `#readonlyinput` before `#linkinput`, so the auto-focus targets `#readonlyinput`, stealing focus from `#linkinput`.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[264-273]
- src/static/js/pad_editbar.ts[429-433]
- src/templates/pad.html[328-338]

### Implementation notes
Modify the rAF auto-focus behavior to be conditional, for example:
- In the rAF callback, only focus the first element if focus is still on the trigger/body (or not already inside the opened popup), OR
- Allow callers to opt out (e.g., `toggleDropDown(moduleName, {autoFocus: false})`) and use that for `embed`, OR
- Prefer a more specific focus target for certain dialogs (Embed -> `#linkinput`).
Preserve the new general behavior for dialogs that don’t explicitly manage focus.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +72 to +89
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');
await expect(chatIcon).toHaveAttribute('aria-label', 'Open chat');
});

test('chat header close/pin controls are buttons with labels', async ({page}) => {
await page.locator('#chaticon').click();
for (const [id, label, tag] of [
['#titlecross', 'Close chat', 'button'],
['#titlesticky', 'Pin chat to screen', 'button'],
] as const) {
const el = page.locator(id);
const tagName = await el.evaluate((node) => node.tagName.toLowerCase());
expect(tagName).toBe(tag);
await expect(el).toHaveAttribute('aria-label', label);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. L10n overwrites aria-labels 🐞 Bug ☼ Reliability

html10n unconditionally sets aria-label to the translated string for any node it translates, so
elements with data-l10n-id ending in .title will have their aria-label overwritten. This makes the
new Playwright assertions for chat button/pin label incorrect and potentially makes the in-HTML
aria-label values ineffective at runtime.
Agent Prompt
### Issue description
The new tests assert hard-coded English `aria-label` values for elements that html10n will later overwrite because they have `data-l10n-id` values ending in `.title`.

### Issue Context
In this repo’s html10n implementation, translating an element sets both the translated property (e.g., `title`) and also sets `aria-label` to the same translated string.

### Fix Focus Areas
- src/static/js/vendors/html10n.ts[661-667]
- src/templates/pad.html[383-395]
- src/tests/frontend-new/specs/a11y_dialogs.spec.ts[72-90]

### Implementation notes
Pick one consistent approach:
1) **Rely on html10n for aria-label**: remove the hard-coded `aria-label` attributes from elements that have `data-l10n-id="*.title"`, and update the Playwright tests to assert that `aria-label` is non-empty (or equals the element’s localized `title`) rather than matching a fixed string.

OR
2) **Preserve explicit aria-label**: change html10n so it does **not** overwrite an existing `aria-label` attribute (only set it if missing). Then the tests can keep asserting the explicit labels.

Either way, ensure tests are stable across locales (avoid asserting fixed English strings if localization can run).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

JohnMcLear and others added 6 commits April 23, 2026 10:39
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>
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>
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>
- #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>
- 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>
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>
@JohnMcLear JohnMcLear force-pushed the fix/a11y-dialogs-labels-lang branch from 818dceb to 68a0761 Compare April 23, 2026 10:00
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 23, 2026

Persistent review updated to latest commit 68a0761

Comment on lines +14 to +16
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"]');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. 4-space indent in a11y spec 📘 Rule violation ⚙ Maintainability

The new Playwright spec introduces 4-space indentation in wrapped function arguments, violating the
repository's 2-space indentation requirement. This can reduce formatting consistency and trigger
lint/format tooling conflicts.
Agent Prompt
## Issue description
The new Playwright spec contains indentation using 4 spaces (not a 2-space multiple per indentation level as required by the repo style rule).

## Issue Context
The compliance checklist requires 2-space indentation and forbids inconsistent indentation widths in new/modified code.

## Fix Focus Areas
- src/tests/frontend-new/specs/a11y_dialogs.spec.ts[14-16]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +213 to +218
// Remember the trigger so we can restore focus when the dialog closes.
const wasAnyOpen = $('.popup.popup-show').length > 0;
if (!wasAnyOpen && moduleName !== 'none') {
const active = document.activeElement;
if (active && active !== document.body) this._lastTrigger = active;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Focus restore captures wrong trigger 🐞 Bug ≡ Correctness

toggleDropDown() tries to remember the trigger via document.activeElement, but toolbar clicks
blur the focused element before executing the command, so _lastTrigger is frequently unset or not
the actual opener and focus is not reliably restored on close/Escape.
Agent Prompt
### Issue description
`toggleDropDown()` stores the trigger based on `document.activeElement`, but toolbar click handling blurs the currently focused element before calling the command, so `document.activeElement` is often `body` (or otherwise not the intended trigger). This makes focus restoration unreliable.

### Issue Context
The toolbar click handler intentionally blurs `:focus` before executing the dropdown command. `toggleDropDown()` should therefore not depend on `document.activeElement` at that moment.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-72]
- src/static/js/pad_editbar.ts[202-218]

### Suggested fix
- Capture the actual trigger element from the originating UI event (e.g., the clicked button) and store it before blurring, then pass it through to `toggleDropDown()` (or store it on the padeditbar instance before invoking the command).
- Alternatively, derive the trigger deterministically from `moduleName` (e.g., locate `li[data-key=<moduleName>] button`) instead of using `document.activeElement`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 23, 2026

Persistent review updated to latest commit c89444c

Comment on lines +268 to +287
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.
const target = openedModule;
requestAnimationFrame(() => {
if (target[0].contains(document.activeElement)) return;
const focusable = target.find(
'button:visible, a[href]:visible, input:not([disabled]):visible, ' +
'select:not([disabled]):visible, textarea:not([disabled]):visible, ' +
'[tabindex]:not([tabindex="-1"]):visible').first();
if (focusable.length) focusable[0].focus();
});
} else if ($('.popup.popup-show').length === 0 && this._lastTrigger) {
// All popups closed — restore focus to the element that opened the first one.
const trigger = this._lastTrigger;
this._lastTrigger = null;
if (document.body.contains(trigger)) trigger.focus();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Stale focus restoration 🐞 Bug ≡ Correctness

padeditbar.toggleDropDown('none') now restores focus to this._lastTrigger whenever no popups are
open, even if no popup was previously open. Because _lastTrigger is set on every toolbar button
click, background calls to toggleDropDown('none') (e.g., connection-state handling) can unexpectedly
move focus from the editor to a stale toolbar button.
Agent Prompt
### Issue description
`toggleDropDown('none')` restores focus to `this._lastTrigger` even when no popup was previously open, which can steal focus during programmatic calls (e.g., connectivity state changes).

### Issue Context
- `_lastTrigger` is set on every toolbar button click.
- Multiple code paths call `toggleDropDown('none')` as a cleanup step regardless of whether a popup is open.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[66-81]
- src/static/js/pad_editbar.ts[210-287]

### Suggested fix
- Gate the focus-restore block so it only runs when a popup was actually open at the start of the function (e.g., `wasAnyOpen === true`) and is now closed.
- Consider clearing `_lastTrigger` when handling non-dropdown toolbar commands, or only setting `_lastTrigger` for dropdown-opening commands, to avoid stale values lingering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…layout

Round 2 of ether#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.
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 23, 2026

Persistent review updated to latest commit f8548b2

Comment on lines +347 to +363
// 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) {
// `toggleDropDown('none')` intentionally skips the users popup so
// switching between other popups doesn't hide the user list. For
// Escape we want the users popup to close too (unless pinned).
const openPopup = $('.popup.popup-show').first();
if (openPopup.attr('id') === 'users' && !openPopup.hasClass('stickyUsers')) {
openPopup.removeClass('popup-show');
$('li[data-key=users] > a').removeClass('selected');
}
this.toggleDropDown('none');
evt.preventDefault();
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Escape won't close colorpicker 🐞 Bug ≡ Correctness

padeditbar._bodyKeyEvent() intercepts Escape whenever any .popup has .popup-show, but it only
closes dropdown popups via toggleDropDown('none') (and special-cases #users). Popups that are
opened outside toggleDropDown (such as #mycolorpicker) keep .popup-show, so Escape becomes a
no-op while still calling preventDefault() and returning early.
Agent Prompt
### Issue description
Escape handling in `pad_editbar._bodyKeyEvent()` triggers for any `.popup.popup-show`, but the close logic only affects dropdown popups (and partially `#users`). Popups opened by other code paths (e.g. `#mycolorpicker` from `pad_userlist.ts`) remain open, so Escape is swallowed (preventDefault + early return) without dismissing the visible popup.

### Issue Context
`#mycolorpicker` is opened by directly adding `.popup-show` and is not part of `padeditbar.dropdowns`, so `toggleDropDown('none')` cannot close it.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[346-363]
- src/static/js/pad_editbar.ts[235-266]
- src/static/js/pad_userlist.ts[587-616]

### Suggested fix
Update the Escape branch to remove `.popup-show` from *all* open popups that should be dismissible via Escape (at least including `#mycolorpicker`, and excluding pinned/sticky cases such as `#users.stickyUsers`). For example:
- Identify all `$('.popup.popup-show')` elements.
- Filter out popups that should remain open (e.g. `#users.stickyUsers`).
- Remove `.popup-show` from the remaining open popups.
- Clear any corresponding toolbar `selected` states as needed.
- Then call `toggleDropDown('none')` (or equivalent) purely for shared cleanup + focus restore, ensuring it does not re-swallow Escape when no popups remain.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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)
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 23, 2026

Persistent review updated to latest commit c2062fe

Comment on lines +665 to +673
// Populate aria-label from the translation so screen readers always get
// a localized accessible name, but do not overwrite an explicit
// aria-label that an author has already set. This lets templates use
// static English aria-labels for icon-only controls (export links,
// chat icon, close/pin buttons) without losing them at localization
// time. See PR #7584 review feedback.
if (!node.hasAttribute('aria-label')) {
node.setAttribute('aria-label', str.str!);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Stale aria-label after relocalize 🐞 Bug ≡ Correctness

html10n.translateNode() now refuses to overwrite an existing aria-label, but html10n itself sets
aria-label during the first localization pass, so later html10n.localize() calls (triggered by
language changes) will not update accessible names. This can leave screen readers announcing labels
in the old language even though the visible text has been translated, because aria-label overrides
text content for the accessible name.
Agent Prompt
## Issue description
`html10n.translateNode()` sets `aria-label` from the translated string. After the first translation, the attribute exists, and with the new guard (`if (!node.hasAttribute('aria-label'))`) subsequent language changes will not update the aria-label. Because `aria-label` overrides an element’s text for accessible-name calculation, screen readers can announce stale (previous-language) labels.

## Issue Context
Etherpad re-localizes at runtime (language changes call `html10n.localize()` via `pad.applyLanguage()`), so `translateNode()` is expected to be idempotent and update derived attributes.

## Fix Focus Areas
- src/static/js/vendors/html10n.ts[661-673]
- src/static/js/pad.ts[570-573]

## Suggested fix
Change the guard to only preserve **author-supplied** aria-labels, while still allowing updates to aria-labels previously generated by html10n. One simple pattern:

- When html10n sets aria-label, also set a marker attribute (e.g. `data-l10n-aria-label="true"`).
- On subsequent translations, overwrite aria-label if the marker is present.

Example:
```ts
const generatedAttr = 'data-l10n-aria-label';
if (!node.hasAttribute('aria-label') || node.getAttribute(generatedAttr) === 'true') {
  node.setAttribute('aria-label', str.str!);
  node.setAttribute(generatedAttr, 'true');
}
```

This preserves explicit template-provided aria-labels, but keeps generated aria-labels in sync with language switches.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant