Skip to content

Commit f2d80b6

Browse files
committed
Enhance platform detection and modal accessibility
Platform Detection: - Add robust macOS detection with User-Agent Client Hints support - Exclude touch devices to prevent iPadOS false positives - Add case-insensitive fallbacks and proper error handling Modal Accessibility: - Add ARIA roles, labels, and descriptions - Implement proper focus management and restoration - Add keyboard navigation with focus trapping - Support Escape key to close modal - Ensure tab cycling within modal boundaries
1 parent 4ab0815 commit f2d80b6

File tree

1 file changed

+134
-6
lines changed

1 file changed

+134
-6
lines changed

assets/js/utils.js

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,47 @@
66
*/
77

88
/**
9-
* Determines whether the user's platform is macOS.
9+
* Determines whether the user's platform is macOS with robust detection.
10+
*
11+
* Rules:
12+
* - Prefer navigator.userAgentData.platform when available
13+
* - Always exclude touch devices (maxTouchPoints > 0) to avoid iPadOS spoofing
14+
* - Fallback to navigator.userAgent and navigator.platform (case-insensitive)
15+
* - Return false for iPad/iPhone/iPod-like devices
1016
*
1117
* @returns {boolean} True if the current platform is macOS; otherwise, false.
1218
*/
1319
function isMacPlatform() {
14-
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
20+
try {
21+
// Exclude touch-capable devices (e.g., iPadOS reporting "MacIntel")
22+
if (
23+
typeof navigator !== "undefined" &&
24+
typeof navigator.maxTouchPoints === "number" &&
25+
navigator.maxTouchPoints > 0
26+
) {
27+
return false;
28+
}
29+
30+
// Use User-Agent Client Hints when available
31+
if (
32+
typeof navigator !== "undefined" &&
33+
navigator.userAgentData &&
34+
typeof navigator.userAgentData.platform === "string"
35+
) {
36+
return /mac/i.test(navigator.userAgentData.platform);
37+
}
38+
39+
// Fallback to UA string and platform
40+
const platform = (navigator && navigator.platform) || "";
41+
const userAgent = (navigator && navigator.userAgent) || "";
42+
43+
const isiOSLike = /ipad|iphone|ipod/i.test(userAgent);
44+
if (isiOSLike) return false;
45+
46+
return /mac/i.test(platform) || /macintosh|mac os x/i.test(userAgent);
47+
} catch (error) {
48+
return false;
49+
}
1550
}
1651

1752
/**
@@ -74,6 +109,9 @@ function createModal(options) {
74109
contentEl.style.overflow = "auto";
75110
contentEl.style.boxShadow = "0 4px 20px rgba(0, 0, 0, 0.3)";
76111
contentEl.setAttribute("tabindex", "-1");
112+
// A11y roles/attributes
113+
contentEl.setAttribute("role", "dialog");
114+
contentEl.setAttribute("aria-modal", "true");
77115

78116
// Set content
79117
if (typeof content === "string") {
@@ -82,21 +120,111 @@ function createModal(options) {
82120
contentEl.appendChild(content);
83121
}
84122

123+
// Try to associate a label/description if present
124+
const heading = contentEl.querySelector("h1, h2, h3");
125+
if (heading) {
126+
if (!heading.id) {
127+
heading.id = `modal-title-${Date.now()}`;
128+
}
129+
contentEl.setAttribute("aria-labelledby", heading.id);
130+
} else {
131+
contentEl.setAttribute("aria-label", "Dialog");
132+
}
133+
const description = contentEl.querySelector("p");
134+
if (description) {
135+
if (!description.id) {
136+
description.id = `modal-desc-${Date.now()}`;
137+
}
138+
contentEl.setAttribute("aria-describedby", description.id);
139+
}
140+
85141
modal.appendChild(contentEl);
86142

87-
// Close when clicking outside
88-
modal.addEventListener("click", (e) => {
143+
// Focus management
144+
const previouslyFocusedElement = document.activeElement;
145+
const focusWhenAttached = () => {
146+
if (document.body && document.body.contains(modal)) {
147+
contentEl.focus();
148+
} else {
149+
requestAnimationFrame(focusWhenAttached);
150+
}
151+
};
152+
requestAnimationFrame(focusWhenAttached);
153+
154+
// Focus trap helpers
155+
const getFocusableElements = () => {
156+
const selector = [
157+
"a[href]",
158+
"area[href]",
159+
"input:not([disabled])",
160+
"select:not([disabled])",
161+
"textarea:not([disabled])",
162+
"button:not([disabled])",
163+
"[tabindex]:not([tabindex='-1'])",
164+
].join(",");
165+
const elements = Array.from(contentEl.querySelectorAll(selector));
166+
// Include the dialog container itself if focusable
167+
if (contentEl.getAttribute("tabindex") !== null) {
168+
elements.unshift(contentEl);
169+
}
170+
return elements.filter(
171+
(el) => el.offsetParent !== null || el === contentEl
172+
);
173+
};
174+
175+
const onKeyDown = (e) => {
176+
if (e.key === "Escape") {
177+
e.stopPropagation();
178+
closeModal();
179+
return;
180+
}
181+
if (e.key === "Tab") {
182+
const focusable = getFocusableElements();
183+
if (focusable.length === 0) {
184+
e.preventDefault();
185+
contentEl.focus();
186+
return;
187+
}
188+
const currentIndex = focusable.indexOf(document.activeElement);
189+
let nextIndex = currentIndex;
190+
if (e.shiftKey) {
191+
nextIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1;
192+
} else {
193+
nextIndex =
194+
currentIndex === focusable.length - 1 ? 0 : currentIndex + 1;
195+
}
196+
e.preventDefault();
197+
focusable[nextIndex].focus();
198+
}
199+
};
200+
201+
const onBackdropClick = (e) => {
89202
if (e.target === modal) {
90203
closeModal();
91204
}
92-
});
205+
};
206+
207+
modal.addEventListener("keydown", onKeyDown);
208+
// Close when clicking outside
209+
modal.addEventListener("click", onBackdropClick);
93210

94211
// Close function
95212
const closeModal = () => {
213+
// Remove listeners first
214+
modal.removeEventListener("keydown", onKeyDown);
215+
modal.removeEventListener("click", onBackdropClick);
216+
96217
if (modal.parentNode) {
97218
document.body.removeChild(modal);
98-
if (onClose) onClose();
99219
}
220+
221+
// Restore focus
222+
if (previouslyFocusedElement && previouslyFocusedElement.focus) {
223+
previouslyFocusedElement.focus();
224+
}
225+
226+
// Invoke callback last
227+
if (onClose) onClose();
100228
};
101229

102230
// Expose close function

0 commit comments

Comments
 (0)