fix(i18n): every label a screen reader or tooltip says is translated

- The Plex playlist rows' "reorder"/"remove", the Plex filter chip's "remove" and the player
  slider's "reset" were English literals; each now names its row or setting in HU/EN.
- The playlist show header's chevron had no accessible name; its decorative thumbnail button left
  the tab order.
- The opt-in Glass tuner panel was English only: every word is translated, and each slider's label
  now names its number box and range.
- New `local/literal-a11y-text` lint rule (with its contract test): a literal word in aria-label,
  title, placeholder, alt and the other spoken/shown attributes fails lint.
This commit is contained in:
2026-09-19 17:12:47 +02:00
parent 7b94e9a651
commit 4f72b193aa
13 changed files with 325 additions and 70 deletions
+52 -2
View File
@@ -82,8 +82,52 @@ const focusSafeDisabled = {
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
// nothing looks at an attribute. A URL-shaped placeholder is not a word, so it passes.
const A11Y_TEXT = new Set([
"aria-label",
"aria-description",
"aria-placeholder",
"aria-roledescription",
"aria-valuetext",
"title",
"placeholder",
"alt",
]);
const literalText = (v) => {
if (!v) return null;
if (v.type === "Literal") return typeof v.value === "string" ? v.value : null;
if (v.type !== "JSXExpressionContainer") return null;
const e = v.expression;
if (e.type === "Literal") return typeof e.value === "string" ? e.value : null;
if (e.type === "TemplateLiteral" && e.expressions.length === 0) return e.quasis[0].value.cooked;
return null;
};
const literalA11yText = {
meta: {
type: "problem",
messages: { literal: "`{{name}}` is read or shown to the user: translate it with t()." },
},
create(context) {
return {
JSXAttribute(node) {
const name = node.name.type === "JSXIdentifier" ? node.name.name : null;
if (!name || !A11Y_TEXT.has(name)) return;
const text = literalText(node.value);
if (text && /[A-Za-z]{2,}/.test(text) && !/^https?:/.test(text))
context.report({ node, messageId: "literal", data: { name } });
},
};
},
};
const localRules = {
rules: { "jsx-comment-text": jsxCommentText, "focus-safe-disabled": focusSafeDisabled },
rules: {
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"literal-a11y-text": literalA11yText,
},
};
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
@@ -113,6 +157,7 @@ export default tseslint.config(
...reactHooks.configs.recommended.rules,
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/literal-a11y-text": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
@@ -237,8 +282,13 @@ export default tseslint.config(
},
{
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
// …and render throwaway markup whose labels no user ever hears.
files: ["src/**/*.test.{ts,tsx}"],
rules: { "no-restricted-globals": "off", "no-restricted-properties": "off" },
rules: {
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
},
},
prettier,
);