1// @ts-check 2/* 3 * This content is licensed according to the W3C Software License at 4 * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 5 * 6 * File: menu-button-links.js 7 * 8 * Desc: Creates a menu button that opens a menu of links 9 * 10 * Modified by Peter Keuter to adhere to the coding standards of Keycloak 11 * Original file: https://www.w3.org/WAI/content-assets/wai-aria-practices/patterns/menu-button/examples/js/menu-button-links.js 12 * Source: https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html 13 */ 14 15class MenuButtonLinks { 16 constructor(domNode) { 17 this.domNode = domNode; 18 this.buttonNode = domNode.querySelector("button"); 19 this.menuNode = domNode.querySelector('[role="menu"]'); 20 this.menuitemNodes = []; 21 this.firstMenuitem = false; 22 this.lastMenuitem = false; 23 this.firstChars = []; 24 25 this.buttonNode.addEventListener("keydown", (e) => this.onButtonKeydown(e)); 26 this.buttonNode.addEventListener("click", (e) => this.onButtonClick(e)); 27 28 const nodes = domNode.querySelectorAll('[role="menuitem"]'); 29 30 for (const menuitem of nodes) { 31 this.menuitemNodes.push(menuitem); 32 menuitem.tabIndex = -1; 33 this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase()); 34 35 menuitem.addEventListener("keydown", (e) => this.onMenuitemKeydown(e)); 36 37 menuitem.addEventListener("mouseover", (e) => 38 this.onMenuitemMouseover(e) 39 ); 40 41 if (!this.firstMenuitem) { 42 this.firstMenuitem = menuitem; 43 } 44 this.lastMenuitem = menuitem; 45 } 46 47 domNode.addEventListener("focusin", () => this.onFocusin()); 48 domNode.addEventListener("focusout", () => this.onFocusout()); 49 50 window.addEventListener( 51 "mousedown", 52 (e) => this.onBackgroundMousedown(e), 53 true 54 ); 55 } 56 57 setFocusToMenuitem = (newMenuitem) => 58 this.menuitemNodes.forEach((item) => { 59 if (item === newMenuitem) { 60 item.tabIndex = 0; 61 newMenuitem.focus(); 62 } else { 63 item.tabIndex = -1; 64 } 65 }); 66 67 setFocusToFirstMenuitem = () => this.setFocusToMenuitem(this.firstMenuitem); 68 69 setFocusToLastMenuitem = () => this.setFocusToMenuitem(this.lastMenuitem); 70 71 setFocusToPreviousMenuitem = (currentMenuitem) => { 72 let newMenuitem, index; 73 74 if (currentMenuitem === this.firstMenuitem) { 75 newMenuitem = this.lastMenuitem; 76 } else { 77 index = this.menuitemNodes.indexOf(currentMenuitem); 78 newMenuitem = this.menuitemNodes[index - 1]; 79 } 80 81 this.setFocusToMenuitem(newMenuitem); 82 83 return newMenuitem; 84 }; 85 86 setFocusToNextMenuitem = (currentMenuitem) => { 87 let newMenuitem, index; 88 89 if (currentMenuitem === this.lastMenuitem) { 90 newMenuitem = this.firstMenuitem; 91 } else { 92 index = this.menuitemNodes.indexOf(currentMenuitem); 93 newMenuitem = this.menuitemNodes[index + 1]; 94 } 95 this.setFocusToMenuitem(newMenuitem); 96 97 return newMenuitem; 98 }; 99 100 setFocusByFirstCharacter = (currentMenuitem, char) => { 101 let start, index; 102 103 if (char.length > 1) { 104 return; 105 } 106 107 char = char.toLowerCase(); 108 109 // Get start index for search based on position of currentItem 110 start = this.menuitemNodes.indexOf(currentMenuitem) + 1; 111 if (start >= this.menuitemNodes.length) { 112 start = 0; 113 } 114 115 // Check remaining slots in the menu 116 index = this.firstChars.indexOf(char, start); 117 118 // If not found in remaining slots, check from beginning 119 if (index === -1) { 120 index = this.firstChars.indexOf(char, 0); 121 } 122 123 // If match was found... 124 if (index > -1) { 125 this.setFocusToMenuitem(this.menuitemNodes[index]); 126 } 127 }; 128 129 // Utilities 130 131 getIndexFirstChars = (startIndex, char) => { 132 for (let i = startIndex; i < this.firstChars.length; i++) { 133 if (char === this.firstChars[i]) { 134 return i; 135 } 136 } 137 return -1; 138 }; 139 140 // Popup menu methods 141 142 openPopup = () => { 143 this.menuNode.style.display = "block"; 144 this.buttonNode.setAttribute("aria-expanded", "true"); 145 }; 146 147 closePopup = () => { 148 if (this.isOpen()) { 149 this.buttonNode.setAttribute("aria-expanded", "false"); 150 this.menuNode.style.removeProperty("display"); 151 } 152 }; 153 154 isOpen = () => { 155 return this.buttonNode.getAttribute("aria-expanded") === "true"; 156 }; 157 158 // Menu event handlers 159 160 onFocusin = () => { 161 this.domNode.classList.add("focus"); 162 }; 163 164 onFocusout = () => { 165 this.domNode.classList.remove("focus"); 166 }; 167 168 onButtonKeydown = (event) => {
169 const key = event.key; 170 let flag = false; 171 172 switch (key) { 173 case " ": 174 case "Enter": 175 case "ArrowDown": 176 case "Down": 177 this.openPopup(); 178 this.setFocusToFirstMenuitem(); 179 flag = true; 180 break; 181 182 case "Esc": 183 case "Escape": 184 this.closePopup(); 185 this.buttonNode.focus(); 186 flag = true; 187 break; 188 189 case "Up": 190 case "ArrowUp": 191 this.openPopup(); 192 this.setFocusToLastMenuitem(); 193 flag = true; 194 break; 195 196 default: 197 break; 198 } 199 200 if (flag) { 201 event.stopPropagation(); 202 event.preventDefault(); 203 } 204 }; 205 206 onButtonClick(event) { 207 if (this.isOpen()) { 208 this.closePopup(); 209 this.buttonNode.focus(); 210 } else { 211 this.openPopup(); 212 this.setFocusToFirstMenuitem(); 213 } 214 215 event.stopPropagation(); 216 event.preventDefault(); 217 } 218 219 onMenuitemKeydown(event) { 220 const tgt = event.currentTarget; 221 const key = event.key; 222 let flag = false; 223 224 const isPrintableCharacter = (str) => str.length === 1 && str.match(/\S/); 225 226 if (event.ctrlKey || event.altKey || event.metaKey) { 227 return; 228 } 229 230 if (event.shiftKey) { 231 if (isPrintableCharacter(key)) { 232 this.setFocusByFirstCharacter(tgt, key); 233 flag = true; 234 } 235 236 if (event.key === "Tab") { 237 this.buttonNode.focus(); 238 this.closePopup(); 239 flag = true; 240 } 241 } else { 242 switch (key) { 243 case " ": 244 window.location.href = tgt.href; 245 break; 246 247 case "Esc": 248 case "Escape": 249 this.closePopup(); 250 this.buttonNode.focus(); 251 flag = true; 252 break; 253 254 case "Up": 255 case "ArrowUp": 256 this.setFocusToPreviousMenuitem(tgt); 257 flag = true; 258 break; 259 260 case "ArrowDown": 261 case "Down": 262 this.setFocusToNextMenuitem(tgt); 263 flag = true; 264 break; 265 266 case "Home": 267 case "PageUp": 268 this.setFocusToFirstMenuitem(); 269 flag = true; 270 break; 271 272 case "End": 273 case "PageDown": 274 this.setFocusToLastMenuitem(); 275 flag = true; 276 break; 277 278 case "Tab": 279 this.closePopup(); 280 break; 281 282 default: 283 if (isPrintableCharacter(key)) { 284 this.setFocusByFirstCharacter(tgt, key); 285 flag = true; 286 } 287 break; 288 } 289 } 290 291 if (flag) { 292 event.stopPropagation(); 293 event.preventDefault(); 294 } 295 } 296 297 onMenuitemMouseover(event) { 298 const tgt = event.currentTarget; 299 tgt.focus(); 300 } 301 302 onBackgroundMousedown(event) { 303 if (!this.domNode.contains(event.target)) { 304 if (this.isOpen()) { 305 this.closePopup(); 306 this.buttonNode.focus(); 307 } 308 } 309 } 310} 311 312const menuButtons = document.querySelectorAll(".menu-button-links"); 313for (const button of menuButtons) { 314 new MenuButtonLinks(button); 315}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.