Files
siftlode/frontend/src/components/ui/sortableA11y.ts
T
peter 7e1aeeb9fe refactor(frontend): honest effect deps in the app shell and the ui layer (E27 S2)
Burns the first 14 of the 113 hook suppressions. Three verdicts, applied per site:

A useEffect that should not exist (6):
- Avatar remembered "the image failed" as a boolean an effect reset on src change. The
  component is recycled across items in the virtualized feed, so after one avatar 429s the
  next item's slot committed the fallback initial over a perfectly good image and only the
  post-paint effect cleared it. The failure is now keyed BY URL; derived state cannot be stale.
- Combobox cleared the typed query from an effect on `open`. Moved into setOpenAnd, the single
  funnel every close goes through. That also stops a spurious onQueryChange("") AT MOUNT, which
  told a controlled caller its own initial query had been cleared before the user touched it.
- CommandPalette reset query + picked row from an effect. Closed now means UNMOUNTED, not
  hidden, so a fresh open starts blank because mounting already means that -- and the ranking
  memo over every channel, playlist and saved view stops running while the palette is shut.
- Pager's page box held a copy of the page that an effect re-synced; it now derives it and
  keeps a draft only while being edited.
- App's ?link= / ?oauth= / ?admin= one-shots asserted "once per mount" with an empty dep list.
  The ref guard the codebase already uses in 22 places ENFORCES it, so the honest deps can
  include `t` -- which a language switch legitimately changes, and which would otherwise have
  re-fired the toast.

Honest deps that also fix something (5):
- App's module preload was keyed on the account id, so a permission change that alters the
  module set without moving the id never re-preloaded. `preload()` is idempotent, so depending
  on `me` costs nothing.
- App's access-request nudge now re-issues on a language switch; it used to stay in the old
  language until the pending count changed.
- App's diagnostics effect: applyDiagnostics already no-ops on an unchanged effective level,
  so picking four fields out by hand was guarding nothing and missed any field added later.
- DataTable's two filter effects: a ref holds the latest onFiltersChange so commitFilters can
  be stable, which is what lets the effects name it instead of switching the check off.

Genuine escape hatches (7) moved OUT of eslint-suppressions.json into inline directives that
name the invariant at the site -- the toast handover's render-phase global, the live region's
deliberate arming tick, the docked one-slot component, the popover engine's floating-ui list
state, dnd-kit's per-drag `moved` flag, BackToTop's DOM read and the palette's debounced
announcement. A reader of the code now sees why; a JSON file no one opens does not.

Tests: Avatar (3, new file) and Pager (3) pin the derived-state frames with a layout-phase
probe -- layout effects run before any passive effect, so they read the frame the browser would
paint. Combobox (2) pins the close-clears-query contract and the no-callback-at-mount rule.
All three sets mutation-checked against the effect-based originals.

Suppressions 113 -> 99; exhaustive-deps 45 -> 35.
2026-09-20 23:57:08 +02:00

123 lines
5.8 KiB
TypeScript

import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { closestCenter } from "@dnd-kit/core";
import type {
Active,
Announcements,
CollisionDetection,
DroppableContainer,
KeyboardCoordinateGetter,
ScreenReaderInstructions,
UniqueIdentifier,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates, 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 `<DndContext>` 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 `<DndContext>`. `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,
});
// dnd-kit reports the item over ITSELF right after the pick-up, in the same tick — announced,
// "moved to position 1" replaced "picked up … use the arrow keys" in the live region before a
// screen reader read it (measured in Chrome). Only a real move is announced as one.
let moved = false;
return {
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
announcements: {
onDragStart: ({ active }) => {
moved = false;
return t("common.dnd.pickedUp", at(active.id));
},
onDragOver: ({ active, over }) => {
if (!over || (!moved && over.id === active.id)) return undefined;
// `moved` is per-DRAG state, and the announcement closures capturing it is the point:
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
// the only thing separating that from a real move. A ref would be the wrong lifetime —
// one drag, not the component.
// eslint-disable-next-line react-hooks/immutability
moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id));
},
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]);
}
// A context that nests sortable lists (a Plex playlist: its blocks, and each show's episodes) must
// keep a drag inside the list it started in. dnd-kit's collision detection and keyboard coordinates
// weigh every droppable in the context, so a movie could land "over" an episode and an episode over
// a movie: the announcement read a place in the other list, and the drop then did something else or
// nothing. Filtered here, `over` is always a sibling — what is announced is what the drop does.
function ownList(active: Active | null, all: DroppableContainer[]): DroppableContainer[] {
const list = active?.data.current?.sortable?.containerId;
return list === undefined
? all
: all.filter((c) => c.data.current?.sortable?.containerId === list);
}
/** `collisionDetection` for nested sortable lists: `closestCenter` among the active item's siblings. */
export const sameListCollision: CollisionDetection = (args) =>
closestCenter({ ...args, droppableContainers: ownList(args.active, args.droppableContainers) });
/** The keyboard sensor's `coordinateGetter` for nested sortable lists: an arrow key steps to the next
* sibling, never into a neighbouring list. */
export const sameListKeyboardCoordinates: KeyboardCoordinateGetter = (event, args) => {
const all = args.context.droppableContainers;
// The map's own class (dnd-kit does not export it), holding only the siblings.
const Siblings = all.constructor as new (
entries: Iterable<[UniqueIdentifier, DroppableContainer]>,
) => typeof all;
const siblings = new Siblings(
ownList(args.context.active, all.toArray()).map((c) => [c.id, c] as const),
);
return sortableKeyboardCoordinates(event, {
...args,
context: { ...args.context, droppableContainers: siblings },
});
};
/** `useSortable`, with its role announced in the user's language instead of dnd-kit's "sortable". */
export function useSortableItem(args: Parameters<typeof useSortable>[0]) {
const { t } = useTranslation();
return useSortable({
...args,
attributes: { ...args.attributes, roleDescription: t("common.dnd.roleDescription") },
});
}