@@ -75,7 +75,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
7575 click. Esc closes. Groups by role so the hierarchy reads. */
7676 }
7777 <div class =" pb-overlay" role =" dialog" aria-modal =" true" aria-label =" Contents" hidden >
78- <div class =" pb-overlay-card" >
78+ <div class =" pb-overlay-card" tabindex = " -1 " >
7979 <div class =" pb-overlay-head" >
8080 <span class =" pb-overlay-eyebrow" >{ tocEyebrow } </span >
8181 <button class =" pb-overlay-close" type =" button" aria-label =" Close contents" >×</button >
@@ -130,7 +130,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
130130 </header >
131131
132132 { /* The spreads. One snap-stop per spread; mobile scrolls naturally. */ }
133- <main class =" pb-spreads" >
133+ <div class =" pb-spreads" >
134134 {
135135 spreads .map ((s ) => {
136136 // Every spread is stacked: the numbered running head, then the hero
@@ -270,7 +270,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
270270 );
271271 })
272272 }
273- </main >
273+ </div >
274274
275275 <SiteFooter />
276276</div >
@@ -288,7 +288,11 @@ const groups: BookTocGroup[] = tocGroups ?? [
288288 scroll-snap-type: y mandatory;
289289 scroll-padding-top: 4rem;
290290 scroll-padding-bottom: 4rem;
291- scroll-behavior: smooth;
291+ }
292+ @media (prefers-reduced-motion: no-preference) {
293+ html:has(.playbook) {
294+ scroll-behavior: smooth;
295+ }
292296 }
293297 @media (max-width: 640px) {
294298 html:has(.playbook) {
@@ -974,6 +978,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
974978 (function () {
975979 const strip = document.querySelector(".pb-strip") as HTMLButtonElement | null;
976980 const overlay = document.querySelector(".pb-overlay") as HTMLElement | null;
981+ const overlayCard = document.querySelector(".pb-overlay-card") as HTMLElement | null;
977982 const closeBtn = document.querySelector(".pb-overlay-close") as HTMLButtonElement | null;
978983 const bars = Array.from(document.querySelectorAll(".pb-strip-bar")) as HTMLElement[];
979984 const tocLinks = Array.from(document.querySelectorAll(".pb-toc-link")) as HTMLAnchorElement[];
@@ -987,6 +992,24 @@ const groups: BookTocGroup[] = tocGroups ?? [
987992 const stops: HTMLElement[] = cover ? [cover, ...spreads] : [...spreads];
988993 let curIdx = 0;
989994
995+ // Respects the user's motion preference: smooth turns become instant
996+ // jumps when they've asked the OS/browser for reduced motion.
997+ function scrollBehavior(): ScrollBehavior {
998+ return matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
999+ }
1000+
1001+ // The element that had focus before the overlay opened, so closing it
1002+ // (button, scrim, or Esc) can put focus back where the reader was.
1003+ let lastFocused: HTMLElement | null = null;
1004+
1005+ function focusableIn(container: HTMLElement): HTMLElement[] {
1006+ return Array.from(
1007+ container.querySelectorAll(
1008+ 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])',
1009+ ),
1010+ ) as HTMLElement[];
1011+ }
1012+
9901013 function setCurrent(idx: number) {
9911014 curIdx = idx;
9921015 const barIdx = idx - 1;
@@ -997,13 +1020,21 @@ const groups: BookTocGroup[] = tocGroups ?? [
9971020
9981021 function openOverlay() {
9991022 if (!overlay || !strip) return;
1023+ lastFocused = document.activeElement as HTMLElement | null;
10001024 overlay.hidden = false;
10011025 strip.setAttribute("aria-expanded", "true");
1026+ // Move focus into the dialog -- the close button when there is one,
1027+ // else the card itself (tabindex="-1", focusable but not tab-stopped).
1028+ (closeBtn ?? overlayCard)?.focus();
10021029 }
10031030 function closeOverlay() {
10041031 if (!overlay || !strip) return;
10051032 overlay.hidden = true;
10061033 strip.setAttribute("aria-expanded", "false");
1034+ // Restore focus to wherever it came from -- normally the strip that
1035+ // opened the overlay -- so the reader isn't dropped back at <body>.
1036+ (lastFocused ?? strip).focus();
1037+ lastFocused = null;
10071038 }
10081039
10091040 if (strip) strip.addEventListener("click", openOverlay);
@@ -1012,6 +1043,23 @@ const groups: BookTocGroup[] = tocGroups ?? [
10121043 overlay.addEventListener("click", (e) => {
10131044 if (e.target === overlay) closeOverlay();
10141045 });
1046+ // Trap Tab/Shift+Tab inside the card while the overlay is open, so
1047+ // focus can't leak to the (visually covered, but still tabbable)
1048+ // page behind it.
1049+ overlay.addEventListener("keydown", (e) => {
1050+ if (e.key !== "Tab" || !overlayCard) return;
1051+ const focusable = focusableIn(overlayCard);
1052+ if (!focusable.length) return;
1053+ const first = focusable[0];
1054+ const last = focusable[focusable.length - 1];
1055+ if (e.shiftKey && document.activeElement === first) {
1056+ e.preventDefault();
1057+ last.focus();
1058+ } else if (!e.shiftKey && document.activeElement === last) {
1059+ e.preventDefault();
1060+ first.focus();
1061+ }
1062+ });
10151063 }
10161064 tocLinks.forEach((link) => {
10171065 link.addEventListener("click", () => {
@@ -1021,24 +1069,33 @@ const groups: BookTocGroup[] = tocGroups ?? [
10211069 });
10221070
10231071 // Keyboard navigation: ←/→ and ↑/↓ step between stops. Skipped
1024- // when the overlay is open (Esc closes that instead) and when
1025- // focus is in a form field. The hash CTAs on cover + TOC handle
1026- // direct jumps; this gives the book a one-keystroke turn.
1072+ // when the overlay is open (Esc closes that instead), when focus is
1073+ // in a form field or content-editable, when a modifier is held (so
1074+ // browser/OS shortcuts on those same keys still work), and when
1075+ // focus is on a <select> (arrows change its value, not the page).
10271076 document.addEventListener("keydown", (e) => {
10281077 if (overlay && !overlay.hidden) {
10291078 if (e.key === "Escape") closeOverlay();
10301079 return;
10311080 }
10321081 const active = document.activeElement as HTMLElement | null;
1033- if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) return;
1082+ if (
1083+ active &&
1084+ (active.tagName === "INPUT" ||
1085+ active.tagName === "TEXTAREA" ||
1086+ active.tagName === "SELECT" ||
1087+ active.isContentEditable)
1088+ )
1089+ return;
1090+ if (e.altKey || e.metaKey || e.ctrlKey) return;
10341091
10351092 if (e.key === "ArrowDown" || e.key === "ArrowRight") {
10361093 const next = stops[Math.min(curIdx + 1, stops.length - 1)];
1037- if (next) next.scrollIntoView({ behavior: "smooth" , block: "start" });
1094+ if (next) next.scrollIntoView({ behavior: scrollBehavior() , block: "start" });
10381095 e.preventDefault();
10391096 } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
10401097 const prev = stops[Math.max(curIdx - 1, 0)];
1041- if (prev) prev.scrollIntoView({ behavior: "smooth" , block: "start" });
1098+ if (prev) prev.scrollIntoView({ behavior: scrollBehavior() , block: "start" });
10421099 e.preventDefault();
10431100 }
10441101 });
0 commit comments