Compare commits
1
Commits
98945ceb7e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b0ec8ad8 |
@@ -1,10 +1,93 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
let lastInteractedFocusable: HTMLElement | null = null;
|
||||||
|
let interactionTrackingBound = false;
|
||||||
|
|
||||||
|
function closestFocusable(target: EventTarget | null): HTMLElement | null {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
const match = target.closest(FOCUSABLE_SELECTOR);
|
||||||
|
return match instanceof HTMLElement ? match : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu items unmount on select; restore to the control that opened the menu. */
|
||||||
|
function menuTriggerFor(item: HTMLElement): HTMLElement | null {
|
||||||
|
if (item.getAttribute("role") !== "menuitem") return null;
|
||||||
|
const menu = item.closest("[role='menu']");
|
||||||
|
const menuId = menu?.getAttribute("id");
|
||||||
|
if (menuId) {
|
||||||
|
const trigger = document.querySelector(
|
||||||
|
`[aria-controls="${CSS.escape(menuId)}"]`,
|
||||||
|
);
|
||||||
|
if (trigger instanceof HTMLElement) return trigger;
|
||||||
|
}
|
||||||
|
const expanded = document.querySelector(
|
||||||
|
'[aria-haspopup="menu"][aria-expanded="true"]',
|
||||||
|
);
|
||||||
|
return expanded instanceof HTMLElement ? expanded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableFocusableFrom(target: EventTarget | null): HTMLElement | null {
|
||||||
|
const focusable = closestFocusable(target);
|
||||||
|
if (!focusable) return null;
|
||||||
|
return menuTriggerFor(focusable) ?? focusable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRestorable(
|
||||||
|
node: HTMLElement | null,
|
||||||
|
dialog: HTMLElement | null,
|
||||||
|
): node is HTMLElement {
|
||||||
|
if (!node?.isConnected) return false;
|
||||||
|
if (node === document.body || node === document.documentElement) return false;
|
||||||
|
if (dialog?.contains(node)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retainLastInteracted(): void {
|
||||||
|
if (interactionTrackingBound || typeof document === "undefined") return;
|
||||||
|
interactionTrackingBound = true;
|
||||||
|
const save = (event: Event) => {
|
||||||
|
const el = stableFocusableFrom(event.target);
|
||||||
|
if (!el) return;
|
||||||
|
lastInteractedFocusable = el;
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", save, true);
|
||||||
|
document.addEventListener("focusin", save);
|
||||||
|
}
|
||||||
|
|
||||||
|
retainLastInteracted();
|
||||||
|
|
||||||
|
function snapshotTrigger(dialog: HTMLElement | null): HTMLElement | null {
|
||||||
|
if (lastInteractedFocusable && !lastInteractedFocusable.isConnected) {
|
||||||
|
lastInteractedFocusable = null;
|
||||||
|
}
|
||||||
|
const active =
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
const fromActive = isRestorable(active, dialog)
|
||||||
|
? (menuTriggerFor(active) ?? active)
|
||||||
|
: null;
|
||||||
|
if (fromActive && isRestorable(fromActive, dialog)) return fromActive;
|
||||||
|
if (isRestorable(lastInteractedFocusable, dialog)) {
|
||||||
|
return lastInteractedFocusable;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFocus(node: HTMLElement | null): void {
|
||||||
|
if (!node?.isConnected) return;
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals.
|
* Escape-to-close, body scroll lock, focus move-in, tab trap, and restore
|
||||||
|
* focus to the control that opened a Create-shell modal.
|
||||||
*/
|
*/
|
||||||
export function useCreateModalA11y(
|
export function useCreateModalA11y(
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -28,17 +111,17 @@ export function useCreateModalA11y(
|
|||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
previousActiveElementRef.current = document.activeElement as HTMLElement;
|
previousActiveElementRef.current = snapshotTrigger(dialogRef.current);
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
if (dialogRef.current) {
|
if (dialogRef.current) {
|
||||||
const focusableElements = dialogRef.current.querySelectorAll(
|
const focusableElements = dialogRef.current.querySelectorAll(
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
FOCUSABLE_SELECTOR,
|
||||||
);
|
);
|
||||||
const firstElement = focusableElements[0] as HTMLElement;
|
const firstElement = focusableElements[0] as HTMLElement | undefined;
|
||||||
if (firstElement) {
|
if (firstElement) {
|
||||||
firstElement.focus();
|
firstElement.focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -51,24 +134,22 @@ export function useCreateModalA11y(
|
|||||||
if (e.key !== "Tab" || !dialogRef.current) return;
|
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||||
|
|
||||||
const focusableElements = dialogRef.current.querySelectorAll(
|
const focusableElements = dialogRef.current.querySelectorAll(
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
FOCUSABLE_SELECTOR,
|
||||||
);
|
);
|
||||||
const firstElement = focusableElements[0] as HTMLElement;
|
const firstElement = focusableElements[0] as HTMLElement | undefined;
|
||||||
const lastElement = focusableElements[
|
const lastElement = focusableElements[
|
||||||
focusableElements.length - 1
|
focusableElements.length - 1
|
||||||
] as HTMLElement;
|
] as HTMLElement | undefined;
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if (document.activeElement === firstElement) {
|
if (document.activeElement === firstElement) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
lastElement?.focus();
|
lastElement?.focus();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (document.activeElement === lastElement) {
|
||||||
if (document.activeElement === lastElement) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
firstElement?.focus();
|
firstElement?.focus();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleTab);
|
document.addEventListener("keydown", handleTab);
|
||||||
@@ -76,7 +157,7 @@ export function useCreateModalA11y(
|
|||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
document.removeEventListener("keydown", handleTab);
|
document.removeEventListener("keydown", handleTab);
|
||||||
previousActiveElementRef.current?.focus();
|
restoreFocus(previousActiveElementRef.current);
|
||||||
};
|
};
|
||||||
}, [dialogRef, isOpen]);
|
}, [dialogRef, isOpen]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { renderWithProviders } from "../utils/test-utils";
|
import { renderWithProviders } from "../utils/test-utils";
|
||||||
import Create from "../../app/components/modals/Create";
|
import Create from "../../app/components/modals/Create";
|
||||||
@@ -8,6 +9,23 @@ import TextInput from "../../app/components/controls/TextInput";
|
|||||||
|
|
||||||
type CreateProps = React.ComponentProps<typeof Create>;
|
type CreateProps = React.ComponentProps<typeof Create>;
|
||||||
|
|
||||||
|
function CreateOpenHarness() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setOpen(true)}>
|
||||||
|
Open create dialog
|
||||||
|
</button>
|
||||||
|
<Create
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title="Test Create Dialog"
|
||||||
|
description="Test description"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("Create", () => {
|
describe("Create", () => {
|
||||||
const defaultProps: CreateProps = {
|
const defaultProps: CreateProps = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -233,6 +251,17 @@ describe("Create", () => {
|
|||||||
expect(document.body.style.overflow).toBe("");
|
expect(document.body.style.overflow).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores focus to the control that opened it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<CreateOpenHarness />);
|
||||||
|
const trigger = screen.getByRole("button", { name: "Open create dialog" });
|
||||||
|
await user.click(trigger);
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(trigger).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
it("traps focus within create dialog", async () => {
|
it("traps focus within create dialog", async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<Create {...defaultProps}>
|
<Create {...defaultProps}>
|
||||||
|
|||||||
@@ -1,12 +1,84 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { screen, fireEvent } from "@testing-library/react";
|
import { screen, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { renderWithProviders } from "../utils/test-utils";
|
import { renderWithProviders } from "../utils/test-utils";
|
||||||
import Dialog from "../../app/components/modals/Dialog";
|
import Dialog from "../../app/components/modals/Dialog";
|
||||||
|
|
||||||
type Props = React.ComponentProps<typeof Dialog>;
|
type Props = React.ComponentProps<typeof Dialog>;
|
||||||
|
|
||||||
|
const dialogCopy = {
|
||||||
|
title: "Confirm action",
|
||||||
|
description: "This cannot be undone.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogOpenHarness() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setOpen(true)}>
|
||||||
|
Open dialog
|
||||||
|
</button>
|
||||||
|
<Dialog
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={dialogCopy.title}
|
||||||
|
description={dialogCopy.description}
|
||||||
|
footer={
|
||||||
|
<button type="button" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFromMenuHarness() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const menuId = "dialog-focus-restore-menu";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-controls={menuId}
|
||||||
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
More options
|
||||||
|
</button>
|
||||||
|
{menuOpen ? (
|
||||||
|
<div role="menu" id={menuId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setDialogOpen(true);
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Dialog
|
||||||
|
isOpen={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
title={dialogCopy.title}
|
||||||
|
description={dialogCopy.description}
|
||||||
|
footer={
|
||||||
|
<button type="button" onClick={() => setDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("Dialog", () => {
|
describe("Dialog", () => {
|
||||||
const defaultProps: Props = {
|
const defaultProps: Props = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -57,4 +129,41 @@ describe("Dialog", () => {
|
|||||||
renderWithProviders(<Dialog {...defaultProps} />);
|
renderWithProviders(<Dialog {...defaultProps} />);
|
||||||
expect(document.body.style.overflow).toBe("hidden");
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores focus to the control that opened it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<DialogOpenHarness />);
|
||||||
|
const trigger = screen.getByRole("button", { name: "Open dialog" });
|
||||||
|
await user.click(trigger);
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(trigger).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus to the opener when the opening click did not focus it", () => {
|
||||||
|
renderWithProviders(<DialogOpenHarness />);
|
||||||
|
const trigger = screen.getByRole("button", { name: "Open dialog" });
|
||||||
|
fireEvent.pointerDown(trigger);
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(trigger).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus to the menu trigger when a menu item opened the dialog", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<DialogFromMenuHarness />);
|
||||||
|
const menuTrigger = screen.getByRole("button", { name: "More options" });
|
||||||
|
await user.click(menuTrigger);
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("menuitem", { name: "Remove" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(menuTrigger).toHaveFocus();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user