mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
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:
parent
53aa7a1c5a
commit
d106ed9c5e
8 changed files with 84 additions and 14 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'}}>
|
||||
|
|
|
|||
|
|
@ -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
19
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
40
src/tests/backend-new/specs/template-l10n-keys.test.ts
Normal file
40
src/tests/backend-new/specs/template-l10n-keys.test.ts
Normal 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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue