fix(ui): Back finds the item again past the focus keeper; nested drags stay in their list
Deep review of the debt burn, each measured in Chrome: - Back from a sub-view whose focused control the swap removed: the focus keeper put the focus on a stand-in (the returning list's first item) and `restoreNavPosition`, finding the focus "already somewhere", gave up — Plex show → Back landed on tile 0, not the tile it was opened from. A stand-in the keeper placed now counts as untouched, like <body>. The keeper's walk up also stops at its container: a slot left with nothing focusable no longer sends the focus to the first control of the whole document. - DataTable enabled both layouts' keyboard lists with the same flag, and `useListNav` reads its ref only when the flag changes: after a resize across `md`, the layout that mounted was never registered (no j/k) and the one that left stayed registered. Each is enabled with its layout. - A Plex playlist nests blocks and each show's episodes in one DndContext; closestCenter and the keyboard coordinates weighed every droppable, so a movie landed "over" an episode — announced as a place in the other list, dropped somewhere else or nowhere. `sameListCollision` and `sameListKeyboardCoordinates` keep a drag among its siblings; onDragEnd's episode → block resolution is gone with the case. - dnd-kit reports the item over itself in the pick-up's own tick; announced, "moved to position 1" replaced "picked up … use the arrow keys" before a screen reader read it.
This commit is contained in:
@@ -6,17 +6,11 @@ import {
|
|||||||
DndContext,
|
DndContext,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
closestCenter,
|
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import {
|
import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
SortableContext,
|
|
||||||
arrayMove,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -40,7 +34,12 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
|||||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
import {
|
||||||
|
sameListCollision,
|
||||||
|
sameListKeyboardCoordinates,
|
||||||
|
useSortableA11y,
|
||||||
|
useSortableItem,
|
||||||
|
} from "./ui/sortableA11y";
|
||||||
|
|
||||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
// 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
|
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||||
@@ -455,7 +454,7 @@ export default function PlexPlaylistView({
|
|||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sameListKeyboardCoordinates }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// A whole block (a movie, a show) sorts among the blocks; an episode only within its own show.
|
// A whole block (a movie, a show) sorts among the blocks; an episode only within its own show.
|
||||||
@@ -481,20 +480,16 @@ export default function PlexPlaylistView({
|
|||||||
const aId = String(active.id);
|
const aId = String(active.id);
|
||||||
const oId = String(over.id);
|
const oId = String(over.id);
|
||||||
const blockIds = new Set(blocks.map((b) => b.id));
|
const blockIds = new Set(blocks.map((b) => b.id));
|
||||||
|
// `over` is always a sibling of `active` (sameListCollision): a block over a block, an episode
|
||||||
|
// over an episode of the same show. The checks below only refuse a stale id.
|
||||||
if (blockIds.has(aId)) {
|
if (blockIds.has(aId)) {
|
||||||
// Moving a whole block (movie or show). Resolve the drop target to a block.
|
// Moving a whole block (movie or show) among the blocks.
|
||||||
let oBlockId = oId;
|
|
||||||
if (!blockIds.has(oId)) {
|
|
||||||
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === oId));
|
|
||||||
if (!b) return;
|
|
||||||
oBlockId = b.id;
|
|
||||||
}
|
|
||||||
const from = blocks.findIndex((b) => b.id === aId);
|
const from = blocks.findIndex((b) => b.id === aId);
|
||||||
const to = blocks.findIndex((b) => b.id === oBlockId);
|
const to = blocks.findIndex((b) => b.id === oId);
|
||||||
if (from < 0 || to < 0) return;
|
if (from < 0 || to < 0) return;
|
||||||
commit(flatten(arrayMove(blocks, from, to)));
|
commit(flatten(arrayMove(blocks, from, to)));
|
||||||
} else {
|
} else {
|
||||||
// Moving an episode — only within its own show block.
|
// Moving an episode — within its own show block.
|
||||||
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
|
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
|
||||||
if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return;
|
if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return;
|
||||||
const from = b.items.findIndex((it) => it.id === aId);
|
const from = b.items.findIndex((it) => it.id === aId);
|
||||||
@@ -600,7 +595,7 @@ export default function PlexPlaylistView({
|
|||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={sameListCollision}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
accessibility={dndA11y}
|
accessibility={dndA11y}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -99,6 +99,36 @@ describe("DataTable · keyboardNav", () => {
|
|||||||
expect(narrow.container.querySelector("table")).toBeNull();
|
expect(narrow.container.querySelector("table")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `useListNav` reads its container when its `enabled` changes. With the flag left on across a
|
||||||
|
// breakpoint, the layout mounted by a resize was never registered — `j` did nothing — while the
|
||||||
|
// unmounted one stayed registered (deep review, debt burn).
|
||||||
|
it("keeps the keys working on whichever layout a resize mounts", () => {
|
||||||
|
let wide = true;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
get matches() {
|
||||||
|
return query === "(min-width: 48rem)" ? wide : false;
|
||||||
|
},
|
||||||
|
media: query,
|
||||||
|
addEventListener: (_: string, l: () => void) => listeners.add(l),
|
||||||
|
removeEventListener: (_: string, l: () => void) => listeners.delete(l),
|
||||||
|
}));
|
||||||
|
const resize = (next: boolean) => {
|
||||||
|
wide = next;
|
||||||
|
act(() => listeners.forEach((l) => l()));
|
||||||
|
};
|
||||||
|
table(() => {});
|
||||||
|
resize(false);
|
||||||
|
expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1);
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("DIV a");
|
||||||
|
resize(true);
|
||||||
|
expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1);
|
||||||
|
(document.activeElement as HTMLElement).blur();
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("TR a");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the rows out of the keyboard without keyboardNav", () => {
|
it("leaves the rows out of the keyboard without keyboardNav", () => {
|
||||||
const { container } = table(() => {}, false);
|
const { container } = table(() => {}, false);
|
||||||
expect(container.querySelector("[data-nav-index]")).toBeNull();
|
expect(container.querySelector("[data-nav-index]")).toBeNull();
|
||||||
|
|||||||
@@ -138,8 +138,11 @@ export default function DataTable<T>({
|
|||||||
const wide = useMediaQuery("(min-width: 48rem)");
|
const wide = useMediaQuery("(min-width: 48rem)");
|
||||||
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
const cardsRef = useRef<HTMLDivElement>(null);
|
const cardsRef = useRef<HTMLDivElement>(null);
|
||||||
useListNav(bodyRef, { enabled: keyboardNav });
|
// Enabled with the layout that is mounted: `useListNav` reads its ref when `enabled` changes, so a
|
||||||
useListNav(cardsRef, { enabled: keyboardNav });
|
// flag that stayed on across a breakpoint left the newly mounted layout unregistered (no j/k) and
|
||||||
|
// the unmounted one registered.
|
||||||
|
useListNav(bodyRef, { enabled: keyboardNav && wide });
|
||||||
|
useListNav(cardsRef, { enabled: keyboardNav && !wide });
|
||||||
|
|
||||||
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
|
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
|
||||||
// latest resolved map so the externalFilter/reset effects can derive from it without taking
|
// latest resolved map so the externalFilter/reset effects can derive from it without taking
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { renderHook } from "@testing-library/react";
|
import { renderHook } from "@testing-library/react";
|
||||||
import type { Active, Over } from "@dnd-kit/core";
|
import type { Active, DroppableContainer, Over, UniqueIdentifier } from "@dnd-kit/core";
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", () => ({
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
@@ -10,7 +10,7 @@ vi.mock("react-i18next", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { useSortableA11y } from "./sortableA11y";
|
import { sameListCollision, sameListKeyboardCoordinates, useSortableA11y } from "./sortableA11y";
|
||||||
|
|
||||||
const rows = ["a", "b", "c", "d"];
|
const rows = ["a", "b", "c", "d"];
|
||||||
const describeRow = (id: string | number) => ({
|
const describeRow = (id: string | number) => ({
|
||||||
@@ -53,3 +53,90 @@ describe("useSortableA11y", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dnd-kit reports the row over ITSELF in the same tick as the pick-up; announced, "moved to position
|
||||||
|
// 1" replaced "picked up … use the arrow keys" before a screen reader read it (measured in Chrome).
|
||||||
|
describe("the pick-up announcement", () => {
|
||||||
|
it("is not overwritten by the row being over its own place, until it has really moved", () => {
|
||||||
|
const { announcements } = renderHook(() => useSortableA11y(describeRow)).result.current;
|
||||||
|
expect(announcements.onDragStart({ active: active("b") })).toBe(
|
||||||
|
"common.dnd.pickedUp:Row b@2/4",
|
||||||
|
);
|
||||||
|
expect(announcements.onDragOver({ active: active("b"), over: over("b") })).toBeUndefined();
|
||||||
|
expect(announcements.onDragOver({ active: active("b"), over: over("c") })).toBe(
|
||||||
|
"common.dnd.movedOver:Row b@3/4",
|
||||||
|
);
|
||||||
|
// Back over its own place after a move IS a move, and says so.
|
||||||
|
expect(announcements.onDragOver({ active: active("b"), over: over("b") })).toBe(
|
||||||
|
"common.dnd.movedOver:Row b@2/4",
|
||||||
|
);
|
||||||
|
// A new drag starts quiet again.
|
||||||
|
announcements.onDragStart({ active: active("a") });
|
||||||
|
expect(announcements.onDragOver({ active: active("a"), over: over("a") })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A Plex playlist nests two sortable lists in one context — its blocks, and each show's episodes.
|
||||||
|
// Unfiltered, a movie could land "over" an episode: the announcement read a place in the other
|
||||||
|
// list, and the drop did something else or nothing (deep review, debt burn).
|
||||||
|
describe("a drag in nested sortable lists", () => {
|
||||||
|
const item = (id: string, list: string, index: number, top: number) => {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
document.body.append(node);
|
||||||
|
return {
|
||||||
|
entry: {
|
||||||
|
id,
|
||||||
|
key: id,
|
||||||
|
disabled: false,
|
||||||
|
node: { current: node },
|
||||||
|
rect: { current: null },
|
||||||
|
data: { current: { sortable: { containerId: list, index, items: [] } } },
|
||||||
|
} as unknown as DroppableContainer,
|
||||||
|
rect: { top, left: 0, width: 100, height: 40, bottom: top + 40, right: 100 },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// Movie A (a block), then an expanded show's first episode just below it, then Movie B.
|
||||||
|
const all = [item("A", "blocks", 0, 0), item("e1", "eps", 0, 50), item("B", "blocks", 1, 200)];
|
||||||
|
const rects = new Map(all.map((d) => [d.entry.id, d.rect]));
|
||||||
|
class Droppables extends Map<UniqueIdentifier, DroppableContainer> {
|
||||||
|
getEnabled() {
|
||||||
|
return [...this.values()].filter((c) => !c.disabled);
|
||||||
|
}
|
||||||
|
toArray() {
|
||||||
|
return [...this.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const droppables = new Droppables(all.map((d) => [d.entry.id, d.entry]));
|
||||||
|
const movieA = { id: "A", data: all[0]!.entry.data, rect: { current: {} } } as unknown as Active;
|
||||||
|
|
||||||
|
it("only ever lands over a sibling in the list the drag started in", () => {
|
||||||
|
const collisions = sameListCollision({
|
||||||
|
active: movieA,
|
||||||
|
collisionRect: { ...all[0]!.rect, top: 45, bottom: 85 },
|
||||||
|
droppableRects: rects,
|
||||||
|
droppableContainers: all.map((d) => d.entry),
|
||||||
|
pointerCoordinates: null,
|
||||||
|
});
|
||||||
|
expect(collisions.map((c) => c.id)).not.toContain("e1");
|
||||||
|
expect(collisions[0]?.id).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steps the keyboard to the next sibling, past the other list's items", () => {
|
||||||
|
const next = sameListKeyboardCoordinates(
|
||||||
|
{ code: "ArrowDown", preventDefault: () => {} } as KeyboardEvent,
|
||||||
|
{
|
||||||
|
active: "A",
|
||||||
|
currentCoordinates: { x: 0, y: 0 },
|
||||||
|
context: {
|
||||||
|
active: movieA,
|
||||||
|
collisionRect: all[0]!.rect,
|
||||||
|
droppableRects: rects,
|
||||||
|
droppableContainers: droppables,
|
||||||
|
over: null,
|
||||||
|
scrollableAncestors: [],
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof sameListKeyboardCoordinates>[1],
|
||||||
|
);
|
||||||
|
expect(next).toEqual({ x: 0, y: 200 }); // Movie B's place, not the episode's at 50
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Announcements, ScreenReaderInstructions, UniqueIdentifier } from "@dnd-kit/core";
|
import { closestCenter } from "@dnd-kit/core";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
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
|
// 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
|
// hard-coded English ("To pick up a draggable item, press the space bar…", "Draggable item 10357
|
||||||
@@ -39,12 +48,22 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
|
|||||||
...at(active),
|
...at(active),
|
||||||
pos: at(over).pos,
|
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 {
|
return {
|
||||||
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
|
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
|
||||||
announcements: {
|
announcements: {
|
||||||
onDragStart: ({ active }) => t("common.dnd.pickedUp", at(active.id)),
|
onDragStart: ({ active }) => {
|
||||||
onDragOver: ({ active, over }) =>
|
moved = false;
|
||||||
over ? t("common.dnd.movedOver", landing(active.id, over.id)) : undefined,
|
return t("common.dnd.pickedUp", at(active.id));
|
||||||
|
},
|
||||||
|
onDragOver: ({ active, over }) => {
|
||||||
|
if (!over || (!moved && over.id === active.id)) return undefined;
|
||||||
|
moved = true;
|
||||||
|
return t("common.dnd.movedOver", landing(active.id, over.id));
|
||||||
|
},
|
||||||
onDragEnd: ({ active, over }) =>
|
onDragEnd: ({ active, over }) =>
|
||||||
over
|
over
|
||||||
? t("common.dnd.dropped", landing(active.id, over.id))
|
? t("common.dnd.dropped", landing(active.id, over.id))
|
||||||
@@ -55,6 +74,39 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
|
|||||||
}, [t]);
|
}, [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". */
|
/** `useSortable`, with its role announced in the user's language instead of dnd-kit's "sortable". */
|
||||||
export function useSortableItem(args: Parameters<typeof useSortable>[0]) {
|
export function useSortableItem(args: Parameters<typeof useSortable>[0]) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { dispatch, useKeyScope } from "./keymap";
|
import { dispatch, useKeyScope } from "./keymap";
|
||||||
import { scrollParent } from "./listPaging";
|
import { scrollParent } from "./listPaging";
|
||||||
|
import { isStandInFocus } from "./useFocusKeeper";
|
||||||
|
|
||||||
// ── Keyboard navigation over lists and grids (E11 S4, UX-6) ──────────────────────────────────────
|
// ── Keyboard navigation over lists and grids (E11 S4, UX-6) ──────────────────────────────────────
|
||||||
// Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so
|
// Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so
|
||||||
@@ -378,13 +379,14 @@ function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
|
|||||||
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
|
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
|
||||||
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
|
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
|
||||||
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
|
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
|
||||||
* where the scroll restore put it, never scrolled to. Returns a cancel. */
|
* where the scroll restore put it, never scrolled to. (A stand-in the focus keeper placed when the view
|
||||||
|
* it held was swapped away counts as untouched, like <body>.) Returns a cancel. */
|
||||||
export function restoreNavPosition(pos: NavPosition): () => void {
|
export function restoreNavPosition(pos: NavPosition): () => void {
|
||||||
const deadline = Date.now() + RESTORE_MS;
|
const deadline = Date.now() + RESTORE_MS;
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const attempt = () => {
|
const attempt = () => {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (active && active !== document.body) return;
|
if (active && active !== document.body && !isStandInFocus(active)) return;
|
||||||
const all = ordered();
|
const all = ordered();
|
||||||
const list = pos.label
|
const list = pos.label
|
||||||
? all.find((l) => labelOf(l) === pos.label)
|
? all.find((l) => labelOf(l) === pos.label)
|
||||||
|
|||||||
@@ -46,6 +46,29 @@ describe("useFocusKeeper", () => {
|
|||||||
expect(document.activeElement).toBe(btn("after the list"));
|
expect(document.activeElement).toBe(btn("after the list"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A page swapped for its loading fallback leaves nothing focusable in the slot. The walk up used to
|
||||||
|
// run past the container and hand the focus to the first control of the whole document.
|
||||||
|
it("never puts the focus outside its own container", async () => {
|
||||||
|
function Swapped() {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useFocusKeeper(ref);
|
||||||
|
const [loaded, setLoaded] = useState(true);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button>rail</button>
|
||||||
|
<div ref={ref}>
|
||||||
|
{loaded ? <button onClick={() => setLoaded(false)}>go</button> : <p>Loading…</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Swapped />);
|
||||||
|
btn("go").focus();
|
||||||
|
act(() => btn("go").click());
|
||||||
|
await flush();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves a keyboard list's rows to listNav", async () => {
|
it("leaves a keyboard list's rows to listNav", async () => {
|
||||||
render(<Rows nav />);
|
render(<Rows nav />);
|
||||||
btn("approve b").focus();
|
btn("approve b").focus();
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Back from a sub-view the focus keeper ALSO sees: the control that held the focus in the sub-view
|
||||||
|
// is removed by the swap, the keeper puts the focus on a stand-in in its slot — the first item of
|
||||||
|
// the returning list — and `restoreNavPosition` then found the focus "already somewhere" and gave
|
||||||
|
// up. Back landed on the first tile instead of the one the view was opened from (measured in Chrome,
|
||||||
|
// deep review of the debt burn). Its own file: listNav keeps module-level state.
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { navItemAttrs, navPosition, restoreNavPosition, useListNav } from "./listNav";
|
||||||
|
import { useFocusKeeper } from "./useFocusKeeper";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function Grid() {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useListNav(ref, { enabled: true });
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
{["a", "b", "c"].map((t, i) => (
|
||||||
|
<div key={t} {...navItemAttrs(i, t)}>
|
||||||
|
<button data-nav-open="">{t}</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let show: (v: "grid" | "detail") => void = () => {};
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
useFocusKeeper(ref);
|
||||||
|
const [view, setView] = useState<"grid" | "detail">("grid");
|
||||||
|
show = setView;
|
||||||
|
return <main ref={ref}>{view === "grid" ? <Grid /> : <button>Back to library</button>}</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = (name: string) =>
|
||||||
|
[...document.querySelectorAll("button")].find((b) => b.textContent === name)!;
|
||||||
|
const wait = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
|
||||||
|
|
||||||
|
describe("Back from a sub-view whose held control the swap removed", () => {
|
||||||
|
it("returns the focus to the item the view was opened from, not the keeper's stand-in", async () => {
|
||||||
|
render(<Page />);
|
||||||
|
button("c").focus();
|
||||||
|
const pos = navPosition()!;
|
||||||
|
expect(pos.index).toBe(2);
|
||||||
|
|
||||||
|
act(() => show("detail"));
|
||||||
|
button("Back to library").focus(); // a control the keeper holds
|
||||||
|
|
||||||
|
act(() => show("grid")); // popstate's view swap…
|
||||||
|
const cancel = restoreNavPosition(pos); // …and its restore
|
||||||
|
await wait(120);
|
||||||
|
cancel();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.activeElement?.closest("[data-nav-index]")?.getAttribute("data-nav-index"),
|
||||||
|
).toBe("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,8 +29,22 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page. */
|
// The element the keeper last put the focus on, while it still holds it. A Back that swaps the page
|
||||||
function slotFor(record: MutationRecord, lost: Node): HTMLElement | null {
|
// or a sub-view removes the held control too, and the keeper's stand-in then sat where
|
||||||
|
// `restoreNavPosition` looks for an untouched <body>: the restore gave up and Back left the focus on
|
||||||
|
// the first tile instead of the one the view was opened from (measured in Chrome). A stand-in is
|
||||||
|
// not the reader's choice, so the restore may take the focus from it.
|
||||||
|
let standIn: HTMLElement | null = null;
|
||||||
|
|
||||||
|
/** Whether `el` holds the focus only because the keeper put it there. */
|
||||||
|
export function isStandInFocus(el: Element | null): boolean {
|
||||||
|
return el !== null && el === standIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page — never
|
||||||
|
* outside `root`: a slot with nothing focusable left (a page swapped for its loading fallback) must
|
||||||
|
* not send the focus to the first control of the whole document. */
|
||||||
|
function slotFor(record: MutationRecord, lost: Node, root: HTMLElement): HTMLElement | null {
|
||||||
const removed = [...record.removedNodes].find((n) => n === lost || n.contains(lost));
|
const removed = [...record.removedNodes].find((n) => n === lost || n.contains(lost));
|
||||||
if (!removed) return null;
|
if (!removed) return null;
|
||||||
const after = firstFocusable(record.nextSibling);
|
const after = firstFocusable(record.nextSibling);
|
||||||
@@ -38,7 +52,7 @@ function slotFor(record: MutationRecord, lost: Node): HTMLElement | null {
|
|||||||
const before = firstFocusable(record.previousSibling, true);
|
const before = firstFocusable(record.previousSibling, true);
|
||||||
if (before) return before;
|
if (before) return before;
|
||||||
let up: Node | null = record.target;
|
let up: Node | null = record.target;
|
||||||
while (up && up instanceof HTMLElement) {
|
while (up && up instanceof HTMLElement && root.contains(up)) {
|
||||||
const inside = firstFocusable(up.firstChild);
|
const inside = firstFocusable(up.firstChild);
|
||||||
if (inside) return inside;
|
if (inside) return inside;
|
||||||
up = up.parentElement;
|
up = up.parentElement;
|
||||||
@@ -53,6 +67,7 @@ export function useFocusKeeper(ref: RefObject<HTMLElement | null>): void {
|
|||||||
let held: HTMLElement | null = null;
|
let held: HTMLElement | null = null;
|
||||||
const onFocusIn = (e: FocusEvent) => {
|
const onFocusIn = (e: FocusEvent) => {
|
||||||
const t = e.target as HTMLElement | null;
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (t !== standIn) standIn = null;
|
||||||
held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null;
|
held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null;
|
||||||
};
|
};
|
||||||
const watch = new MutationObserver((records) => {
|
const watch = new MutationObserver((records) => {
|
||||||
@@ -62,8 +77,9 @@ export function useFocusKeeper(ref: RefObject<HTMLElement | null>): void {
|
|||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (active && active !== document.body) return; // something already took the focus
|
if (active && active !== document.body) return; // something already took the focus
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
const target = slotFor(r, lost);
|
const target = slotFor(r, lost, root);
|
||||||
if (target) {
|
if (target) {
|
||||||
|
standIn = target;
|
||||||
target.focus({ preventScroll: true });
|
target.focus({ preventScroll: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user