Files
siftlode/frontend/src/components/ui/sortableA11y.ts
T
peter 76358a0d77 refactor(ui): the popover engine and every render-phase ref write compile under the React Compiler
E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.

- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
  a ref, so every component holding one was skipped (21 call sites, the engine and its surface
  among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
  else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
  useSyncExternalStore, which React asks on every render instead of a cached memo.

Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
  the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
  The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
  draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
  inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
  "one writer" rule; the writer comes first now and the effect goes through it.

The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
2026-10-03 23:18:26 +02:00

122 lines
5.8 KiB
TypeScript

import { useMemo } from "react";
import { useLatest } from "../../lib/useLatest";
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 = useLatest(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.
// Per-DRAG state the announcement closures share (a component-lifetime ref would be the wrong
// lifetime): an object owned by this memo, so the closures mutate a property, never a binding.
const drag = { moved: false };
return {
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
announcements: {
onDragStart: ({ active }) => {
drag.moved = false;
return t("common.dnd.pickedUp", at(active.id));
},
onDragOver: ({ active, over }) => {
if (!over || (!drag.moved && over.id === active.id)) return undefined;
// 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.
drag.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, latest]);
}
// 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") },
});
}