|
| 1 | +// Keyboard and screen-reader behaviour shared by every dialog |
| 2 | +// (.modal-overlay). The dialogs are shown and hidden by their own code |
| 3 | +// setting style.display, so this watches that rather than every open |
| 4 | +// and close function calling in: |
| 5 | +// - each box is marked up as a modal dialog, labelled by its title; |
| 6 | +// - opening one moves focus into it, unless its own code already did |
| 7 | +// (the craft dialog's amount box); |
| 8 | +// - Tab and Shift+Tab stay inside the top dialog; |
| 9 | +// - closing one puts focus back where it was before it opened, unless |
| 10 | +// focus has gone on somewhere else since (another dialog opened). |
| 11 | + |
| 12 | +import { findCopy } from './util.js'; |
| 13 | + |
| 14 | +const FOCUSABLE = 'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])'; |
| 15 | +const openedFrom = new Map(); // overlay -> element focused before it opened |
| 16 | +let focused = null; // the element focused now, |
| 17 | +let focusedBefore = null; // and the one before it |
| 18 | + |
| 19 | +function isShown(overlay) { |
| 20 | + return overlay.style.display !== 'none'; |
| 21 | +} |
| 22 | + |
| 23 | +// The dialog on top: the highest z-index, then the latest in the page. |
| 24 | +function topDialog() { |
| 25 | + let top = null; |
| 26 | + let topZ = -Infinity; |
| 27 | + for (const overlay of document.querySelectorAll('.modal-overlay')) { |
| 28 | + if (!isShown(overlay)) continue; |
| 29 | + const z = parseInt(getComputedStyle(overlay).zIndex, 10) || 0; |
| 30 | + if (z >= topZ) { |
| 31 | + top = overlay; |
| 32 | + topZ = z; |
| 33 | + } |
| 34 | + } |
| 35 | + return top; |
| 36 | +} |
| 37 | + |
| 38 | +function focusables(box) { |
| 39 | + return [...box.querySelectorAll(FOCUSABLE)].filter(el => |
| 40 | + !el.disabled && !el.closest('[hidden], [inert]') && el.getClientRects().length > 0); |
| 41 | +} |
| 42 | + |
| 43 | +function markUp(overlay) { |
| 44 | + const box = overlay.querySelector('.modal-box'); |
| 45 | + if (!box) return; |
| 46 | + box.setAttribute('role', 'dialog'); |
| 47 | + box.setAttribute('aria-modal', 'true'); |
| 48 | + box.tabIndex = -1; // focusable by script, so focus can wait in the dialog itself |
| 49 | + const title = box.querySelector('.modal-item-name, .craft-title'); |
| 50 | + if (title) { |
| 51 | + if (!title.id) title.id = overlay.id + 'Title'; |
| 52 | + box.setAttribute('aria-labelledby', title.id); |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +function onShown(overlay) { |
| 57 | + const active = document.activeElement; |
| 58 | + const box = overlay.querySelector('.modal-box') || overlay; |
| 59 | + if (active && overlay.contains(active)) { |
| 60 | + // Its own code focused something in it: what had focus before that. |
| 61 | + openedFrom.set(overlay, focusedBefore && !overlay.contains(focusedBefore) ? focusedBefore : null); |
| 62 | + return; |
| 63 | + } |
| 64 | + openedFrom.set(overlay, active && active !== document.body ? active : null); |
| 65 | + box.focus({ preventScroll: true }); |
| 66 | +} |
| 67 | + |
| 68 | +function onHidden(overlay) { |
| 69 | + const opener = openedFrom.get(overlay); |
| 70 | + // The Crafts tab and the ingredients grid are redrawn every few |
| 71 | + // seconds, so it may have been replaced by a copy of itself. |
| 72 | + const back = opener && !opener.isConnected ? findCopy(document, opener) : opener; |
| 73 | + openedFrom.delete(overlay); |
| 74 | + const active = document.activeElement; |
| 75 | + // Focus moved on to something else (another dialog's box): leave it. |
| 76 | + if (active && active !== document.body && !overlay.contains(active)) return; |
| 77 | + if (back && back.isConnected && back.getClientRects().length > 0) { |
| 78 | + back.focus({ preventScroll: true }); |
| 79 | + return; |
| 80 | + } |
| 81 | + // Nothing to go back to: the dialog under it, if there is one. |
| 82 | + const under = topDialog(); |
| 83 | + if (under) (under.querySelector('.modal-box') || under).focus({ preventScroll: true }); |
| 84 | + else if (active && overlay.contains(active)) active.blur(); |
| 85 | +} |
| 86 | + |
| 87 | +function trapTab(e) { |
| 88 | + if (e.key !== 'Tab' || e.ctrlKey || e.metaKey || e.altKey) return; |
| 89 | + const overlay = topDialog(); |
| 90 | + if (!overlay) return; |
| 91 | + const box = overlay.querySelector('.modal-box') || overlay; |
| 92 | + const items = focusables(box); |
| 93 | + if (!items.length) { |
| 94 | + e.preventDefault(); |
| 95 | + box.focus({ preventScroll: true }); |
| 96 | + return; |
| 97 | + } |
| 98 | + const first = items[0]; |
| 99 | + const last = items[items.length - 1]; |
| 100 | + const active = document.activeElement; |
| 101 | + const inside = box.contains(active) && active !== box; |
| 102 | + if (e.shiftKey && (!inside || active === first)) { |
| 103 | + e.preventDefault(); |
| 104 | + last.focus(); |
| 105 | + } else if (!e.shiftKey && (!inside || active === last)) { |
| 106 | + e.preventDefault(); |
| 107 | + first.focus(); |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +export function setupDialogs() { |
| 112 | + const observer = new MutationObserver((records) => { |
| 113 | + for (const { target, oldValue } of records) { |
| 114 | + const wasShown = !/display:\s*none/.test(oldValue || ''); |
| 115 | + const shown = isShown(target); |
| 116 | + if (shown && !wasShown) onShown(target); |
| 117 | + else if (!shown && wasShown) onHidden(target); |
| 118 | + } |
| 119 | + }); |
| 120 | + for (const overlay of document.querySelectorAll('.modal-overlay')) { |
| 121 | + markUp(overlay); |
| 122 | + observer.observe(overlay, { attributes: true, attributeFilter: ['style'], attributeOldValue: true }); |
| 123 | + } |
| 124 | + document.addEventListener('keydown', trapTab); |
| 125 | + document.addEventListener('focusin', (e) => { |
| 126 | + if (e.target === focused) return; |
| 127 | + focusedBefore = focused; |
| 128 | + focused = e.target; |
| 129 | + }); |
| 130 | +} |
0 commit comments