fix(keys): epic-end review round 2 — focus comes back to the card, the right rail and the reason
Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped), plus the admin refusal copy gap round 1 surfaced. - A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link). - Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists. - The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after new results starts at the first visible item; a menu or dialog opened from the item keeps it. - The list keys reach the cheat sheet only while a list has an item. - A j-focused card root is a named group while it holds the focus. - The channel page's Block and Hide toggles keep the focus while their request runs. - A split export that fails part-way says how many clips were added and deselects them, so a retry adds only the rest. - Config save, the one-click share link and "log out other sessions" report failures with their reason. - VirtualGrid uses the one scroll-parent walk listNav measures against. - Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again"; the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail. HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1.
This commit is contained in:
+58
-11
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { dispatch, useKeyScope } from "./keymap";
|
||||
import { scrollParent } from "./listPaging";
|
||||
|
||||
@@ -35,8 +35,9 @@ interface NavList {
|
||||
}
|
||||
|
||||
const lists = new Map<HTMLElement, NavList>();
|
||||
// The item we gave a tabindex to, so the next move can take it back.
|
||||
// The item we gave a tabindex to, so the next move can take it back — and whether we named it too.
|
||||
let roved: HTMLElement | null = null;
|
||||
let named = false;
|
||||
|
||||
const INDEX = "data-nav-index";
|
||||
const LIST = "data-nav-list";
|
||||
@@ -125,10 +126,17 @@ function firstVisible(): { list: NavList; index: number } | null {
|
||||
let last: { list: NavList; index: number; el: HTMLElement } | null = null;
|
||||
|
||||
function onFocusIn(e: FocusEvent): void {
|
||||
const item = (e.target as HTMLElement | null)?.closest?.<HTMLElement>(`[${INDEX}]`);
|
||||
const target = e.target as HTMLElement | null;
|
||||
const item = target?.closest?.<HTMLElement>(`[${INDEX}]`);
|
||||
const list =
|
||||
item && lists.get(item.parentElement?.closest<HTMLElement>(`[${LIST}]`) as HTMLElement);
|
||||
if (item && list) last = { list, index: indexOf(item), el: item };
|
||||
// Focus went on to page content that is not an item — the search box, a filter chip: the reader
|
||||
// left the list, and whatever it shows next (new results) is not "the slot they were in" (deep-review
|
||||
// E11 round 2). A layer opened FROM the item — its menu, a confirm — does not count: focus comes
|
||||
// back from it, and what it did (a hidden card, a deleted row) is the removal the slot is kept for.
|
||||
// Removing the focused node itself fires no focusin.
|
||||
else if (!target?.closest?.('[data-floating-ui-portal], [role="dialog"]')) last = null;
|
||||
}
|
||||
|
||||
/** Where `j` (`k`: the item before) lands after the last-focused item left the DOM: the item now in
|
||||
@@ -150,13 +158,31 @@ function slotOfRemoved(delta: 1 | -1): { list: NavList; index: number } | null {
|
||||
|
||||
function focusItem(el: HTMLElement, reveal = true): void {
|
||||
if (roved !== el) {
|
||||
if (named) roved?.removeAttribute("role");
|
||||
if (named) roved?.removeAttribute("aria-label");
|
||||
roved?.removeAttribute("tabindex");
|
||||
roved = null;
|
||||
named = false;
|
||||
// An item that is a Tab stop in its own right (a native <button> tile, a role=button card with
|
||||
// tabIndex 0) keeps it: a -1 here would take it out of the Tab order.
|
||||
if (!el.hasAttribute("tabindex") && el.tabIndex < 0) {
|
||||
el.setAttribute("tabindex", "-1");
|
||||
roved = el;
|
||||
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
|
||||
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
|
||||
// opens (deep-review E11 round 2). A row (<tr>) keeps its own role.
|
||||
const open = el.querySelector("[data-nav-open]");
|
||||
const name = (open?.getAttribute("aria-label") || open?.textContent || "").trim();
|
||||
if (
|
||||
el.tagName === "DIV" &&
|
||||
name &&
|
||||
!el.hasAttribute("role") &&
|
||||
!el.hasAttribute("aria-label")
|
||||
) {
|
||||
el.setAttribute("role", "group");
|
||||
el.setAttribute("aria-label", name);
|
||||
named = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
el.focus({ preventScroll: true });
|
||||
@@ -280,19 +306,22 @@ function onKey(e: KeyboardEvent): void {
|
||||
// on screen again. An overlay (the YouTube player) never unmounts the list; it restores focus itself.
|
||||
|
||||
/** Where focus sits in the page's lists: the list's place in reading order, how many lists the page
|
||||
* had on screen, and the item's index. */
|
||||
* had on screen, the list's accessible name when it has one (a rail's title), and the item's index. */
|
||||
export interface NavPosition {
|
||||
readonly list: number;
|
||||
readonly lists: number;
|
||||
readonly label?: string | null;
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
const labelOf = (list: NavList): string | null => list.el.getAttribute("aria-label");
|
||||
|
||||
export function navPosition(): NavPosition | null {
|
||||
const cur = current();
|
||||
if (!cur) return null;
|
||||
const all = ordered();
|
||||
const list = all.indexOf(cur.list);
|
||||
return list < 0 ? null : { list, lists: all.length, index: cur.index };
|
||||
return list < 0 ? null : { list, lists: all.length, label: labelOf(cur.list), index: cur.index };
|
||||
}
|
||||
|
||||
// How long a returning page gets to put its lists back (a lazy chunk, cached data), and how often we
|
||||
@@ -300,10 +329,14 @@ export function navPosition(): NavPosition | null {
|
||||
const RESTORE_MS = 2000;
|
||||
const RESTORE_POLL_MS = 50;
|
||||
|
||||
/** Focus the item at `pos` once the page has as many lists on screen as when it was left — before
|
||||
* that, list N may not yet be the list it was. 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. */
|
||||
/** Focus the item at `pos` once its list is on screen again. A NAMED list (a rail) is found by its
|
||||
* name: a rail that appeared above it in the meantime — "Continue watching", filled by the very title
|
||||
* watched in between, after the page's cache expired — shifts every place after it, and matching by
|
||||
* place landed the focus in the wrong rail (deep-review E11 round 2). An unnamed list only by its
|
||||
* 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
|
||||
* 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. */
|
||||
export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
const deadline = Date.now() + RESTORE_MS;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -311,7 +344,12 @@ export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body) return;
|
||||
const all = ordered();
|
||||
const el = all.length >= pos.lists ? itemAt(all[pos.list]!, pos.index) : null;
|
||||
const list = pos.label
|
||||
? all.find((l) => labelOf(l) === pos.label)
|
||||
: all.length === pos.lists
|
||||
? all[pos.list]
|
||||
: undefined;
|
||||
const el = list ? itemAt(list, pos.index) : null;
|
||||
if (el) return focusItem(el, false);
|
||||
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
|
||||
};
|
||||
@@ -333,7 +371,11 @@ export function useListNav(
|
||||
): void {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
useKeyScope("list", opts.enabled);
|
||||
// The `list` keys do something only while there is an item to land on. An empty queue, a list still
|
||||
// loading or one that failed mounts its container all the same, and the cheat sheet listed j/k, the
|
||||
// arrows and Enter over nothing (keymap: a scope is pushed only while its keys really do something).
|
||||
const [hasItems, setHasItems] = useState(false);
|
||||
useKeyScope("list", opts.enabled && hasItems);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!opts.enabled || !el) return;
|
||||
@@ -344,11 +386,16 @@ export function useListNav(
|
||||
reveal: (i) => optsRef.current.reveal?.(i),
|
||||
});
|
||||
el.setAttribute(LIST, "");
|
||||
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
|
||||
const watch = new MutationObserver(check);
|
||||
watch.observe(el, { childList: true, subtree: true });
|
||||
check();
|
||||
if (lists.size === 1) {
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
}
|
||||
return () => {
|
||||
watch.disconnect();
|
||||
lists.delete(el);
|
||||
el.removeAttribute(LIST);
|
||||
if (lists.size === 0) {
|
||||
|
||||
Reference in New Issue
Block a user