From 17a26894b0b66ace6b4f47c917c749ca44138cae Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 17:37:21 +0200 Subject: [PATCH] fix(a11y): every sortable list speaks the user's language to a screen reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dnd-kit's screen-reader text is hard-coded English and names an item by its id ("Draggable item 10357 was dropped", a role of "sortable"). Only the YouTube playlist replaced it; the Plex playlist, the saved-views list and the side-panel groups read it out under a Hungarian UI — found while checking the Plex playlist's keyboard reorder on localdev. `ui/sortableA11y` is now the one place: `useSortableA11y` gives a DndContext its instructions and announcements (HU/EN, `common.dnd.*`), `useSortableItem` announces the role. The YouTube playlist's own announcements move onto it, which also fixes them: its lists reorder on drop, so reading the dragged row's own index said "moved to position 1" at every step and "dropped at position 1" wherever the row went. The place announced is now the slot the row would land in. A `local/sortable-a11y` lint rule (with its contract test) refuses a DndContext without `accessibility` and a direct `useSortable`. --- frontend/eslint.config.js | 32 +++++++++ .../src/components/PersonalViewsModal.tsx | 23 ++++--- frontend/src/components/Playlists.tsx | 41 ++++-------- frontend/src/components/PlexPlaylistView.tsx | 32 +++++++-- frontend/src/components/ui/PanelGroup.tsx | 4 +- frontend/src/components/ui/PanelGroups.tsx | 13 +++- .../components/ui/sortableA11y.dom.test.tsx | 55 ++++++++++++++++ frontend/src/components/ui/sortableA11y.ts | 65 +++++++++++++++++++ frontend/src/i18n/locales/en/common.json | 10 ++- frontend/src/i18n/locales/en/playlists.json | 5 -- frontend/src/i18n/locales/hu/common.json | 10 ++- frontend/src/i18n/locales/hu/playlists.json | 5 -- .../src/lib/sortableA11y.contract.test.ts | 35 ++++++++++ 13 files changed, 273 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/ui/sortableA11y.dom.test.tsx create mode 100644 frontend/src/components/ui/sortableA11y.ts create mode 100644 frontend/src/lib/sortableA11y.contract.test.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1901c399..4eca0b3c 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -122,8 +122,39 @@ const literalA11yText = { }; }, }; +// dnd-kit's screen-reader text is hard-coded English and names an item by its id; three of the four +// sortable lists shipped it that way under a Hungarian UI. `ui/sortableA11y` is the one place that +// replaces it, and this is what keeps the next sortable list from skipping it. +const sortableA11y = { + meta: { + type: "problem", + messages: { + context: "A needs `accessibility={useSortableA11y(...)}` (ui/sortableA11y).", + item: "Use `useSortableItem` from ui/sortableA11y: it announces the role in the user's language.", + }, + }, + create(context) { + const own = context.filename.split("\\").join("/").endsWith("components/ui/sortableA11y.ts"); + return { + JSXOpeningElement(node) { + if (node.name.type !== "JSXIdentifier" || node.name.name !== "DndContext") return; + if ( + !node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "accessibility") + ) + context.report({ node, messageId: "context" }); + }, + ImportDeclaration(node) { + if (own || node.source.value !== "@dnd-kit/sortable") return; + for (const sp of node.specifiers) + if (sp.type === "ImportSpecifier" && sp.imported.name === "useSortable") + context.report({ node: sp, messageId: "item" }); + }, + }; + }, +}; const localRules = { rules: { + "sortable-a11y": sortableA11y, "jsx-comment-text": jsxCommentText, "focus-safe-disabled": focusSafeDisabled, "literal-a11y-text": literalA11yText, @@ -158,6 +189,7 @@ export default tseslint.config( "local/jsx-comment-text": "error", "local/focus-safe-disabled": "error", "local/literal-a11y-text": "error", + "local/sortable-a11y": "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. diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 32fe0501..e87d6b1b 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -9,12 +9,7 @@ import { useSensors, type DragEndEvent, } from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; import Modal from "./ui/Modal"; @@ -25,6 +20,7 @@ import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { useUndoable } from "../lib/useUndoable"; import UndoToolbar from "./ui/UndoToolbar"; import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name @@ -43,7 +39,7 @@ function ViewRow({ onSelect: () => void; }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id: view.id, }); return ( @@ -147,6 +143,12 @@ export default function PersonalViewsModal() { ) : feedCaptured; + const dndA11y = useSortableA11y((id) => ({ + title: views.find((v) => v.id === id)?.name ?? "", + pos: views.findIndex((v) => v.id === id) + 1, + total: views.length, + })); + async function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; @@ -179,7 +181,12 @@ export default function PersonalViewsModal() { {views.length === 0 && (

{t("views.empty")}

)} - + v.id)} strategy={verticalListSortingStrategy} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 894a3d9e..adfbe755 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -15,7 +15,6 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -69,6 +68,7 @@ import { alignClass } from "./ui/tableAlign"; import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns"; import { GRIP_KEY, playlistColumns } from "./playlistColumns"; import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; // The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a // body that is still a drag-reorderable @dnd-kit list. @@ -115,7 +115,7 @@ function Row({ menuProps: Record; }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id: video.id, disabled: dragDisabled, }); @@ -415,32 +415,15 @@ export default function Playlists() { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); - const posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1; - const titleOf = (id: string | number) => shownRef.current.find((v) => v.id === id)?.title ?? ""; - const total = () => shownRef.current.length; - const screenReaderInstructions = { draggable: t("playlists.dndInstructions") }; - const announcements = { - onDragStart: ({ active }: { active: { id: string | number } }) => - t("playlists.dndPickedUp", { - title: titleOf(active.id), - pos: posOf(active.id), - total: total(), - }), - onDragOver: ({ active }: { active: { id: string | number } }) => - t("playlists.dndMovedOver", { - title: titleOf(active.id), - pos: posOf(active.id), - total: total(), - }), - onDragEnd: ({ active }: { active: { id: string | number } }) => - t("playlists.dndDropped", { - title: titleOf(active.id), - pos: posOf(active.id), - total: total(), - }), - onDragCancel: ({ active }: { active: { id: string | number } }) => - t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }), - }; + // Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y). + const dndA11y = useSortableA11y((id) => { + const rows = shownRef.current; + return { + title: rows.find((v) => v.id === id)?.title ?? "", + pos: rows.findIndex((v) => v.id === id) + 1, + total: rows.length, + }; + }); const plName = (p: { kind: string; name: string }) => playlistName(p, t); @@ -1072,7 +1055,7 @@ export default function Playlists() { onDragEnd={onDragEnd} // dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could // drag; now that the keyboard can, they ARE the instructions for the feature. - accessibility={{ screenReaderInstructions, announcements }} + accessibility={dndA11y} > {/* Not clipped, so the header's filter popovers can overflow the table box. */}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 321a7245..cf29bac9 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -15,7 +15,6 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -41,6 +40,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import { FocusSafeButton } from "./ui/FocusSafeButton"; import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits"; +import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat @@ -146,7 +146,7 @@ function DragHandle({ title, ...props }: { title: string } & Record; ctx: Ctx }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id: block.id, disabled: ctx.busy, }); @@ -458,6 +458,23 @@ export default function PlexPlaylistView({ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); + // A whole block (a movie, a show) sorts among the blocks; an episode only within its own show. + const dndA11y = useSortableA11y((id) => { + const at = blocks.findIndex((b) => b.id === id); + if (at >= 0) { + const b = blocks[at]!; + return { + title: b.kind === "show" ? b.title : b.item.title, + pos: at + 1, + total: blocks.length, + }; + } + const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id)); + const eps = show?.kind === "show" ? show.items : []; + const ep = eps.findIndex((it) => it.id === id); + return { title: eps[ep]?.title ?? "", pos: ep + 1, total: eps.length }; + }); + function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; @@ -581,7 +598,12 @@ export default function PlexPlaylistView({ ) : items.length === 0 ? (

{t("plex.playlist.empty")}

) : ( - + b.id)} strategy={verticalListSortingStrategy}>
{blocks.map((b) => diff --git a/frontend/src/components/ui/PanelGroup.tsx b/frontend/src/components/ui/PanelGroup.tsx index 361d64d8..487a06de 100644 --- a/frontend/src/components/ui/PanelGroup.tsx +++ b/frontend/src/components/ui/PanelGroup.tsx @@ -1,8 +1,8 @@ import { type ReactNode, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react"; -import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useSortableItem } from "./sortableA11y"; // One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag // handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three @@ -35,7 +35,7 @@ export default function PanelGroup({ children: ReactNode; }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id, disabled: !editing, }); diff --git a/frontend/src/components/ui/PanelGroups.tsx b/frontend/src/components/ui/PanelGroups.tsx index 82d494cb..42e1cae5 100644 --- a/frontend/src/components/ui/PanelGroups.tsx +++ b/frontend/src/components/ui/PanelGroups.tsx @@ -16,6 +16,7 @@ import { } from "@dnd-kit/sortable"; import type { PanelLayout } from "../../lib/panelLayout"; import PanelGroup from "./PanelGroup"; +import { useSortableA11y } from "./sortableA11y"; export type PanelItem = { id: string; @@ -70,6 +71,11 @@ export default function PanelGroups({ function toggleHidden(id: string) { setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); } + const dndA11y = useSortableA11y((id) => ({ + title: byId.get(String(id))?.title ?? "", + pos: renderedIds.indexOf(String(id)) + 1, + total: renderedIds.length, + })); function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; @@ -80,7 +86,12 @@ export default function PanelGroups({ } return ( - +
{renderedIds.map((id) => ( diff --git a/frontend/src/components/ui/sortableA11y.dom.test.tsx b/frontend/src/components/ui/sortableA11y.dom.test.tsx new file mode 100644 index 00000000..bb59587a --- /dev/null +++ b/frontend/src/components/ui/sortableA11y.dom.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { Active, Over } from "@dnd-kit/core"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, o?: Record) => + o ? `${key}:${o.title}@${o.pos}/${o.total}` : key, + }), +})); + +import { useSortableA11y } from "./sortableA11y"; + +const rows = ["a", "b", "c", "d"]; +const describeRow = (id: string | number) => ({ + title: `Row ${id}`, + pos: rows.indexOf(String(id)) + 1, + total: rows.length, +}); +const active = (id: string) => ({ id }) as unknown as Active; +const over = (id: string) => ({ id }) as unknown as Over; + +// The lists here reorder on DROP, so the dragged row's own index stays its starting place for the +// whole drag. The YouTube playlist read exactly that, and announced "moved to position 1" at every +// step and "dropped at position 1" wherever the row went. The place is the `over` row's slot. +describe("useSortableA11y", () => { + const { announcements, screenReaderInstructions } = renderHook(() => useSortableA11y(describeRow)) + .result.current; + + it("speaks the user's language, not dnd-kit's English", () => { + expect(screenReaderInstructions.draggable).toBe("common.dnd.instructions"); + }); + + it("announces where the row WOULD land while it moves, and where it did land", () => { + expect(announcements.onDragStart({ active: active("a") })).toBe( + "common.dnd.pickedUp:Row a@1/4", + ); + expect(announcements.onDragOver({ active: active("a"), over: over("c") })).toBe( + "common.dnd.movedOver:Row a@3/4", + ); + expect(announcements.onDragEnd({ active: active("a"), over: over("d") })).toBe( + "common.dnd.dropped:Row a@4/4", + ); + }); + + it("says a drop outside the list, or Escape, put the row back where it was", () => { + expect(announcements.onDragEnd({ active: active("b"), over: null })).toBe( + "common.dnd.cancelled:Row b@2/4", + ); + expect(announcements.onDragCancel({ active: active("b"), over: null })).toBe( + "common.dnd.cancelled:Row b@2/4", + ); + }); +}); diff --git a/frontend/src/components/ui/sortableA11y.ts b/frontend/src/components/ui/sortableA11y.ts new file mode 100644 index 00000000..158a9534 --- /dev/null +++ b/frontend/src/components/ui/sortableA11y.ts @@ -0,0 +1,65 @@ +import { useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import type { Announcements, ScreenReaderInstructions, UniqueIdentifier } from "@dnd-kit/core"; +import { useSortable } from "@dnd-kit/sortable"; + +// What a screen reader hears while a keyboard user reorders a list. dnd-kit's defaults are +// hard-coded English ("To pick up a draggable item, press the space bar…", "Draggable item 10357 +// was dropped", a role of "sortable"), and only the YouTube playlist had replaced them — the Plex +// playlist, the saved-views list and the side-panel groups spoke English under a Hungarian UI and +// named an item by its internal id. Every sortable list goes through here instead +// (`local` lint: a `` needs `accessibility`, `useSortable` comes from this file). +// +// A position is where the item WOULD land — the `over` item's slot — not where it started: the +// lists here reorder on drop, so reading the dragged item's own index announced its old place at +// every step and at the drop (the YouTube playlist did exactly that). + +/** One sortable item as the announcements name it: its title, and its 1-based place in the list it + * sorts within (a Plex episode sorts within its show, not the whole playlist). */ +export interface SortableSubject { + title: string; + pos: number; + total: number; +} + +/** The `accessibility` prop for a ``. `describe` is read at announcement time, so it + * may close over the latest render without making the announcements a render dependency. */ +export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubject): { + screenReaderInstructions: ScreenReaderInstructions; + announcements: Announcements; +} { + const { t } = useTranslation(); + const latest = useRef(describe); + latest.current = describe; + return useMemo(() => { + // Spread into a fresh object: i18next's options type is an index signature, which a named + // interface does not satisfy. + const at = (id: UniqueIdentifier) => ({ ...latest.current(id) }); + const landing = (active: UniqueIdentifier, over: UniqueIdentifier) => ({ + ...at(active), + pos: at(over).pos, + }); + return { + screenReaderInstructions: { draggable: t("common.dnd.instructions") }, + announcements: { + onDragStart: ({ active }) => t("common.dnd.pickedUp", at(active.id)), + onDragOver: ({ active, over }) => + over ? t("common.dnd.movedOver", landing(active.id, over.id)) : undefined, + onDragEnd: ({ active, over }) => + over + ? t("common.dnd.dropped", landing(active.id, over.id)) + : t("common.dnd.cancelled", at(active.id)), + onDragCancel: ({ active }) => t("common.dnd.cancelled", at(active.id)), + }, + }; + }, [t]); +} + +/** `useSortable`, with its role announced in the user's language instead of dnd-kit's "sortable". */ +export function useSortableItem(args: Parameters[0]) { + const { t } = useTranslation(); + return useSortable({ + ...args, + attributes: { ...args.attributes, roleDescription: t("common.dnd.roleDescription") }, + }); +} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 9db81db3..10a97819 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -35,5 +35,13 @@ "moreRefine": "{{count}} more — refine with search", "matchMode": "Match", "removeNamed": "Remove {{name}}", - "resetNamed": "Reset {{name}}" + "resetNamed": "Reset {{name}}", + "dnd": { + "instructions": "Press space to pick up an item, the arrow keys to move it, space again to drop it, or escape to cancel.", + "roleDescription": "movable item", + "pickedUp": "Picked up “{{title}}”, position {{pos}} of {{total}}.", + "movedOver": "“{{title}}” moved to position {{pos}} of {{total}}.", + "dropped": "“{{title}}” dropped at position {{pos}} of {{total}}.", + "cancelled": "Move cancelled; “{{title}}” is back at position {{pos}}." + } } diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 78277d3c..d4483693 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -113,11 +113,6 @@ "confirmDeleteLinked": "Delete the playlist “{{name}}”? It is synced with YouTube — choose whether to delete it there as well. This can't be undone.", "dragBlockedBuiltin": "This list's order is fixed — it can't be reordered by hand.", "deleteBoth": "Delete here and on YouTube", - "dndInstructions": "Press space to pick up a video, the arrow keys to move it, space again to drop it, or escape to cancel.", - "dndPickedUp": "Picked up “{{title}}”, position {{pos}} of {{total}}.", - "dndMovedOver": "“{{title}}” moved to position {{pos}} of {{total}}.", - "dndDropped": "“{{title}}” dropped at position {{pos}} of {{total}}.", - "dndCancelled": "Move cancelled; “{{title}}” is back at position {{pos}}.", "viewGrouped": "grouped by channel", "viewGroupedAnd": "grouped by channel, {{sort}}", "viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 2c9978d5..57054942 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -35,5 +35,13 @@ "moreRefine": "még {{count}} — finomíts kereséssel", "matchMode": "Egyezés", "removeNamed": "{{name}}: eltávolítás", - "resetNamed": "{{name}}: alaphelyzet" + "resetNamed": "{{name}}: alaphelyzet", + "dnd": { + "instructions": "Szóközzel felveszed az elemet, a nyilakkal mozgatod, újabb szóközzel leteszed, Escape-pel megszakítod.", + "roleDescription": "mozgatható elem", + "pickedUp": "„{{title}}” felvéve, {{pos}}. a(z) {{total}} közül.", + "movedOver": "„{{title}}” a(z) {{pos}}. helyre került a(z) {{total}} közül.", + "dropped": "„{{title}}” letéve a(z) {{pos}}. helyre a(z) {{total}} közül.", + "cancelled": "Mozgatás megszakítva; „{{title}}” visszakerült a(z) {{pos}}. helyre." + } } diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index bd2812dc..8b6c104d 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -113,11 +113,6 @@ "confirmDeleteLinked": "Törlöd a(z) „{{name}}” listát? A YouTube-bal szinkronban van — válaszd ki, hogy ott is törlődjön-e. Nem vonható vissza.", "dragBlockedBuiltin": "Ennek a listának kötött a sorrendje — kézzel nem rendezhető át.", "deleteBoth": "Törlés itt és a YouTube-on", - "dndInstructions": "Szóközzel felveszed a videót, a nyilakkal mozgatod, újabb szóközzel leteszed, Escape-pel megszakítod.", - "dndPickedUp": "„{{title}}” felvéve, {{pos}}. a(z) {{total}} közül.", - "dndMovedOver": "„{{title}}” a(z) {{pos}}. helyre került a(z) {{total}} közül.", - "dndDropped": "„{{title}}” letéve a(z) {{pos}}. helyre a(z) {{total}} közül.", - "dndCancelled": "Mozgatás megszakítva; „{{title}}” visszakerült a(z) {{pos}}. helyre.", "viewGrouped": "csatorna szerint csoportosítva", "viewGroupedAnd": "csatorna szerint csoportosítva, {{sort}}", "viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.", diff --git a/frontend/src/lib/sortableA11y.contract.test.ts b/frontend/src/lib/sortableA11y.contract.test.ts new file mode 100644 index 00000000..c54eb41f --- /dev/null +++ b/frontend/src/lib/sortableA11y.contract.test.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from "node:url"; + +import { ESLint } from "eslint"; +import { describe, expect, it } from "vitest"; + +// `local/sortable-a11y` (eslint.config.js): every sortable list speaks the user's language to a +// screen reader, through ui/sortableA11y. Three of four lists had shipped dnd-kit's English +// defaults. A rule that matches nothing looks exactly like a clean codebase, so this asserts it fires. +const RULE = "local/sortable-a11y"; +const FRONTEND = fileURLToPath(new URL("../../", import.meta.url)); + +async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise { + const eslint = new ESLint({ cwd: FRONTEND }); + const [result] = await eslint.lintText(source, { filePath }); + return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; +} + +describe("a sortable list", () => { + it("is refused without translated screen-reader text", async () => { + expect(await hits(`export const A = () => {}} />;\n`)).toBe(1); + expect( + await hits(`import { useSortable } from "@dnd-kit/sortable";\nexport { useSortable };\n`), + ).toBe(1); + }); + + it("is fine through ui/sortableA11y, which alone may wrap dnd-kit's hook", async () => { + expect(await hits(`export const B = ({ a }) => ;\n`)).toBe(0); + expect( + await hits( + `import { useSortable } from "@dnd-kit/sortable";\nexport { useSortable };\n`, + "src/components/ui/sortableA11y.ts", + ), + ).toBe(0); + }); +});