Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
404 changes: 404 additions & 0 deletions docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions src/static/css/pad/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,21 @@
text-align: right;
text-decoration: none;
cursor: pointer;
background: transparent;
border: 0;
padding: 0;
font-family: inherit;
line-height: 1;
}
#titlebar .stick-to-screen-btn {
font-size: 10px;
padding-top: 2px;
}
#titlebar .stick-to-screen-btn:focus-visible,
#titlebar .hide-reduce-btn:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}

/* -- MESSAGES -- */
#chattext {
Expand Down Expand Up @@ -121,10 +131,32 @@

/* -- CHAT ICON -- */
#chaticon {
/* #chaticon was converted from <span> to <button> for a11y; reset the
UA-default button chrome so the corner icon keeps its pre-conversion
shape. Deliberately do NOT reset `border` here — the 1px grey border
is supplied earlier (#chaticon {border:1px solid #ccc; border-bottom:
none}) and is part of the intended visual. Overriding with border:0
visibly broke the icon. See PR #7584 review feedback. */
appearance: none;
margin: 0;
background-color: #fff;
cursor: pointer;
display: none;
padding: 5px;
font: inherit;
color: inherit;
}
/* Scope: the inner .buttonicon span here is just a glyph holder. Its global
rule in icons.css applies `display: flex` which is fine for toolbar
<button class="buttonicon"> instances but breaks inline layout when
nested inside a button that's laying out label + glyph + counter on one
line. Keep the glyph inline for the chat-icon corner widget. */
#chaticon .buttonicon {
display: inline;
}
#chaticon:focus-visible {
outline: 2px solid #0066cc;
outline-offset: -2px;
}
#chaticon a {
text-decoration: none
Expand Down
9 changes: 9 additions & 0 deletions src/static/css/pad/toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@
}

.toolbar .show-more-icon-btn {
/* Reset user-agent <button> styling introduced when this was converted
from <span> for a11y. Without these the native button border/background
leak through and shift the glyph off-centre. */
appearance: none;
background: transparent;
border: 0;
padding: 0;
color: inherit;
font: inherit;
display:none;
cursor: pointer;
height: 39px;
Expand Down
13 changes: 13 additions & 0 deletions src/static/js/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,19 @@ exports.chat = (() => {

// initial messages are loaded in pad.js' _afterHandshake

$('#chaticon').on('click', (e) => {
e.preventDefault();
this.show();
});
$('#titlecross').on('click', (e) => {
e.preventDefault();
this.hide();
});
$('#titlesticky').on('click', (e) => {
e.preventDefault();
this.stickToScreen(true);
});

$('#chatcounter').text(0);
$('#chatloadmessagesbutton').on('click', () => {
const start = Math.max(this.historyPointer - 20, 0);
Expand Down
81 changes: 79 additions & 2 deletions src/static/js/pad_editbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,22 @@ class ToolbarItem {
bind(callback) {
if (this.isButton()) {
this.$el.on('click', (event) => {
// Stash the clicked button as the focus-restore target BEFORE we
// blur :focus — but only for dropdown-opening buttons. Non-dropdown
// commands (list toggles, bold, etc.) return focus to the ace editor
// and should not touch _lastTrigger (it would retain a stale
// reference and mess with later popup Esc-close focus handling).
const cmd = this.getCommand();
// @ts-ignore — padeditbar is the exported singleton defined below
const isDropdownTrigger = exports.padeditbar.dropdowns.indexOf(cmd) !== -1;
if (isDropdownTrigger) {
const trigger = (this.$el.find('button')[0] as HTMLElement | undefined) ||
(this.$el[0] as HTMLElement);
// @ts-ignore
if (trigger) exports.padeditbar._lastTrigger = trigger;
}
$(':focus').trigger('blur');
callback(this.getCommand(), this);
callback(cmd, this);
event.preventDefault();
});
} else if (this.isSelect()) {
Expand Down Expand Up @@ -128,6 +142,7 @@ exports.padeditbar = new class {
this._editbarPosition = 0;
this.commands = {};
this.dropdowns = [];
this._lastTrigger = null;
}

init() {
Expand All @@ -145,7 +160,8 @@ exports.padeditbar = new class {
});

$('.show-more-icon-btn').on('click', () => {
$('.toolbar').toggleClass('full-icons');
const expanded = $('.toolbar').toggleClass('full-icons').hasClass('full-icons');
$('.show-more-icon-btn').attr('aria-expanded', String(expanded));
});
this.checkAllIconsAreDisplayedInToolbar();
$(window).on('resize', _.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
Expand Down Expand Up @@ -208,6 +224,19 @@ exports.padeditbar = new class {
$('.nice-select').removeClass('open');
$('.toolbar-popup').removeClass('popup-show');

// Remember the trigger so we can restore focus when the dialog closes.
// The Button click handler pre-sets `_lastTrigger` before calling blur(),
// because blur would make document.activeElement === <body>. For other
// paths (keyboard shortcut, programmatic open) fall back to whatever has
// focus right now.
const wasAnyOpen = $('.popup.popup-show').length > 0;
if (!wasAnyOpen && moduleName !== 'none' && !this._lastTrigger) {
const active = document.activeElement;
if (active && active !== document.body) this._lastTrigger = active;
}
Comment on lines +227 to +236
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


let openedModule = null;

// hide all modules and remove highlighting of all buttons
if (moduleName === 'none') {
for (const thisModuleName of this.dropdowns) {
Expand Down Expand Up @@ -236,9 +265,40 @@ exports.padeditbar = new class {
} else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show');
openedModule = module;
}
}
}

if (openedModule) {
// Move focus into the now-visible popup so keyboard users land inside the dialog.
// Skip if a command handler already placed focus inside this popup — the Embed
// command focuses #linkinput deliberately, which is different from the first
// tabbable element (a readonly checkbox) and should win.
// Fallback: if no focusable descendant exists (e.g. #users where the only
// input is disabled), focus the popup div itself so keydown events fire on
// the outer document instead of being trapped in the ace editor iframe.
const target = openedModule;
requestAnimationFrame(() => {
// If a command handler already placed focus inside the popup (e.g.
// the Embed command focuses #linkinput, showusers focuses
// #myusernameedit), honour that.
if (target[0].contains(document.activeElement)) return;
// Otherwise focus the popup container itself. This keeps keydown
// events on the outer document (so Esc always dismisses the popup,
// even when the popup has no directly-focusable descendants like
// #users does), and it works uniformly across browsers without
// getting tripped up by `visibility: hidden` nested popups.
// Keyboard users can Tab from here into the popup's controls.
if (!target.attr('tabindex')) target.attr('tabindex', '-1');
target[0].focus();
});
Comment on lines +273 to +295
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

} 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();
}
Comment on lines +273 to +301
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

} catch (err) {
cbErr = err || new Error(err);
} finally {
Expand Down Expand Up @@ -289,6 +349,23 @@ exports.padeditbar = new class {
}

_bodyKeyEvent(evt) {
// Escape while any popup is open: close it. We don't restrict to
// `:focus inside popup` because some popups (e.g. #users) have no
// focusable content on open — focus stays in the ace editor iframe —
// but Esc should still dismiss them for keyboard users.
if (evt.keyCode === 27 && $('.popup.popup-show').length > 0) {
// `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;
}
Comment on lines +352 to +368
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

// If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
Expand Down
12 changes: 9 additions & 3 deletions src/static/js/vendors/html10n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,9 +662,15 @@ export class Html10n {
if (node.children.length === 0 || prop != 'textContent') {
// @ts-ignore
node[prop] = str.str!
node.setAttribute("aria-label", str.str!); // Sets the aria-label
// The idea of the above is that we always have an aria value
// This might be a bit of an abrupt solution but let's see how it goes
// Populate aria-label from the translation so screen readers 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!);
}
Comment on lines +665 to +673
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

} else {
let children = node.childNodes,
found = false
Expand Down
8 changes: 7 additions & 1 deletion src/templates/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<%
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs;
var renderLang = (req && typeof req.acceptsLanguages === 'function'
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
%>
<!doctype html>
<html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>">

<title><%=settings.title%></title>
<meta charset="utf-8">
Expand Down
Loading
Loading