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
+21 -8
View File
@@ -128,12 +128,15 @@ function Poster({ src, className }: { src: string; className: string }) {
);
}
function DragHandle(props: Record<string, unknown>) {
/** `title` names the row it moves: a screen reader heard the English word "reorder" on every
* row, with nothing to say which. */
function DragHandle({ title, ...props }: { title: string } & Record<string, unknown>) {
const { t } = useTranslation();
return (
<button
{...props}
className="shrink-0 cursor-grab text-muted hover:text-fg active:cursor-grabbing"
aria-label="reorder"
aria-label={t("plex.playlist.reorderItem", { title })}
>
<GripVertical className="h-4 w-4" />
</button>
@@ -141,6 +144,7 @@ function DragHandle(props: Record<string, unknown>) {
}
function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: ep.id,
disabled: ctx.busy,
@@ -157,7 +161,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
style={style}
className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`}
>
<DragHandle {...attributes} {...listeners} />
<DragHandle title={ep.title} {...attributes} {...listeners} />
<span className="w-5 shrink-0 text-center text-2xs text-muted tabular-nums">
{ep.episode_number}
</span>
@@ -185,7 +189,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
onClick={() => ctx.onRemove([ep.id])}
disabled={ctx.busy}
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
aria-label="remove"
aria-label={t("plex.playlist.removeItem", { title: ep.title })}
>
<X className="h-4 w-4" />
</FocusSafeButton>
@@ -194,6 +198,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
}
function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
disabled: ctx.busy,
@@ -210,7 +215,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
style={style}
className="glass-card flex items-center gap-3 rounded-xl p-2"
>
<DragHandle {...attributes} {...listeners} />
<DragHandle title={item.title} {...attributes} {...listeners} />
<button
onClick={() => ctx.onPlay(item.id)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
@@ -228,7 +233,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
onClick={() => ctx.onRemove([item.id])}
disabled={ctx.busy}
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
aria-label="remove"
aria-label={t("plex.playlist.removeItem", { title: item.title })}
>
<X className="h-4 w-4" />
</FocusSafeButton>
@@ -258,16 +263,24 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
<div className="flex items-center gap-2.5 p-2">
<DragHandle {...attributes} {...listeners} />
<DragHandle title={block.title} {...attributes} {...listeners} />
<button
onClick={() => ctx.toggleShow(block.showKey)}
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={open}
aria-label={block.title}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{!dense && (
<button onClick={() => ctx.toggleShow(block.showKey)} className="flex shrink-0">
// A second, pointer-only target for the same toggle (its images are decorative): out of
// the tab order, so the keyboard does not stop on a button with no name.
<button
onClick={() => ctx.toggleShow(block.showKey)}
className="flex shrink-0"
tabIndex={-1}
aria-hidden
>
{block.items.slice(0, 3).map((ep, i) => (
<img
key={ep.id}