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