From b024fbf1a172d8ebe0c05531f689395f97f3ea7b Mon Sep 17 00:00:00 2001 From: thattonBL <79150422+thattonBL@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:34:24 +0100 Subject: [PATCH] Restrict tab focus to within modal dialogues when open (fixes #1070 and #1072) (#1163) * Issues #1070 and #1072 Restrict tab focus to within modal dialogue elements when model-dialogue is open - Add key listener override on Dialogue.ts which is the base class for most Dialogues - Added code to do the same thing for the React Dowload Dialogue * Amendments to Issues #1070 and #1072 Restrict tab focus to within modal dialogue elements when model-dialogue is open - Changed the way in which the DownloadDialogue.tsx establishes the first element to give focus to on load - Extracted the focusable elements logic to use for the above - Removed added whitespace from Dialogue.ts --------- Co-authored-by: thatton --- .../DownloadDialogue.tsx | 54 +++++++++++++++++-- .../iiif/modules/uv-shared-module/Dialogue.ts | 33 +++++++++++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/DownloadDialogue.tsx b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/DownloadDialogue.tsx index db0cdd900..60a443d85 100644 --- a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/DownloadDialogue.tsx +++ b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/DownloadDialogue.tsx @@ -78,7 +78,6 @@ const DownloadDialogue = ({ triggerButton: HTMLElement; }) => { const ref = useRef(null); - const [position, setPosition] = useState({ top: "0px", left: "0px" }); const [arrowPosition, setArrowPosition] = useState("0px 0px"); const [selectedPage, setSelectedPage] = useState<"left" | "right">("left"); @@ -109,12 +108,59 @@ const DownloadDialogue = ({ setPosition({ top: `${top}px`, left: `${left}px` }); setArrowPosition(`${arrowLeft}px 0px`); + + // Focus on the first element when opened + const focusableElements = getFocusableElements(); + if (focusableElements && focusableElements.length > 0) { + focusableElements[0]?.focus(); + } } }, [open]); - if (!open) { - return null; - } + // Method to get focusable elements inside the component + const getFocusableElements = (): NodeListOf | null => { + return ref.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) as NodeListOf; + }; + + + // Focus trapping logic + const handleTabKey = (e: KeyboardEvent) => { + if (e.key === "Tab") { + const focusableElements = getFocusableElements(); + if (!focusableElements) return; + + const firstFocusableElement = focusableElements[0] as HTMLElement; + const lastFocusableElement = focusableElements[focusableElements.length - 1] as HTMLElement; + + if (e.shiftKey) { + // If Shift + Tab is pressed and the focus is on the first element, go to the last + if (document.activeElement === firstFocusableElement) { + e.preventDefault(); + lastFocusableElement.focus(); + } + } else { + // If Tab is pressed and the focus is on the last element, go to the first + if (document.activeElement === lastFocusableElement) { + e.preventDefault(); + firstFocusableElement.focus(); + } + } + } + }; + + useEffect(() => { + if (open) { + document.addEventListener("keydown", handleTabKey); + } + + return () => { + document.removeEventListener("keydown", handleTabKey); + }; + }, [open]); + + if (!open) return null; function getCanvasDimensions(canvas: Canvas): Size | null { // externalResource may not have loaded yet diff --git a/src/content-handlers/iiif/modules/uv-shared-module/Dialogue.ts b/src/content-handlers/iiif/modules/uv-shared-module/Dialogue.ts index cedddb02d..0f067f3d2 100644 --- a/src/content-handlers/iiif/modules/uv-shared-module/Dialogue.ts +++ b/src/content-handlers/iiif/modules/uv-shared-module/Dialogue.ts @@ -28,7 +28,6 @@ export class Dialogue< create(): void { this.setConfig("dialogue"); - super.create(); // events. @@ -168,6 +167,9 @@ export class Dialogue< } }, 1); + // Add keydown event listener to trap focus within the dialog + this.$element.on("keydown", (e: JQuery.Event) => this.handleKeydown(e)); + this.extensionHost.publish(IIIFEvents.SHOW_OVERLAY); if (this.isUnopened) { @@ -186,6 +188,9 @@ export class Dialogue< this.$element.hide(); this.isActive = false; + // Remove the keydown event listener + this.$element.off("keydown"); + this.extensionHost.publish(this.closeCommand); this.extensionHost.publish(IIIFEvents.HIDE_OVERLAY); } @@ -198,4 +203,30 @@ export class Dialogue< left: Math.floor(this.extension.width() / 2 - this.$element.width() / 2), }); } + + private handleKeydown(event: JQuery.Event): void { + if (event.key === "Tab") { + const focusableSelectors = + 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex="0"]'; + const focusableElements = this.$element.find(focusableSelectors).filter(':visible'); + + const firstElement = focusableElements.first()[0]; + const lastElement = focusableElements.last()[0]; + const activeElement = document.activeElement; + + if (event.shiftKey) { + // Shift + Tab (backwards) + if (activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } else { + // Tab (forwards) + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + } + } }