fix(a11y): add Dialog titles/descriptions and missing index.code key (#7836)

Closes #7835.

- src/locales/en.json: add `index.code` (referenced by src/templates/index.html
  for the session-receive code input but never defined, producing a
  "Couldn't find translation key" console error on the landing page).
- admin/src/utils/LoadingScreen.tsx, admin/src/pages/PadPage.tsx,
  admin/src/pages/AuthorPage.tsx: every @radix-ui/react-dialog `Dialog.Content`
  now has a `Dialog.Title` and `Dialog.Description` (visually hidden via
  `@radix-ui/react-visually-hidden` where there is no visible heading),
  silencing Radix's a11y console warnings on every admin page load.
- src/tests/backend-new/specs/template-l10n-keys.test.ts: regression
  coverage — fails CI if any `data-l10n-id` in `src/templates/*.html` is
  missing from `src/locales/en.json`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-25 10:43:10 +01:00 committed by GitHub
parent 53aa7a1c5a
commit d106ed9c5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 84 additions and 14 deletions

View file

@ -24,6 +24,7 @@
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.4",

View file

@ -14,6 +14,8 @@
"cap-warning": "Showing the first 1000 authors. Narrow your search to see more.",
"feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.",
"no-results": "No authors match this search.",
"confirm-dialog-title": "Confirm author erasure",
"confirm-dialog-description": "Review the impact of erasing this author and confirm or cancel.",
"confirm-preview-title": "Erase author {{name}}",
"confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.",
"confirm-irreversible": "This cannot be undone.",

View file

@ -1,6 +1,7 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
import * as Dialog from "@radix-ui/react-dialog";
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
import {ChevronLeft, ChevronRight, Trash2} from "lucide-react";
import {useStore} from "../store/store.ts";
import {SearchField} from "../components/SearchField.tsx";
@ -153,16 +154,20 @@ export const AuthorPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild>
<Dialog.Title>{t('ep_admin_authors:confirm-dialog-title')}</Dialog.Title>
</VisuallyHidden>
<VisuallyHidden asChild>
<Dialog.Description>{t('ep_admin_authors:confirm-dialog-description')}</Dialog.Description>
</VisuallyHidden>
{dialog.phase === 'loading-preview' && <div>
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
</div>}
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
const p = dialog.preview;
return <div>
<Dialog.Title asChild>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
</Dialog.Title>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
<p>{t('ep_admin_authors:confirm-preview-counters', {
tokenMappings: p.removedTokenMappings,
externalMappings: p.removedExternalMappings,

View file

@ -4,6 +4,7 @@ import {useStore} from "../store/store.ts";
import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {useDebounce} from "../utils/useDebounce.ts";
import * as Dialog from "@radix-ui/react-dialog";
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
import {useForm} from "react-hook-form";
import type {TFunction} from "i18next";
@ -165,7 +166,10 @@ export const PadPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.delete_pad_dialog_title')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
</Dialog.Description>
<div className="settings-button-bar">
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
@ -178,7 +182,10 @@ export const PadPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.error_prefix')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
</Dialog.Description>
<div className="settings-button-bar">
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
</div>
@ -191,6 +198,7 @@ export const PadPage = () => {
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
<VisuallyHidden asChild><Dialog.Description>{t('admin_pads.create_pad_dialog_description')}</Dialog.Description></VisuallyHidden>
<form onSubmit={handleSubmit(onPadCreate)}>
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>

View file

@ -1,13 +1,18 @@
import {useStore} from "../store/store.ts";
import * as Dialog from '@radix-ui/react-dialog';
import {VisuallyHidden} from '@radix-ui/react-visually-hidden';
import {useTranslation} from 'react-i18next';
import brand from './brand.svg'
export const LoadingScreen = ()=>{
const showLoading = useStore(state => state.showLoading)
const {t} = useTranslation()
return <Dialog.Root open={showLoading}><Dialog.Portal>
<Dialog.Overlay className="loading-screen fixed inset-0 bg-black bg-opacity-50 z-50 dialog-overlay" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 dialog-content">
<VisuallyHidden asChild><Dialog.Title>{t('admin.loading')}</Dialog.Title></VisuallyHidden>
<VisuallyHidden asChild><Dialog.Description>{t('admin.loading_description')}</Dialog.Description></VisuallyHidden>
<div className="flex flex-col items-center">
<div className="animate-spin w-16 h-16 border-t-2 border-b-2 border-[--fg-color] rounded-full"></div>
<div className="mt-4 text-[--fg-color]">

19
pnpm-lock.yaml generated
View file

@ -68,6 +68,9 @@ importers:
'@radix-ui/react-toast':
specifier: ^1.2.15
version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@types/react':
specifier: ^19.2.15
version: 19.2.15
@ -8342,10 +8345,10 @@ snapshots:
'@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3)
'@typescript-eslint/parser': 7.18.0(eslint@10.4.0)(typescript@6.0.3)
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.4.0)
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0)
eslint-plugin-cypress: 2.15.2(eslint@10.4.0)
eslint-plugin-eslint-comments: 3.2.0(eslint@10.4.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0)
eslint-plugin-mocha: 10.5.0(eslint@10.4.0)
eslint-plugin-n: 17.24.0(eslint@10.4.0)(typescript@6.0.3)
eslint-plugin-prefer-arrow: 1.2.3(eslint@10.4.0)
@ -8366,7 +8369,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0)(eslint@10.4.0):
eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@8.1.1)
@ -8377,18 +8380,18 @@ snapshots:
stable-hash: 0.0.5
tinyglobby: 0.2.16
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0):
eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.18.0(eslint@10.4.0)(typescript@6.0.3)
eslint: 10.4.0
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.4.0)
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0)
transitivePeerDependencies:
- supports-color
@ -8410,7 +8413,7 @@ snapshots:
eslint: 10.4.0
ignore: 5.3.2
eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -8421,7 +8424,7 @@ snapshots:
doctrine: 2.1.0
eslint: 10.4.0
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3

View file

@ -1,6 +1,7 @@
{
"admin.page-title": "Admin Dashboard - Etherpad",
"admin.loading": "Loading…",
"admin.loading_description": "Please wait while the page is loading.",
"admin.toggle_sidebar": "Toggle sidebar",
"admin.shout": "Communication",
"admin_shout.online_one": "There is currently {{count}} user online",
@ -20,7 +21,11 @@
"admin_pads.col.revisions": "Revisions",
"admin_pads.col.users": "Users",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Choose a name for the new pad.",
"admin_pads.delete_pad_dialog_description": "Confirm or cancel pad deletion.",
"admin_pads.delete_pad_dialog_title": "Delete pad",
"admin_pads.empty_never_edited": "empty · never edited",
"admin_pads.error_dialog_description": "An error has occurred.",
"admin_pads.error_prefix": "Error",
"admin_pads.filter.active": "Active",
"admin_pads.filter.all": "All",
@ -217,6 +222,7 @@
"index.copyLinkButton": "Copy link to clipboard",
"index.transferToSystem": "3. Copy session to new system",
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
"index.code": "Code",
"index.transferSessionDescription": "Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.",
"index.createOpenPad": "Open pad by name",
"index.openPad": "open an existing Pad with the name:",

View file

@ -0,0 +1,40 @@
'use strict';
// Regression: src/templates/index.html referenced `data-l10n-id="index.code"`
// but src/locales/en.json had no `index.code` key, producing a "Couldn't find
// translation key index.code" console error on the landing page (issue #7835).
// This test asserts every data-l10n-id attribute in our shipped templates has
// a matching source string in en.json so the class of bug fails in CI.
import {readFileSync, readdirSync} from 'fs';
import {join} from 'path';
import {describe, it, expect} from 'vitest';
const repoRoot = join(__dirname, '..', '..', '..', '..');
const templatesDir = join(repoRoot, 'src', 'templates');
const enJsonPath = join(repoRoot, 'src', 'locales', 'en.json');
const en = JSON.parse(readFileSync(enJsonPath, 'utf8')) as Record<string, string>;
const collectKeys = (html: string): string[] => {
const out: string[] = [];
const re = /data-l10n-id="([^"]+)"/g;
let m;
while ((m = re.exec(html)) !== null) out.push(m[1]);
return out;
};
const templateFiles = readdirSync(templatesDir)
.filter((f) => f.endsWith('.html'))
.map((f) => join(templatesDir, f));
describe('template l10n keys', () => {
for (const file of templateFiles) {
it(`every data-l10n-id in ${file.replace(repoRoot + '/', '')} exists in en.json`, () => {
const html = readFileSync(file, 'utf8');
const keys = collectKeys(html);
const missing = keys.filter((k) => !(k in en));
expect(missing, `missing keys in en.json: ${missing.join(', ')}`).toEqual([]);
});
}
});