fix(a11y): chips, grips, the panel tab and Back to top keep the focus somewhere real
- F5: a chip's × pressed from the keyboard hands the focus to the next chip (else the previous one, else what follows) before it unmounts — in DismissibleChip, so all five chip rows get it (`focusNeighbour` in lib/useFocusKeeper). - F4: a drag grip names what it lifts (`common.order.dragNamed`: panel groups, personal views); the two generic "Drag to reorder" keys are gone. - F6: the side panel's edge tab is out of the Tab order while the card covers it, names its panel, and a keyboard pin moves the focus into the card. - F7: Back to top is no Tab stop while hidden, and a keyboard press leaves the focus on the page.
This commit is contained in:
@@ -19,7 +19,13 @@ const h = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, o?: { name?: string }) =>
|
||||||
|
key === "common.order.dragNamed" ? `${key}:${o?.name}` : key,
|
||||||
|
}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
|
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
|
||||||
@@ -225,7 +231,7 @@ describe("PersonalViewsModal — reordering without a drag (WCAG 2.5.7)", () =>
|
|||||||
it("picks a view up from the keyboard, as its grip's instructions say", async () => {
|
it("picks a view up from the keyboard, as its grip's instructions say", async () => {
|
||||||
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
|
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
|
||||||
mount();
|
mount();
|
||||||
const grip = (await screen.findAllByRole("button", { name: "views.reorder" }))[0]!;
|
const grip = await screen.findByRole("button", { name: "common.order.dragNamed:Docs" });
|
||||||
grip.focus();
|
grip.focus();
|
||||||
fireEvent.keyDown(grip, { code: "Space", key: " " });
|
fireEvent.keyDown(grip, { code: "Space", key: " " });
|
||||||
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
|
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ function ViewRow({
|
|||||||
selected ? "bg-accent/15" : "state-layer"
|
selected ? "bg-accent/15" : "state-layer"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<DragHandle label={t("views.reorder")} {...attributes} {...listeners} />
|
<DragHandle
|
||||||
|
label={t("common.order.dragNamed", { name: view.name })}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
selectable
|
selectable
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E28 review F7: while hidden the button only went transparent — a Tab still landed on the invisible
|
||||||
|
// control. And a keyboard press scrolled to the top and hid it with the focus still on it.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import BackToTop from "./BackToTop";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function scrolled(main: HTMLElement, top: number) {
|
||||||
|
main.scrollTop = top;
|
||||||
|
act(() => void main.dispatchEvent(new Event("scroll")));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackToTop", () => {
|
||||||
|
it("is no Tab stop while hidden; a keyboard press leaves the focus on the page", () => {
|
||||||
|
const main = document.createElement("main");
|
||||||
|
main.tabIndex = -1;
|
||||||
|
main.scrollTo = vi.fn();
|
||||||
|
document.body.appendChild(main);
|
||||||
|
render(<BackToTop />);
|
||||||
|
const btn = screen.getByText("common.backToTop").closest("button")!;
|
||||||
|
expect(btn.tabIndex).toBe(-1);
|
||||||
|
scrolled(main, 900);
|
||||||
|
expect(btn.tabIndex).toBe(0);
|
||||||
|
btn.focus();
|
||||||
|
fireEvent.click(btn, { detail: 0 });
|
||||||
|
expect(main.scrollTo).toHaveBeenCalled();
|
||||||
|
expect(document.activeElement).toBe(main);
|
||||||
|
main.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,11 +42,13 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
|||||||
setShow(!!el && el.scrollTop > threshold);
|
setShow(!!el && el.scrollTop > threshold);
|
||||||
}, [dep, threshold]);
|
}, [dep, threshold]);
|
||||||
|
|
||||||
const toTop = () =>
|
// A keyboard press hides the button it was on: the focus goes to the page it scrolled (its <main>
|
||||||
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({
|
// is a focus rest, not a Tab stop), not left on an invisible control (E28 review F7).
|
||||||
top: 0,
|
const toTop = (e: React.MouseEvent) => {
|
||||||
behavior: "smooth",
|
const main = scrollerRef.current ?? document.querySelector("main");
|
||||||
});
|
main?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
if (e.detail === 0) main?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
|
||||||
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
|
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
|
||||||
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
|
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
|
||||||
@@ -57,6 +59,9 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
|||||||
<Pressable
|
<Pressable
|
||||||
align="center"
|
align="center"
|
||||||
onClick={toTop}
|
onClick={toTop}
|
||||||
|
// Hidden, it only goes transparent (so it can fade): out of the Tab order as well.
|
||||||
|
tabIndex={show ? undefined : -1}
|
||||||
|
aria-hidden={show ? undefined : true}
|
||||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${
|
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${
|
||||||
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { FocusSafeButton } from "./FocusSafeButton";
|
import { FocusSafeButton } from "./FocusSafeButton";
|
||||||
|
import { focusNeighbour } from "../../lib/useFocusKeeper";
|
||||||
import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles";
|
import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles";
|
||||||
|
|
||||||
// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that
|
// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that
|
||||||
@@ -113,7 +114,14 @@ export function DismissibleChip({
|
|||||||
)}
|
)}
|
||||||
<FocusSafeButton
|
<FocusSafeButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDismiss}
|
onClick={(e) => {
|
||||||
|
// From the keyboard the × holds the focus, and the chip unmounts: hand the focus to the
|
||||||
|
// next chip (else the previous one, else what follows the row) first, not to <body> (E28
|
||||||
|
// review F5). A mouse press on it leaves the focus where it was.
|
||||||
|
const chip = e.currentTarget.parentElement;
|
||||||
|
if (chip && e.currentTarget === document.activeElement) focusNeighbour(chip);
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
aria-label={dismissLabel}
|
aria-label={dismissLabel}
|
||||||
title={dismissLabel}
|
title={dismissLabel}
|
||||||
className="-my-px grid size-6 shrink-0 place-items-center rounded-full transition state-layer touch-target"
|
className="-my-px grid size-6 shrink-0 place-items-center rounded-full transition state-layer touch-target"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
import { FloatingTree } from "@floating-ui/react";
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -278,6 +279,28 @@ describe("Combobox", () => {
|
|||||||
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
|
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
|
||||||
// E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a
|
// E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a
|
||||||
// picker whose pick was still being written took another pick.
|
// picker whose pick was still being written took another pick.
|
||||||
|
// E28 review F5: a chip's × pressed from the keyboard unmounted with the focus on it — to <body>,
|
||||||
|
// out of the picker. The focus goes to the next chip's × (else the previous one, else the field).
|
||||||
|
describe("Combobox · removing a chip from the keyboard", () => {
|
||||||
|
function Harness({ initial }: { initial: string[] }) {
|
||||||
|
const [sel, setSel] = useState(initial);
|
||||||
|
return <Combobox label="Genre" options={OPTIONS} selected={sel} onChange={setSel} />;
|
||||||
|
}
|
||||||
|
const x = (name: string) =>
|
||||||
|
screen
|
||||||
|
.getAllByRole("button", { name: "common.removeNamed" })
|
||||||
|
.find((b) => b.closest("span")?.textContent?.includes(name))!;
|
||||||
|
|
||||||
|
it("hands the focus to the neighbour chip, then to the field", () => {
|
||||||
|
wrap(<Harness initial={["Drama", "Action"]} />);
|
||||||
|
x("Drama").focus();
|
||||||
|
fireEvent.click(x("Drama"), { detail: 0 });
|
||||||
|
expect(document.activeElement).toBe(x("Action"));
|
||||||
|
fireEvent.click(x("Action"), { detail: 0 });
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("combobox"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Combobox · honest states", () => {
|
describe("Combobox · honest states", () => {
|
||||||
it("says the list failed to load, not that nothing matched", () => {
|
it("says the list failed to load, not that nothing matched", () => {
|
||||||
wrap(
|
wrap(
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function PanelGroup({
|
|||||||
<DragHandle
|
<DragHandle
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
label={t("sidebar.dragToReorder")}
|
label={t("common.order.dragNamed", { name: title })}
|
||||||
className="-ml-1"
|
className="-ml-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { render, screen, fireEvent, cleanup, within } from "@testing-library/rea
|
|||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key),
|
||||||
|
}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
@@ -56,3 +61,12 @@ describe("PanelGroups — reordering without a drag", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "common.order.up" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "common.order.up" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E28 review F4: every group's grip was "Drag to reorder" — a screen reader heard the same name five
|
||||||
|
// times and could not tell which group it would lift. It names its group, as the playlist grips do.
|
||||||
|
describe("PanelGroups — the drag grip", () => {
|
||||||
|
it("is named by its group", () => {
|
||||||
|
mount(["a", "b", "c"]);
|
||||||
|
expect(screen.getByRole("button", { name: "common.order.dragNamed:Group b" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,12 +78,31 @@ describe("SidePanel · a mouse on a wide window", () => {
|
|||||||
it("pins from the tab, as before", () => {
|
it("pins from the tab, as before", () => {
|
||||||
stubMedia({ coarse: false, narrow: false, wide: true });
|
stubMedia({ coarse: false, narrow: false, wide: true });
|
||||||
const { onToggleCollapse } = mount();
|
const { onToggleCollapse } = mount();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" }));
|
fireEvent.click(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }));
|
||||||
expect(onToggleCollapse).toHaveBeenCalledOnce();
|
expect(onToggleCollapse).toHaveBeenCalledOnce();
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E28 review F6: the pinned (or peeking) card covers the tab, which only goes transparent — yet it
|
||||||
|
// stayed a Tab stop, an invisible control the focus landed on, named just "Unpin panel".
|
||||||
|
describe("SidePanel · the tab under an open card", () => {
|
||||||
|
it("is out of the Tab order while the card is open, and names its panel", () => {
|
||||||
|
stubMedia({ coarse: false, narrow: false, wide: true });
|
||||||
|
globalThis.ResizeObserver ??= class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
mount(false); // pinned
|
||||||
|
const tab = screen.getByRole("button", { name: "sidebar.unpinNamed:Filters", hidden: true });
|
||||||
|
expect(tab.tabIndex).toBe(-1);
|
||||||
|
cleanup();
|
||||||
|
mount(true);
|
||||||
|
expect(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }).tabIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("SidePanel · on touch", () => {
|
describe("SidePanel · on touch", () => {
|
||||||
it("opens a drawer from the tab instead of pinning, and Esc closes it", () => {
|
it("opens a drawer from the tab instead of pinning, and Esc closes it", () => {
|
||||||
stubMedia({ coarse: true, narrow: false, wide: false });
|
stubMedia({ coarse: true, narrow: false, wide: false });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
||||||
import { useScrollFade } from "../../lib/useScrollFade";
|
import { useScrollFade } from "../../lib/useScrollFade";
|
||||||
@@ -64,6 +64,7 @@ export default function SidePanel({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const cardRef = useRef<HTMLElement>(null);
|
||||||
const fade = useScrollFade();
|
const fade = useScrollFade();
|
||||||
const peek = useHoverFocusWithin();
|
const peek = useHoverFocusWithin();
|
||||||
const drawerMode = useMediaQuery(DRAWER_QUERY, false);
|
const drawerMode = useMediaQuery(DRAWER_QUERY, false);
|
||||||
@@ -143,16 +144,29 @@ export default function SidePanel({
|
|||||||
hover that opened the card, and unmounting it while focused would throw keyboard focus to
|
hover that opened the card, and unmounting it while focused would throw keyboard focus to
|
||||||
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
|
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
|
||||||
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
|
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
|
||||||
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
|
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable, and a focus already on it stays.
|
||||||
|
But no Tab lands on it while the card covers it (E28 review F6): an invisible stop, where the
|
||||||
|
card's own pin says the same. A keyboard pin hands the focus into the card it opened. */}
|
||||||
<Pressable
|
<Pressable
|
||||||
{...(drawerMode ? {} : peek.hoverHandlers)}
|
{...(drawerMode ? {} : peek.hoverHandlers)}
|
||||||
onClick={drawerMode ? () => setDrawerOpen(true) : onToggleCollapse}
|
onClick={
|
||||||
|
drawerMode
|
||||||
|
? () => setDrawerOpen(true)
|
||||||
|
: (e) => {
|
||||||
|
onToggleCollapse();
|
||||||
|
if (e.detail === 0)
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
cardRef.current?.querySelector<HTMLElement>("button")?.focus(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tabIndex={expanded ? -1 : undefined}
|
||||||
aria-label={
|
aria-label={
|
||||||
drawerMode
|
drawerMode
|
||||||
? t("sidebar.openPanel", { title })
|
? t("sidebar.openPanel", { title })
|
||||||
: pinned
|
: pinned
|
||||||
? t("sidebar.unpin")
|
? t("sidebar.unpinNamed", { title })
|
||||||
: t("sidebar.pin")
|
: t("sidebar.pinNamed", { title })
|
||||||
}
|
}
|
||||||
aria-haspopup={drawerMode ? "dialog" : undefined}
|
aria-haspopup={drawerMode ? "dialog" : undefined}
|
||||||
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 pointer-coarse:w-11 transition-opacity ${
|
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 pointer-coarse:w-11 transition-opacity ${
|
||||||
@@ -175,6 +189,7 @@ export default function SidePanel({
|
|||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<aside
|
<aside
|
||||||
|
ref={cardRef}
|
||||||
{...peek.hoverHandlers}
|
{...peek.hoverHandlers}
|
||||||
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
|
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
"top": "Move to top",
|
"top": "Move to top",
|
||||||
"up": "Move up",
|
"up": "Move up",
|
||||||
"down": "Move down",
|
"down": "Move down",
|
||||||
"bottom": "Move to bottom"
|
"bottom": "Move to bottom",
|
||||||
|
"dragNamed": "Reorder “{{name}}”"
|
||||||
},
|
},
|
||||||
"copy": {
|
"copy": {
|
||||||
"title": "Copy title",
|
"title": "Copy title",
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
"customize": "Customize sidebar",
|
"customize": "Customize sidebar",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"editHint": "Drag or use the arrows to reorder · eye to show/hide",
|
"editHint": "Drag or use the arrows to reorder · eye to show/hide",
|
||||||
"dragToReorder": "Drag to reorder",
|
|
||||||
"showWidget": "Show widget",
|
"showWidget": "Show widget",
|
||||||
"hideWidget": "Hide widget",
|
"hideWidget": "Hide widget",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"pin": "Pin panel open",
|
"pin": "Pin panel open",
|
||||||
"unpin": "Unpin panel",
|
"unpin": "Unpin panel",
|
||||||
|
"pinNamed": "Pin {{title}} open",
|
||||||
|
"unpinNamed": "Unpin {{title}}",
|
||||||
"openPanel": "Open {{title}}",
|
"openPanel": "Open {{title}}",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
||||||
"reorder": "Drag to reorder",
|
|
||||||
"actionFailed": "Couldn't update your saved views — please try again",
|
"actionFailed": "Couldn't update your saved views — please try again",
|
||||||
"reorderFailed": "Couldn't save the new order — please try again",
|
"reorderFailed": "Couldn't save the new order — please try again",
|
||||||
"saved": "Saved views",
|
"saved": "Saved views",
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
"top": "Legfelülre",
|
"top": "Legfelülre",
|
||||||
"up": "Eggyel feljebb",
|
"up": "Eggyel feljebb",
|
||||||
"down": "Eggyel lejjebb",
|
"down": "Eggyel lejjebb",
|
||||||
"bottom": "Legalulra"
|
"bottom": "Legalulra",
|
||||||
|
"dragNamed": "„{{name}}” átrendezése"
|
||||||
},
|
},
|
||||||
"copy": {
|
"copy": {
|
||||||
"title": "Cím másolása",
|
"title": "Cím másolása",
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
"customize": "Oldalsáv testreszabása",
|
"customize": "Oldalsáv testreszabása",
|
||||||
"done": "Kész",
|
"done": "Kész",
|
||||||
"editHint": "Húzd vagy a nyilakkal rendezd át · szem a megjelenítéshez/elrejtéshez",
|
"editHint": "Húzd vagy a nyilakkal rendezd át · szem a megjelenítéshez/elrejtéshez",
|
||||||
"dragToReorder": "Húzd az átrendezéshez",
|
|
||||||
"showWidget": "Modul megjelenítése",
|
"showWidget": "Modul megjelenítése",
|
||||||
"hideWidget": "Modul elrejtése",
|
"hideWidget": "Modul elrejtése",
|
||||||
"expand": "Kibontás",
|
"expand": "Kibontás",
|
||||||
"collapse": "Összecsukás",
|
"collapse": "Összecsukás",
|
||||||
"pin": "Panel rögzítése nyitva",
|
"pin": "Panel rögzítése nyitva",
|
||||||
"unpin": "Panel rögzítésének feloldása",
|
"unpin": "Panel rögzítésének feloldása",
|
||||||
|
"pinNamed": "{{title}} rögzítése nyitva",
|
||||||
|
"unpinNamed": "{{title}} rögzítésének feloldása",
|
||||||
"openPanel": "{{title}} megnyitása",
|
"openPanel": "{{title}} megnyitása",
|
||||||
"from": "Ettől",
|
"from": "Ettől",
|
||||||
"to": "Eddig",
|
"to": "Eddig",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
||||||
"reorder": "Húzd az átrendezéshez",
|
|
||||||
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra",
|
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra",
|
||||||
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra",
|
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra",
|
||||||
"saved": "Mentett nézetek",
|
"saved": "Mentett nézetek",
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move the focus off `el`, which is about to go, to its nearest focusable neighbour — the next one,
|
||||||
|
* else the previous one, widening to the parent's neighbours — so its removal does not drop the
|
||||||
|
* focus to <body>. For a removal the caller makes itself (a chip's ×) anywhere, including a modal
|
||||||
|
* or a side panel, where the keeper below does not watch. False when nothing was found. */
|
||||||
|
export function focusNeighbour(el: HTMLElement): boolean {
|
||||||
|
for (let at: HTMLElement | null = el; at && at !== document.body; at = at.parentElement) {
|
||||||
|
const hit = firstFocusable(at.nextSibling) ?? firstFocusable(at.previousSibling, true);
|
||||||
|
if (hit) {
|
||||||
|
hit.focus({ preventScroll: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// The element the keeper last put the focus on, while it still holds it. A Back that swaps the page
|
// 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
|
// 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
|
// `restoreNavPosition` looks for an untouched <body>: the restore gave up and Back left the focus on
|
||||||
|
|||||||
Reference in New Issue
Block a user