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:
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user