From fa4f823fc8e77ea7a1ed1c47a646dec742c19f36 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 19:19:43 +0200 Subject: [PATCH] fix(ui): Back finds the item again past the focus keeper; nested drags stay in their list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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. --- frontend/src/components/PlexPlaylistView.tsx | 33 +++---- .../ui/DataTable.keyboard.dom.test.tsx | 30 ++++++ frontend/src/components/ui/DataTable.tsx | 7 +- .../components/ui/sortableA11y.dom.test.tsx | 91 ++++++++++++++++++- frontend/src/components/ui/sortableA11y.ts | 62 ++++++++++++- frontend/src/lib/listNav.ts | 6 +- frontend/src/lib/useFocusKeeper.dom.test.tsx | 23 +++++ .../lib/useFocusKeeper.restore.dom.test.tsx | 64 +++++++++++++ frontend/src/lib/useFocusKeeper.ts | 24 ++++- 9 files changed, 306 insertions(+), 34 deletions(-) create mode 100644 frontend/src/lib/useFocusKeeper.restore.dom.test.tsx diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index cf29bac9..3290d90f 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -6,17 +6,11 @@ import { DndContext, KeyboardSensor, PointerSensor, - closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; -import { - SortableContext, - arrayMove, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { ArrowLeft, @@ -40,7 +34,12 @@ 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"; +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 // 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( 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. @@ -481,20 +480,16 @@ export default function PlexPlaylistView({ const aId = String(active.id); const oId = String(over.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)) { - // Moving a whole block (movie or show). Resolve the drop target to a block. - 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; - } + // Moving a whole block (movie or show) among the blocks. 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; commit(flatten(arrayMove(blocks, from, to))); } 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)); if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return; const from = b.items.findIndex((it) => it.id === aId); @@ -600,7 +595,7 @@ export default function PlexPlaylistView({ ) : ( diff --git a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx index 643f3a11..e515ed9f 100644 --- a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx @@ -99,6 +99,36 @@ describe("DataTable · keyboardNav", () => { 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", () => { const { container } = table(() => {}, false); expect(container.querySelector("[data-nav-index]")).toBeNull(); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index 910b02e8..d13e2ba3 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -138,8 +138,11 @@ export default function DataTable({ const wide = useMediaQuery("(min-width: 48rem)"); const bodyRef = useRef(null); const cardsRef = useRef(null); - useListNav(bodyRef, { enabled: keyboardNav }); - useListNav(cardsRef, { enabled: keyboardNav }); + // Enabled with the layout that is mounted: `useListNav` reads its ref when `enabled` changes, so a + // 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 // latest resolved map so the externalFilter/reset effects can derive from it without taking diff --git a/frontend/src/components/ui/sortableA11y.dom.test.tsx b/frontend/src/components/ui/sortableA11y.dom.test.tsx index bb59587a..bd9985ec 100644 --- a/frontend/src/components/ui/sortableA11y.dom.test.tsx +++ b/frontend/src/components/ui/sortableA11y.dom.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, vi } from "vitest"; 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", () => ({ 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 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 { + 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[1], + ); + expect(next).toEqual({ x: 0, y: 200 }); // Movie B's place, not the episode's at 50 + }); +}); diff --git a/frontend/src/components/ui/sortableA11y.ts b/frontend/src/components/ui/sortableA11y.ts index 158a9534..cf8b817b 100644 --- a/frontend/src/components/ui/sortableA11y.ts +++ b/frontend/src/components/ui/sortableA11y.ts @@ -1,7 +1,16 @@ 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"; +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 @@ -39,12 +48,22 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj ...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 }) => t("common.dnd.pickedUp", at(active.id)), - onDragOver: ({ active, over }) => - over ? t("common.dnd.movedOver", landing(active.id, over.id)) : undefined, + 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 = true; + return t("common.dnd.movedOver", landing(active.id, over.id)); + }, onDragEnd: ({ active, over }) => over ? t("common.dnd.dropped", landing(active.id, over.id)) @@ -55,6 +74,39 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj }, [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[0]) { const { t } = useTranslation(); diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index ba83e26a..d9e4a53e 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type RefObject } from "react"; import { dispatch, useKeyScope } from "./keymap"; import { scrollParent } from "./listPaging"; +import { isStandInFocus } from "./useFocusKeeper"; // ── 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 @@ -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, * list N may be another list, and 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 - * 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 .) Returns a cancel. */ export function restoreNavPosition(pos: NavPosition): () => void { const deadline = Date.now() + RESTORE_MS; let timer: ReturnType | undefined; const attempt = () => { const active = document.activeElement; - if (active && active !== document.body) return; + if (active && active !== document.body && !isStandInFocus(active)) return; const all = ordered(); const list = pos.label ? all.find((l) => labelOf(l) === pos.label) diff --git a/frontend/src/lib/useFocusKeeper.dom.test.tsx b/frontend/src/lib/useFocusKeeper.dom.test.tsx index 7bdf765b..5602a22e 100644 --- a/frontend/src/lib/useFocusKeeper.dom.test.tsx +++ b/frontend/src/lib/useFocusKeeper.dom.test.tsx @@ -46,6 +46,29 @@ describe("useFocusKeeper", () => { 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(null); + useFocusKeeper(ref); + const [loaded, setLoaded] = useState(true); + return ( + <> + +
+ {loaded ? :

Loading…

} +
+ + ); + } + render(); + 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 () => { render(); btn("approve b").focus(); diff --git a/frontend/src/lib/useFocusKeeper.restore.dom.test.tsx b/frontend/src/lib/useFocusKeeper.restore.dom.test.tsx new file mode 100644 index 00000000..3476c4af --- /dev/null +++ b/frontend/src/lib/useFocusKeeper.restore.dom.test.tsx @@ -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(null); + useListNav(ref, { enabled: true }); + return ( +
+ {["a", "b", "c"].map((t, i) => ( +
+ +
+ ))} +
+ ); +} + +let show: (v: "grid" | "detail") => void = () => {}; + +function Page() { + const ref = useRef(null); + useFocusKeeper(ref); + const [view, setView] = useState<"grid" | "detail">("grid"); + show = setView; + return
{view === "grid" ? : }
; +} + +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(); + 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"); + }); +}); diff --git a/frontend/src/lib/useFocusKeeper.ts b/frontend/src/lib/useFocusKeeper.ts index 86c30e55..bf4b9ec8 100644 --- a/frontend/src/lib/useFocusKeeper.ts +++ b/frontend/src/lib/useFocusKeeper.ts @@ -29,8 +29,22 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null return null; } -/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page. */ -function slotFor(record: MutationRecord, lost: Node): HTMLElement | null { +// The element the keeper last put the focus on, while it still holds it. A Back that swaps the page +// or a sub-view removes the held control too, and the keeper's stand-in then sat where +// `restoreNavPosition` looks for an untouched : 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)); if (!removed) return null; const after = firstFocusable(record.nextSibling); @@ -38,7 +52,7 @@ function slotFor(record: MutationRecord, lost: Node): HTMLElement | null { const before = firstFocusable(record.previousSibling, true); if (before) return before; let up: Node | null = record.target; - while (up && up instanceof HTMLElement) { + while (up && up instanceof HTMLElement && root.contains(up)) { const inside = firstFocusable(up.firstChild); if (inside) return inside; up = up.parentElement; @@ -53,6 +67,7 @@ export function useFocusKeeper(ref: RefObject): void { let held: HTMLElement | null = null; const onFocusIn = (e: FocusEvent) => { const t = e.target as HTMLElement | null; + if (t !== standIn) standIn = null; held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null; }; const watch = new MutationObserver((records) => { @@ -62,8 +77,9 @@ export function useFocusKeeper(ref: RefObject): void { const active = document.activeElement; if (active && active !== document.body) return; // something already took the focus for (const r of records) { - const target = slotFor(r, lost); + const target = slotFor(r, lost, root); if (target) { + standIn = target; target.focus({ preventScroll: true }); return; }