From 3dc84dfd448c14aa720a8bddbb3b618ce932ab32 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:46:49 -0400 Subject: [PATCH] Refactor video modal accessibility wiring --- js/ui/components/VideoModal.js | 74 +++++----- js/ui/components/modalAccessibility.js | 192 +++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 34 deletions(-) create mode 100644 js/ui/components/modalAccessibility.js diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index a01036b7..1998d76c 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -1,3 +1,5 @@ +import { createModalAccessibility } from "./modalAccessibility.js"; + export class VideoModal { constructor({ removeTrackingScripts, @@ -25,6 +27,8 @@ export class VideoModal { this.loaded = false; this.playerModal = null; + this.modalPanel = null; + this.modalBackdrop = null; this.scrollRegion = null; this.modalVideo = null; this.modalStatus = null; @@ -46,6 +50,9 @@ export class VideoModal { this.modalMoreBtn = null; this.modalMoreMenu = null; + this.modalAccessibility = null; + this.modalNavScrollHandler = null; + this.modalZapDialog = null; this.modalZapForm = null; this.modalZapAmountInput = null; @@ -190,9 +197,23 @@ export class VideoModal { } hydrate(playerModal) { + if (this.modalAccessibility?.destroy) { + this.modalAccessibility.destroy(); + } + this.modalAccessibility = null; + + const previousScrollRegion = this.scrollRegion; + if (previousScrollRegion && this.modalNavScrollHandler) { + previousScrollRegion.removeEventListener( + "scroll", + this.modalNavScrollHandler + ); + } + this.playerModal = playerModal; - this.scrollRegion = - playerModal.querySelector(".player-modal__content") || playerModal; + this.modalPanel = playerModal.querySelector(".bv-modal__panel") || null; + this.modalBackdrop = playerModal.querySelector("[data-dismiss]") || null; + this.scrollRegion = this.modalPanel || playerModal; this.modalVideo = playerModal.querySelector("#modalVideo") || null; this.modalStatus = playerModal.querySelector("#modalStatus") || null; @@ -253,7 +274,7 @@ export class VideoModal { const modalNav = playerModal.querySelector("#modalNav"); if (modalNav && this.scrollRegion) { let lastScrollY = 0; - this.scrollRegion.addEventListener("scroll", () => { + this.modalNavScrollHandler = () => { const currentScrollY = this.scrollRegion.scrollTop; const shouldShowNav = currentScrollY <= lastScrollY || currentScrollY < 50; @@ -261,9 +282,22 @@ export class VideoModal { ? "translateY(0)" : "translateY(-100%)"; lastScrollY = currentScrollY; - }); + }; + this.scrollRegion.addEventListener("scroll", this.modalNavScrollHandler); + } else { + this.modalNavScrollHandler = null; } + this.modalAccessibility = createModalAccessibility({ + root: this.playerModal, + panel: this.modalPanel, + backdrop: this.modalBackdrop, + document: this.document, + onRequestClose: () => { + this.dispatch("modal:close", { video: this.activeVideo }); + }, + }); + this.bindVideoEvents(); this.bindActionButtons(); this.setZapVisibility(false); @@ -427,34 +461,6 @@ export class VideoModal { this.dispatch("creator:navigate", { video: this.activeVideo }); } - toggleBackgroundInert(enable) { - const ids = ["sidebar", "sidebarOverlay"]; - for (const id of ids) { - const element = this.document.getElementById(id); - if (!element) { - continue; - } - - if (enable) { - if (!element.hasAttribute("inert")) { - element.setAttribute("inert", ""); - } - element.dataset.modalInert = "true"; - element.setAttribute("aria-hidden", "true"); - element.classList.add("is-modal-hidden"); - } else { - if (element.dataset.modalInert === "true") { - element.removeAttribute("inert"); - delete element.dataset.modalInert; - } - if (element.getAttribute("aria-hidden") === "true") { - element.removeAttribute("aria-hidden"); - } - element.classList.remove("is-modal-hidden"); - } - } - } - open(video) { this.activeVideo = video || null; if (!this.playerModal) { @@ -465,7 +471,7 @@ export class VideoModal { this.playerModal.classList.remove("hidden"); this.document.body.classList.add("modal-open"); this.document.documentElement.classList.add("modal-open"); - this.toggleBackgroundInert(true); + this.modalAccessibility?.activate(); if (this.scrollRegion) { this.scrollRegion.scrollTop = 0; } @@ -481,7 +487,7 @@ export class VideoModal { } this.document.body.classList.remove("modal-open"); this.document.documentElement.classList.remove("modal-open"); - this.toggleBackgroundInert(false); + this.modalAccessibility?.deactivate(); this.setGlobalModalState("player", false); this.forceRemovePoster("close"); } diff --git a/js/ui/components/modalAccessibility.js b/js/ui/components/modalAccessibility.js new file mode 100644 index 00000000..b434881a --- /dev/null +++ b/js/ui/components/modalAccessibility.js @@ -0,0 +1,192 @@ +const FOCUSABLE_SELECTORS = [ + "a[href]", + "area[href]", + "button:not([disabled])", + 'input:not([type="hidden"]):not([disabled])', + "select:not([disabled])", + "textarea:not([disabled])", + "iframe", + "object", + "embed", + '[contenteditable="true"]', + "[tabindex]", + "audio[controls]", + "video[controls]", +].join(","); + +const scheduleMicrotask = + typeof queueMicrotask === "function" + ? queueMicrotask + : (callback) => Promise.resolve().then(callback); + +function isFocusable(element) { + if (!element) { + return false; + } + if (element.hasAttribute('disabled')) { + return false; + } + const tabindex = element.getAttribute('tabindex'); + if (tabindex && Number(tabindex) < 0) { + return false; + } + if (element.getAttribute('aria-hidden') === 'true') { + return false; + } + const rects = element.getClientRects(); + if (rects.length === 0) { + return false; + } + return true; +} + +export function createModalAccessibility({ root, panel, backdrop, document: providedDocument, onRequestClose } = {}) { + const doc = providedDocument || root?.ownerDocument || (typeof document !== 'undefined' ? document : null); + if (!root || !panel || !doc) { + return { + activate() {}, + deactivate() {}, + destroy() {}, + isActive() { + return false; + }, + }; + } + + let active = false; + let previousFocus = null; + + const resolveDismissTarget = (target) => { + if (!target) { + return null; + } + if ( + backdrop && + (target === backdrop || backdrop.contains?.(target)) && + !backdrop.hasAttribute("data-dismiss") + ) { + return backdrop; + } + return target.closest?.("[data-dismiss]") || null; + }; + + const keydownListener = (event) => { + if (!active) { + return; + } + if (event.key === "Escape") { + if (root.contains(event.target)) { + event.preventDefault(); + } + onRequestClose?.(event); + return; + } + if (event.key !== "Tab" || !root.contains(event.target)) { + return; + } + + const focusable = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTORS)).filter(isFocusable); + const activeElement = doc.activeElement; + const isPanelFocused = activeElement === panel; + + if (focusable.length === 0) { + event.preventDefault(); + if (panel !== activeElement) { + panel.focus({ preventScroll: true }); + } + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey) { + if (activeElement === first || !panel.contains(activeElement) || isPanelFocused) { + event.preventDefault(); + last.focus({ preventScroll: true }); + } + return; + } + + if (activeElement === last || isPanelFocused) { + event.preventDefault(); + first.focus({ preventScroll: true }); + } + }; + + const clickListener = (event) => { + if (!active) { + return; + } + const dismissTarget = resolveDismissTarget(event.target); + if (!dismissTarget) { + return; + } + if (!root.contains(dismissTarget)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onRequestClose?.(event); + }; + + const activate = () => { + if (active) { + return; + } + active = true; + const activeElement = doc.activeElement; + previousFocus = activeElement instanceof Element ? activeElement : null; + doc.addEventListener("keydown", keydownListener, true); + root.addEventListener("click", clickListener); + if (backdrop && backdrop !== root) { + backdrop.addEventListener("click", clickListener); + } + + scheduleMicrotask(() => { + if (!active) { + return; + } + const focusTarget = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTORS)).find(isFocusable) || panel; + if (typeof focusTarget.focus === "function") { + focusTarget.focus({ preventScroll: true }); + } + }); + }; + + const deactivate = () => { + if (!active) { + return; + } + doc.removeEventListener("keydown", keydownListener, true); + root.removeEventListener("click", clickListener); + if (backdrop && backdrop !== root) { + backdrop.removeEventListener("click", clickListener); + } + active = false; + + if (previousFocus && typeof previousFocus.focus === "function") { + if ( + !root.contains(previousFocus) && + typeof doc.contains === "function" && + doc.contains(previousFocus) + ) { + previousFocus.focus({ preventScroll: true }); + } + } + previousFocus = null; + }; + + const destroy = () => { + deactivate(); + }; + + return { + activate, + deactivate, + destroy, + isActive() { + return active; + }, + }; +}