6
6
*/
7
7
8
8
/**
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
10
16
*
11
17
* @returns {boolean } True if the current platform is macOS; otherwise, false.
12
18
*/
13
19
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 / m a c / 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 = / i p a d | i p h o n e | i p o d / i. test ( userAgent ) ;
44
+ if ( isiOSLike ) return false ;
45
+
46
+ return / m a c / i. test ( platform ) || / m a c i n t o s h | m a c o s x / i. test ( userAgent ) ;
47
+ } catch ( error ) {
48
+ return false ;
49
+ }
15
50
}
16
51
17
52
/**
@@ -74,6 +109,9 @@ function createModal(options) {
74
109
contentEl . style . overflow = "auto" ;
75
110
contentEl . style . boxShadow = "0 4px 20px rgba(0, 0, 0, 0.3)" ;
76
111
contentEl . setAttribute ( "tabindex" , "-1" ) ;
112
+ // A11y roles/attributes
113
+ contentEl . setAttribute ( "role" , "dialog" ) ;
114
+ contentEl . setAttribute ( "aria-modal" , "true" ) ;
77
115
78
116
// Set content
79
117
if ( typeof content === "string" ) {
@@ -82,21 +120,111 @@ function createModal(options) {
82
120
contentEl . appendChild ( content ) ;
83
121
}
84
122
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
+
85
141
modal . appendChild ( contentEl ) ;
86
142
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 ) => {
89
202
if ( e . target === modal ) {
90
203
closeModal ( ) ;
91
204
}
92
- } ) ;
205
+ } ;
206
+
207
+ modal . addEventListener ( "keydown" , onKeyDown ) ;
208
+ // Close when clicking outside
209
+ modal . addEventListener ( "click" , onBackdropClick ) ;
93
210
94
211
// Close function
95
212
const closeModal = ( ) => {
213
+ // Remove listeners first
214
+ modal . removeEventListener ( "keydown" , onKeyDown ) ;
215
+ modal . removeEventListener ( "click" , onBackdropClick ) ;
216
+
96
217
if ( modal . parentNode ) {
97
218
document . body . removeChild ( modal ) ;
98
- if ( onClose ) onClose ( ) ;
99
219
}
220
+
221
+ // Restore focus
222
+ if ( previouslyFocusedElement && previouslyFocusedElement . focus ) {
223
+ previouslyFocusedElement . focus ( ) ;
224
+ }
225
+
226
+ // Invoke callback last
227
+ if ( onClose ) onClose ( ) ;
100
228
} ;
101
229
102
230
// Expose close function
0 commit comments