src/package.json:
- test: was mocha --import=tsx --recursive ...; now `vitest run` (vitest.config
already includes tests/backend/specs, tests/backend-new/specs, and
tests/container/specs)
- test-utils: vitest run with --testTimeout 5000
- test-container: vitest run tests/container/specs/api
- test:vitest renamed to test:watch (watch mode for local dev)
.github/workflows/backend-tests.yml:
- removed the redundant 'Run the new vitest tests' step from all 4 jobs since
pnpm test now runs vitest itself
Convert remaining exports.baseURL references to use the baseURL const defined at module level.
This completes the conversion from CommonJS require/exports to ESM import/export syntax.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert dynamic requires to dynamic imports for circular dependency handling
- Make timeslider.init async to support dynamic imports
- Update exports references to use module scope or window
- Add ESM export default for jquery library
- Keep CommonJS compatibility for jquery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert const X = require('Y') to import X from 'Y.js'
- Add .js extensions to relative imports
- Convert exports.X = Y to export {X}
- Convert dynamic requires to dynamic imports for circular dependency handling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}
- Refactor forward references to allow function reordering
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace default imports with namespace imports for modules that only export
named exports:
- PadManager.ts: export const getPad, listAllPads, etc.
- AuthorManager.ts: export const getAuthor, etc.
- ImportHtml.ts: export const setPadHTML
- ExportHtml.ts: export const getPadHTMLDocument
Changed 'import X from Y' to 'import * as X from Y' in:
- Test files (export_list, chat, messages, etc.)
- Utility files (ExportHtml, ExportTxt, ExportEtherpad, ImportEtherpad, Cleanup)
- API test files (pad, restoreRevision)
This fixes ESM module resolution errors when these modules are imported as
default exports despite only providing named exports.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Change 'import common from' to 'import * as common from' in 20 test files
to use named imports instead of default import.
Co-authored-by: GitHub Copilot <copilot@github.com>
APIHandler / APIKeyHandler / SocketIORouter / ExportHandler / ImportHandler /
RestAPI: imports flipped to .js, exports.X -> export const X.
PadMessageHandler: full conversion incl. internal exports.X() callsites
(sendChatMessageToPadClients, updatePadClients, composePadChangesets) rewritten
to bare names. Default export object added so existing
`import padMessageHandler from '...'` callers keep working without changes.
Conditional require() of LibreOffice in ImportHandler/ExportHandler hoisted
to a top-level namespace import (`import * as converterModule`).
Self-referential exports.X pattern replaced by a module-private `eejs` object
that's also the default export. __dirname shimmed via import.meta.url.
`require` (used by templates as args.require) preserved via createRequire.
All 26 files in node/utils/ now ESM. Settings.ts CJS shim removed (dead code
under "type": module); plugins doing require() get .default-wrapped namespace
via Node interop, the createRequire bridge in pluginfw will preserve sync
loading once that lands. toolbar.ts's module.exports.availableButtons
self-reference replaced by a module-private toolbar const.
ts-check: 526 -> 539 errors. The +13 is from default-imports landing on
modules that still live in db/ as CJS; resolves once db/ is flipped.
* fix(userlist): stop username input from overlapping the Log out button
Fixes#7593. In the pad's Users popup, #myusernameform had no width
set and the <input id="myusernameedit"> inside it took its natural
content width, pushing past the Log out button and making the button
overflow the popup at common widths.
Constrain #myusernameform to 75px and make the input fill its
container with box-sizing: border-box so the text field stays inside
the form and the Log out button sits visibly next to it rather than
getting covered or clipped off-screen.
Low-risk, CSS-only change. No test plan beyond visual verification
because the affected control is in the users popup UI.
* fix(chat): bottom-align titlebar controls; restore chat icon click (#7590)
Two regressions from the #7584 a11y refactor of the chat widget,
both pure-CSS fixes scoped to the chat panel.
1. Title bar — `<a>` → `<button>` for #titlecross/#titlesticky kept the
`float: right` layout, but a `<button>`'s box is only as tall as its
glyph, so the small `−` and `█` controls floated at the *top* of the
44px title bar instead of sitting on the title's baseline as the
anchors did. Switch #titlebar to a flex row with `align-items:
flex-end`, give #titlelabel `flex: 1` to push the controls to the
right edge, and use `order: 1/2` to keep the historical visual order
`[█] [−]` (which `float: right` previously produced from reverse
source order).
2. Chat-icon corner widget — `<div>` → `<button id="chaticon">` exposes
the inner `<span class="buttonicon">` to the global `.buttonicon`
rule's `display: flex; position: relative; align-items/justify-content:
center;`. The existing override only reset `display`, leaving the
span as a positioned flex item that, in some layouts, sat over the
button's hit surface and swallowed clicks. Reset the remaining flex
properties and add `pointer-events: none` so clicks always reach the
`<button>`'s own click handler — preferred over weakening the global
.buttonicon rule, which the toolbar relies on for icon centring.
Visual-only / behaviour-fix, no markup or JS changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): grey disabled chat option labels (#7592)
When "Disable chat" is ticked in the Settings dialog, refreshMyViewControls()
already sets `disabled` on `#options-stickychat` and `#options-chatandusers`,
but the browser only greys the checkbox itself — the adjacent `<label>`
keeps its normal colour, so the row still looks interactive even though
clicks are no-ops.
Add a popup-scoped rule that follows the existing convention used for
disabled `.nice-select` controls (`color: #999; cursor: not-allowed`) so
any disabled checkbox or radio in a settings popup matches its label to
the disabled state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(userlist): drop username input width cap (#7593 review)
The width:75px on #myusernameform and width:100%/box-sizing on
#myusernameedit from a55436ca0 were guarding against an overlap with
a "Log out" button — but no Log out button exists in vanilla
etherpad-lite (the original report came from a setup with a plugin
that adds one). Without that button visible, the cap just makes the
default username field unnecessarily narrow.
Restore #myusernameform to just `margin-left: 10px` and drop the
forced width on the input. If the overlap reappears in a real plugin
setup it should be re-fixed there (or with a more targeted rule that
only kicks in when a logout button is actually present).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): keep titlesticky at top of title bar (#7590 review)
The previous pass bottom-aligned both corner controls via
align-items: flex-end on #titlebar. That correctly placed the close
button (#titlecross) on the title's baseline, but it also dragged the
much smaller "stick to screen" button (#titlesticky) down to the same
baseline — visibly far below where it sat in the original layout.
Switch to per-control align-self so each lands where it should:
- #titlesticky → align-self: flex-start (top, where it always was)
- #titlecross → align-self: flex-end (bottom, on the title's baseline)
- #titlelabel → align-self: center (don't stretch the heading)
Drop align-items from #titlebar so the defaults don't override these.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(chat): restore original #titlebar layout (#7590 review)
Both attempted CSS layouts for the title bar (full flex with
align-items: flex-end, then per-control align-self) ended up looking
worse than the original in review. Drop all the #titlebar / #titlelabel
/ #titlecross / #titlesticky changes from 905294d5b and f37da9a62 and
restore the pre-existing float-based layout. The chat panel ships with
its original visuals; we'll revisit #7590 separately if needed.
Keeps the chat-icon click fix from 905294d5b (#chaticon .buttonicon
flex/pointer-events reset) and the focus-visible additions for the
title-bar buttons.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): clear inline display:none in chat.show()
When the user disables chat in settings, applyShowChat(false) calls
\`$('#chatbox').hide()\` which sets the chatbox's inline display to
\`none\`. Re-enabling chat doesn't undo that — it only re-shows the
icon. Then clicking the icon runs chat.show(), which adds the
\`.visible\` class but only flips visibility, not display, so the
chatbox stays hidden by the lingering inline style and the chat
appears not to open.
Clear the inline display in chat.show() before adding the .visible
class so the box becomes visible regardless of how it got hidden.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(colibris): align username gap; grey unchecked-disabled toggles
users.css: change #myusernameform margin-left from 35px to 10px to
match the base popup_users.css. The 35px value was chosen for the
sticky chatAndUsers layout, but for the standalone Users popup it
opens an unnecessarily wide gap between the colour swatch and the
username field. (#7593 review)
form.css: drop the \`:checked\` qualifier from the disabled toggle
visual rule so unchecked-but-disabled toggles also dim. Without this,
"Chat always on screen" / "Show Chat and Users" stayed fully bright
when "Disable chat" was ticked even though the underlying inputs were
disabled. Fixes#7592 in the colibris skin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): simple flex titlebar — CHAT _ []
Single flex row, vertically centred via align-items: center. Title
takes the remaining width with flex: 1; the two corner controls fall
in at the right edge in source order (titlecross then titlesticky),
giving the intended visual: minus on the left, sticky on the right.
Drops `float: right` from the controls, `display: inline` from the
heading, and the prior `padding-top: 2px` hack on titlesticky (flex
alignment handles the vertical position now).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): titlebar uses underscore for minimize; symmetric padding
- Replace \`−\` with \`_\` in #titlecross. The minus glyph sits at
the centre of its em-box and read as a hyphen mid-row when the row
was vertically centred; \`_\` sits at the bottom of its em-box and
reads as a proper minimize indicator.
- Even out #titlebar horizontal padding to 9px and drop the asymmetric
\`margin-left: 4px\` on #titlelabel so CHAT on the left and the
sticky button on the right are the same distance from the bar's
edges.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): lift #titlecross underscore 5px
The \`_\` glyph renders at the bottom of its em-box, so even with the
title bar's flex \`align-items: center\` it sits noticeably below the
CHAT baseline. Lift it with \`transform: translateY(-5px)\` (doesn't
affect flex layout calculations) so the underscore reads at roughly
the same vertical line as the title.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(chat): cover #7590 / #7592 / #7593 fixes
Adds Playwright frontend specs for the changes in this PR:
chat.spec.ts
- chat icon click reveals chatbox after disable→enable cycle
(regression: chat.show() must clear inline display:none)
- title bar lays out as a centred flex row with underscore minimize
(covers display, align-items, label flex:1, no float, translateY
lift, and visual padding symmetry via rendered geometry)
- chat icon click reliably opens the chat box (#chaticon .buttonicon
pointer/flex reset)
pad_settings.spec.ts
- disabling chat disables and visually greys the dependent chat
toggles (#7592 — checks input :disabled state and label opacity)
change_user_name.spec.ts
- #myusernameform has 10px left margin and is not width-capped
(#7593 review — colibris margin alignment, no input width cap)
Padding symmetry asserted via rendered rect deltas rather than the
CSS literal, since colibris ships its own #titlebar padding override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#5071. `/p/:pad/:rev/export/etherpad` has always ignored the rev
parameter and returned the full pad history, unlike the txt/html
export endpoints which use the same route but do respect rev. Users
wanting to back up or inspect a snapshot of a pad at a specific rev
got every later revision in the payload instead — both wasteful and
a surprise when the downloaded .etherpad blob contained content that
had supposedly been reverted.
Change:
- `exportEtherpad.getPadRaw(padId, readOnlyId, revNum?)` now takes an
optional revNum. When supplied, it clamps to `min(revNum, pad.head)`,
iterates only revs 0..effectiveHead, and ships a shallow-cloned pad
object whose `head` and `atext` reflect the requested snapshot. The
original live Pad is still passed to the `exportEtherpad` hook so
plugin callbacks see the real document.
- `ExportHandler` passes `req.params.rev` through on the `etherpad`
type, matching the existing behavior of `txt` and `html`.
- Chat history is intentionally left full (it is not rev-anchored).
Adds three backend regression tests under `ExportEtherpad.ts`:
- default (no revNum) still exports the full history
- explicit revNum limits exported revs and rewrites the serialized
head so re-import reconstructs the pad at that rev
- revNum above head is treated as full history, preventing accidental
truncation of short pads
Out of scope: `getHTML(padID, rev)` on the API side is already honoring
rev in current code (exportHtml.getPadHTML threads the parameter
through), so the earlier report on that API call appears to be
resolved. This PR does not touch it.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): undo/redo scrolls the viewport to follow the caret
Before: on a large pad, pressing Ctrl+Z (or Ctrl+Y, or the toolbar undo
button) updated the caret in the rep model and the DOM, but the viewport
did not follow when the caret landed below the visible area. The user
was left looking at the same scroll position while their change had
been undone somewhere they couldn't see.
Root cause: scroll.ts's `caretIsBelowOfViewport` branch ran
`outer.scrollTo(0, outer[0].innerHeight)` — a fixed offset equal to the
inner iframe's height, NOT the caret position. That was a special-case
added in PR #4639 to keep the caret visible when the user pressed Enter
at the very end of the pad. It worked for that one scenario because the
newly-appended `<div>` happened to be at the bottom of the pad too; for
any other way of putting the caret below the viewport (undo, redo,
programmatic selection change, deletion that collapsed a long block) it
scrolled to an arbitrary spot.
Fix: mirror the `caretIsAboveOfViewport` branch. After the deferred
render settles, recompute the caret's position relative to the viewport
and scroll by exactly the delta needed to bring the caret back in — plus
the configured margin. The Enter-at-last-line case still works because
the caret genuinely is near the bottom of the pad and the delta resolves
to "scroll down by a screen".
Closes#7007
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7007): use real typing so undo has changesets to replay
The first iteration of the Playwright spec built the pad by writing
directly to #innerdocbody.innerHTML. That bypasses Etherpad's text
layer, so the undo module had no changeset to revert — Ctrl+Z became a
no-op and the scroll assertion saw no movement (CI failure output:
`Expected: < 2302, Received: 2302`).
Replace with real keyboard typing of 45 lines via the existing
writeToPad-style pattern, then make the edit + scroll + Ctrl+Z under
that real content. Slower (~5s per test) but faithful to how undo
interacts with the pad.
Also drop the `test.beforeEach(clearCookies)` scaffolding — it wasn't
doing anything useful here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(7007): scroll caret into view directly in doUndoRedo
Revert the scroll.ts rewrite from the previous commits and move the
fix to the right abstraction layer: the undo/redo entry point itself.
`scrollNodeVerticallyIntoView`'s caret-below-viewport branch has a
well-documented special case (PR #4639) that scrolls to the inner
iframe's innerHeight so Enter-on-last-line stays smooth. Changing
that function for the undo case risked regressing the Enter case or
racing with the existing scrollY bookkeeping. The CI run showed the
rewrite wasn't actually producing viewport movement.
Do the simpler thing instead: in `doUndoRedo`, after the selection is
updated, call `Element.scrollIntoView({block: "center"})` on the
caret's line node. That's browser-native, works inside the
ace_inner / ace_outer iframe chain, doesn't need setTimeout, and matches
what gedit/libreoffice do.
Closes#7007
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: page down/up now scrolls by viewport height, not line count
The previous implementation counted logical lines in the viewport,
which failed when long wrapped lines consumed the entire viewport.
Now scrolls by actual pixel height for correct behavior.
Fixes#4562
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use outerDoc instead of outerWin.document for viewport height in PageDown/Up
outerWin is an HTMLIFrameElement (returned by getElementsByName), not a
Window object, so it has no .document property. The existing getInnerHeight()
helper already uses outerDoc.documentElement.clientHeight correctly; align
the PageDown/PageUp handler with that pattern.
Adds a Playwright regression test that verifies PageDown scrolls the
viewport when the pad contains long wrapping lines.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: rewrite page down/up to use pixel-based line counting
The previous approach tried to scroll the outerWin iframe element
directly which didn't work. Reverted to the original cursor-movement
approach but calculates lines-to-skip using viewport pixel height
divided by actual rendered line heights. This correctly handles long
wrapped lines that consume multiple visual rows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore getInnerHeight + inclusive range fixes lost in rebase
Recover the PageDown/Up fixes that got dropped when this branch was
rebased onto develop:
- Use getInnerHeight() instead of outerDoc.documentElement.clientHeight
so hidden-iframe and Opera edge cases are handled the same as the rest
of the editor.
- scroll.getVisibleLineRange() returns an inclusive end index, so count
(end - start + 1) logical lines to match the pixel-sum loop bounds.
- Replace the flaky 'PageDown scrolls viewport' test with the robust
#4562 regression that builds long wrapped lines via direct DOM and
asserts the caret advances on successive PageDown presses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After picking a value from a toolbar <select> (ep_headings style
picker is the canonical case), keyboard focus was left on the
nice-select wrapper rather than returned to the pad editor. Users
had to click back into the pad before typing resumed.
The ToolbarItem.bind() class already calls padeditor.ace.focus() at
the end of triggerCommand, but that only runs for selects wired via
data-key on the wrapping <li>. Plugin-provided selects (e.g.
ep_headings2's #heading-selection inside <li id="headings">, no
data-key) don't go through that path — they bind their own change
handler and never return focus.
Fix: add a delegated change handler on `#editbar select` that calls
padeditor.ace.focus() after any toolbar select change. Deferred via
setTimeout(0) so plugin change handlers (bound on the same event)
complete their ace.callWithAce work before focus moves. Redundant but
harmless for data-key-wired selects that are already refocused by
triggerCommand.
Added a Playwright regression test that simulates the nice-select
option-click (val + change, which is what the wrapper dispatches
internally) and verifies typing after the change lands in the pad.
Skips when ep_headings2 isn't installed.
Closes#7589.
* fix(admin): restore i18n on /admin by copying locales to the right path
The admin SPA fetches `/admin/locales/<lang>.json`. Building with
vite-plugin-static-copy and `src: '../src/locales'` was placing the
115 core locale files at `src/templates/admin/src/locales/` (the
plugin's `dirClean` strips a leading `../` but keeps the remaining
parent path). The express admin handler 404'd those fetches, fell
back to serving `index.html`, JSON.parse silently failed, and every
`<Trans>` rendered its raw key — see #7586.
Replace the plugin with a small inline build/dev plugin: at build
time copy `src/locales/*.json` to `<outDir>/locales/`; in dev serve
the same files via middleware so `vite dev` also works. Drop the
now-unused `vite-plugin-static-copy` dependency.
Add regression coverage that none of the existing admin specs had:
- backend HTTP test for GET /admin/locales/{en,de}.json
- Playwright admin i18n spec asserting translated <h1> renders for
the default locale and for ?lng=de, plus a request-level check
that the response is JSON, not the SPA fallback.
Closes#7586
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(admin): bundle locales via import.meta.glob, drop copy plugin
The first pass at #7586 replaced vite-plugin-static-copy with a
custom build/dev plugin that copied src/locales/*.json into the
admin output and served them in dev. That works, but the
vite-plugin-static-copy README explicitly recommends the public
directory or a JS import for this case, and the import path is
strictly cleaner: no copy step, no /admin/locales/* express route,
no SPA-fallback-shaped failure mode.
Use import.meta.glob in admin/src/localization/i18n.ts so each
language ships as its own hashed JSON chunk and is lazy-loaded on
demand. The vite config goes back to just react + base + outDir.
The plugin namespaces (e.g. ep_admin_pads) keep their existing
admin/public/<ns>/<lang>.json layout.
Tests:
- Drop tests/backend/specs/adminLocales.ts — it asserted on a
/admin/locales/<lang>.json route that this approach no longer
uses; the regression mechanism it pinned doesn't exist anymore
and the test required the admin frontend to be built before the
backend test runs (which CI doesn't do).
- Keep tests/frontend-new/admin-spec/admini18n.spec.ts (rendered
<h1> in default and ?lng=de). Verified red→green: reverting just
the loader to the pre-fix /admin/locales fetch makes both specs
fail; restoring makes them pass.
Also update pnpm-lock.yaml to drop the now-unused
vite-plugin-static-copy entries — fixes ERR_PNPM_OUTDATED_LOCKFILE
that was failing every CI install upfront.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): negotiate lang/dir per request and set on <html>
Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.
This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): dialog semantics on popups; fix aria-role typo on userlist
Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:
- #settings, #import_export, #embed, #skin-variants (labelledby)
- #connectivity, #users, #mycolorpicker (aria-label)
Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.
Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): focus management and Escape-to-close for popups
Three additions to toggleDropDown / _bodyKeyEvent:
- Remember the trigger element (document.activeElement) when opening
a popup, so we can restore focus when it closes.
- On open, focus the first focusable element inside the popup so
keyboard users land inside the dialog instead of staying on the
trigger button.
- Escape pressed while focus is inside a popup closes it, then the
restore-focus path runs and the trigger button is refocused.
Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): make chaticon and chat header controls real buttons
- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users
Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): accessible names for icon-only toolbar/export controls
- Export links (#exportetherpada, #exporthtmla, #exportplaina,
#exportworda, #exportpdfa, #exportopena): added aria-label so the
link is announced as e.g. "Export as PDF". The inner icon span
gets aria-hidden=true so screen readers don't read both the icon
text and the link label.
- Show-more toolbar toggle (.show-more-icon-btn): converted from
<span> to <button type=button> with aria-label and aria-expanded.
The click handler now toggles aria-expanded alongside the
full-icons class so assistive tech reflects the open/closed state.
- Theme switcher knob: aria-label changed from "theme-switcher-knob"
(a class-style identifier, not human text) to "Toggle theme".
Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(a11y): cover dialog semantics, html lang, icon button labels
New Playwright spec verifies the a11y guarantees added by this branch:
- <html> has a non-empty lang attribute
- settings/import_export/embed/users popups expose role=dialog,
aria-modal=true, and either aria-labelledby (when an h1 exists)
or aria-label (when none does)
- Escape from inside the settings popup closes it AND restores
focus to the trigger button
- Export links each carry a descriptive aria-label
- #chaticon is a real <button> with aria-label
- #titlecross / #titlesticky are real <button>s with aria-label
- #otherusers uses role=region + aria-live=polite + aria-label
(and the previous aria-role typo is gone)
- .show-more-icon-btn is a <button> with aria-label and
aria-expanded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): address Qodo review feedback from PR #7584
1. Users Escape close broken - toggleDropDown('none') intentionally
skips the users module so switching between other popups doesn't
hide the user list. That meant Escape couldn't dismiss the Users
popup either. The Escape branch now checks for #users as the
focused popup and closes it explicitly (respecting stickyUsers)
before falling through to the normal close-all path.
2. Embed focus overridden - the rAF auto-focus in toggleDropDown
grabbed the first focusable descendant, which stole focus from
command handlers that target a specific control (notably the Embed
command's #linkinput). rAF now bails out if focus is already
inside the newly-opened popup.
3. Button click blurs :focus before toggleDropDown captures trigger -
discovered while investigating the Firefox Playwright failure for
"settings popup Escape restores focus". Button.bind() calls
$(':focus').trigger('blur') before invoking the callback, so by
the time toggleDropDown() captured document.activeElement as the
restore target it was already <body>. The click handler now
stashes padeditbar._lastTrigger to the clicked <button> before
blur runs; toggleDropDown only falls back to activeElement when
the pre-stash didn't happen (keyboard shortcut path).
4. html10n overwrites aria-label - html10n unconditionally set
aria-label to the translated string, clobbering explicit aria-label
on elements that also carry data-l10n-id. setAttribute now only
fires when the element has no aria-label; explicit author labels
win, unlabelled translated elements still get a name.
5. Button visual reset - the show-more-icon-btn and #chaticon
conversions inherited UA default button border/background/padding,
shifting icon glyphs visibly off-centre. Added appearance /
background / border / padding resets.
6. Export links test assumes soffice is installed - #exportworda,
#exportpdfa, #exportopena are removed client-side by pad_impexp.ts
when clientVars.exportAvailable === 'no'. The test now skips links
absent at runtime.
Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.
* fix(a11y): close popups with no focusable content; unbreak chat-icon layout
Round 2 of #7584 review follow-ups.
1. Users popup Escape still didn't close the dialog (user-confirmed).
Root cause: _bodyKeyEvent is bound to the OUTER document's body.
When #users opens, the command handler tries to focus
#myusernameedit but that input is `disabled`, so focus stays in the
ace editor iframe. Keydown from inside the iframe does not bubble
to the outer document, so Esc never reaches _bodyKeyEvent.
Fix: in the open-popup rAF, if no command handler placed focus
inside the dialog, focus the popup div itself (with tabindex=-1).
That keeps subsequent keydown events on the outer document so
Esc can dismiss the popup. Also broadened the Esc branch to fire
whenever any popup is `.popup-show`, regardless of where :focus
lives — some popups legitimately have no focusable content at
open.
Added a regression test that opens #users and asserts Esc closes
it. Passes on both Chromium and Firefox.
2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
- My previous `border: 0` reset was overriding the intended
`border: 1px solid #ccc; border-bottom: none` from the earlier
rule. Removed `border: 0`; the earlier explicit border suffices
to suppress UA defaults.
- The `<span class="buttonicon">` inside `#chaticon` was picking
up the global `.buttonicon { display: flex; }` rule meant for
toolbar button instances, which broke the inline layout of the
label + glyph + counter row. Added a scoped
`#chaticon .buttonicon { display: inline; }` override.
All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.
* fix(a11y): only stash _lastTrigger for dropdown-opening buttons
Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.
Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.
Verified locally on Chromium:
- ordered_list.spec.ts: 6/6 pass (was 4/6)
- unordered_list.spec.ts: 6/6 pass (was 4/6)
- undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
- a11y_dialogs.spec.ts: 11/11 pass (unchanged)
* fix(a11y): address Qodo review round 4 for PR #7584#1 Stale aria-label after relocalize
html10n.translateNode() refused to overwrite any existing aria-label,
which also skipped updates on language change (pad.applyLanguage()
re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
aria-label + marker when html10n populates it, overwrite only if the
marker is present. Explicit template-supplied aria-labels stay as-is;
html10n-generated ones refresh on relocalize.
#2 Escape won't close colorpicker
_bodyKeyEvent caught Escape on any `.popup.popup-show` but only
closed dropdown popups via toggleDropDown('none'). Popups opened
outside the editbar framework (#mycolorpicker, toggled directly by
pad_userlist.ts) stayed open while preventDefault() swallowed the
key. Now the Escape branch manually closes any popup that
toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
unless pinned) and leaves registered dropdowns for toggleDropDown to
close so its focus-restore sees the transition.
#3 Stale focus restoration
toggleDropDown('none') restored focus to _lastTrigger even when no
popup was open on entry, which meant background callers
(connectivity setup, periodic state handling) could yank focus out
of the editor to a stale toolbar button. Gated the restore on
`wasAnyOpen === true` so it only fires when there was a popup to
close.
#11 English aria-label overrides i18n (export links, chat icon)
Removed the hard-coded English aria-label from export anchors and
removed aria-hidden from their inner localized spans. Screen readers
now get the localized child text as the accessible name (Etherpad,
HTML, PDF, etc.), matching the visible UI language.
Removed the English aria-label from #chaticon and #titlesticky as
well — both have data-l10n-id, so html10n populates a localized
aria-label via the marker mechanism in #1. #titlecross keeps its
static aria-label because it has no data-l10n-id yet.
#4 4-space indent in a11y spec
Two tests had continuation lines at 4-space indent violating the
repo's 2-space rule. Folded the signatures onto one line.
Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).
Verified locally (dev server restarted for each round):
- a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
- ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
- Full backend suite: 799 passing, 0 failing
- tsc --noEmit clean in our code
#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>