├── .gitignore ├── README.md ├── examples └── listbox │ ├── listbox-collapsible.js │ ├── listbox.css │ ├── listbox.html │ └── listbox.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.js ├── App.test.js ├── components ├── Button │ ├── Button.css │ ├── Button.js │ ├── README.md │ └── index.js ├── Dialog │ ├── Dialog.css │ ├── Dialog.js │ ├── README.md │ └── index.js ├── Listbox │ ├── Listbox.css │ ├── Listbox.js │ ├── README.md │ └── index.js ├── README.md └── index.js ├── index.css ├── index.js ├── logo.svg └── serviceWorker.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A11Y Components 2 | 3 | The purpose of this repository is to share what I've learned about creating accessible React components. It is not currently open source, but might be at some point in the future. 4 | 5 | If you're in interested in resources, my workflow, or the progress of this project, I'm putting together [a public Trello board](https://trello.com/b/nU9Zp4Iv/a11y-components) to keep myself organized. Feel free to peruse it! 6 | 7 | ## File Structure 8 | 9 | Several weeks ago, a friend recommended I check out the Shopify's [`polaris-react` repository](https://github.com/Shopify/polaris-react) when I was working on accessible modal dialogs. The way they've structured their files is 🌟 GORGEOUS 🌟 and it brings my soul joy. I _love_ an organized and functional file structure. 10 | 11 | So, here's what I've got: 12 | 13 | ``` 14 | src/ 15 | components/ 16 | Button/ 17 | Button.css 18 | Button.js 19 | README.md 20 | index.js 21 | Listbox/ 22 | ... 23 | ... 24 | README.md 25 | index.js 26 | App.js 27 | ... 28 | ``` 29 | 30 | Let's dive in! 31 | 32 | 1. [`src/App.js`](https://github.com/ashleemboyer/a11y-components/blob/master/src/App.js) is just a sandbox right now. I'll import components in there as I test them. 33 | 2. `src/components/` 34 | - [`README.md`](https://github.com/ashleemboyer/a11y-components/blob/master/src/components/README.md) will contain a list of all the components and maybe some high-level descriptions of them. [Shopify's README](https://github.com/Shopify/polaris-react/blob/master/src/components/README.md) at this level talks about how to use their components. 35 | - [`index.js`](https://github.com/ashleemboyer/a11y-components/blob/master/src/components/index.js) handles exporting components so they can be imported elsewhere without having to know their paths. Look at [how long the file is](https://github.com/Shopify/polaris-react/blob/master/src/components/index.ts) for polaris-react. 😱 36 | 3. `src/components/` 37 | - `.css` handles the styling for the component. 38 | - `.js` contains the React code for the component. 39 | - `README.md` documents the compnent with information like intended use, accessibility rules, etc. 40 | - `index.js` handles exporting the component from its directory. 41 | 42 | This structure is so clean to me, and I think it will encourage good naming conventions and consistent documentation. 43 | -------------------------------------------------------------------------------- /examples/listbox/listbox-collapsible.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ARIA Collapsible Dropdown Listbox Example 3 | * @function onload 4 | * @desc Initialize the listbox example once the page has loaded 5 | */ 6 | 7 | window.addEventListener("load", function() { 8 | var button = document.getElementById("exp_button"); 9 | var exListbox = new aria.Listbox(document.getElementById("exp_elem_list")); 10 | var listboxButton = new aria.ListboxButton(button, exListbox); 11 | }); 12 | 13 | var aria = aria || {}; 14 | 15 | aria.ListboxButton = function(button, listbox) { 16 | this.button = button; 17 | this.listbox = listbox; 18 | this.registerEvents(); 19 | }; 20 | 21 | aria.ListboxButton.prototype.registerEvents = function() { 22 | this.button.addEventListener("click", this.showListbox.bind(this)); 23 | this.button.addEventListener("keyup", this.checkShow.bind(this)); 24 | this.listbox.listboxNode.addEventListener( 25 | "blur", 26 | this.hideListbox.bind(this) 27 | ); 28 | this.listbox.listboxNode.addEventListener( 29 | "keydown", 30 | this.checkHide.bind(this) 31 | ); 32 | this.listbox.setHandleFocusChange(this.onFocusChange.bind(this)); 33 | }; 34 | 35 | aria.ListboxButton.prototype.checkShow = function(evt) { 36 | var key = evt.which || evt.keyCode; 37 | 38 | switch (key) { 39 | case aria.KeyCode.UP: 40 | case aria.KeyCode.DOWN: 41 | evt.preventDefault(); 42 | this.showListbox(); 43 | this.listbox.checkKeyPress(evt); 44 | break; 45 | } 46 | }; 47 | 48 | aria.ListboxButton.prototype.checkHide = function(evt) { 49 | var key = evt.which || evt.keyCode; 50 | 51 | switch (key) { 52 | case aria.KeyCode.RETURN: 53 | case aria.KeyCode.ESC: 54 | evt.preventDefault(); 55 | this.hideListbox(); 56 | this.button.focus(); 57 | break; 58 | } 59 | }; 60 | 61 | aria.ListboxButton.prototype.showListbox = function() { 62 | aria.Utils.removeClass(this.listbox.listboxNode, "hidden"); 63 | this.button.setAttribute("aria-expanded", "true"); 64 | this.listbox.listboxNode.focus(); 65 | }; 66 | 67 | aria.ListboxButton.prototype.hideListbox = function() { 68 | aria.Utils.addClass(this.listbox.listboxNode, "hidden"); 69 | this.button.removeAttribute("aria-expanded"); 70 | }; 71 | 72 | aria.ListboxButton.prototype.onFocusChange = function(focusedItem) { 73 | this.button.innerText = focusedItem.innerText; 74 | }; 75 | -------------------------------------------------------------------------------- /examples/listbox/listbox.css: -------------------------------------------------------------------------------- 1 | .annotate { 2 | font-style: italic; 3 | color: #366ed4; 4 | } 5 | 6 | .listbox-area { 7 | padding: 20px; 8 | background: #eee; 9 | border: 1px solid #aaa; 10 | font-size: 0; 11 | } 12 | 13 | .left-area, 14 | .right-area { 15 | box-sizing: border-box; 16 | display: inline-block; 17 | font-size: 14px; 18 | vertical-align: top; 19 | width: 50%; 20 | } 21 | 22 | .left-area { 23 | padding-right: 10px; 24 | } 25 | 26 | .right-area { 27 | padding-left: 10px; 28 | } 29 | 30 | [role="listbox"] { 31 | min-height: 18em; 32 | padding: 0; 33 | background: white; 34 | border: 1px solid #aaa; 35 | } 36 | 37 | [role="option"] { 38 | display: block; 39 | padding: 0 1em 0 1.5em; 40 | position: relative; 41 | line-height: 1.8em; 42 | } 43 | 44 | [role="option"].focused { 45 | background: #bde4ff; 46 | } 47 | 48 | [role="option"][aria-selected="true"]::before { 49 | content: "✓"; 50 | position: absolute; 51 | left: 0.5em; 52 | } 53 | 54 | button { 55 | font-size: 16px; 56 | } 57 | 58 | button[aria-disabled="true"] { 59 | opacity: 0.5; 60 | } 61 | 62 | .move-right-btn { 63 | padding-right: 20px; 64 | position: relative; 65 | } 66 | 67 | .move-right-btn::after { 68 | content: " "; 69 | height: 10px; 70 | width: 12px; 71 | background-image: url("../imgs/Arrows-Right-icon.png"); 72 | background-position: center right; 73 | position: absolute; 74 | right: 2px; 75 | top: 6px; 76 | } 77 | 78 | .move-left-btn { 79 | padding-left: 20px; 80 | position: relative; 81 | } 82 | 83 | .move-left-btn::after { 84 | content: " "; 85 | height: 10px; 86 | width: 12px; 87 | background-image: url("../imgs/Arrows-Left-icon.png"); 88 | background-position: center left; 89 | position: absolute; 90 | left: 2px; 91 | top: 6px; 92 | } 93 | 94 | #ss_elem_list { 95 | max-height: 18em; 96 | overflow-y: auto; 97 | position: relative; 98 | } 99 | 100 | #exp_button { 101 | border-radius: 0; 102 | font-size: 16px; 103 | text-align: left; 104 | padding: 5px 10px; 105 | width: 150px; 106 | position: relative; 107 | } 108 | 109 | #exp_button::after { 110 | width: 0; 111 | height: 0; 112 | border-left: 8px solid transparent; 113 | border-right: 8px solid transparent; 114 | border-top: 8px solid #aaa; 115 | content: " "; 116 | position: absolute; 117 | right: 5px; 118 | top: 10px; 119 | } 120 | 121 | #exp_button[aria-expanded="true"]::after { 122 | width: 0; 123 | height: 0; 124 | border-left: 8px solid transparent; 125 | border-right: 8px solid transparent; 126 | border-top: 0; 127 | border-bottom: 8px solid #aaa; 128 | content: " "; 129 | position: absolute; 130 | right: 5px; 131 | top: 10px; 132 | } 133 | 134 | #exp_elem_list { 135 | border-top: 0; 136 | max-height: 10em; 137 | overflow-y: auto; 138 | position: absolute; 139 | margin: 0; 140 | width: 148px; 141 | } 142 | 143 | .hidden { 144 | display: none; 145 | } 146 | 147 | .toolbar { 148 | font-size: 0; 149 | } 150 | 151 | .toolbar-item { 152 | border: 1px solid #aaa; 153 | background: #ccc; 154 | } 155 | 156 | .toolbar-item[aria-disabled="false"]:focus { 157 | background-color: #eee; 158 | } 159 | 160 | .offscreen { 161 | clip: rect(1px 1px 1px 1px); 162 | clip: rect(1px, 1px, 1px, 1px); 163 | font-size: 14px; 164 | height: 1px; 165 | overflow: hidden; 166 | position: absolute; 167 | white-space: nowrap; 168 | width: 1px; 169 | } 170 | -------------------------------------------------------------------------------- /examples/listbox/listbox.html: -------------------------------------------------------------------------------- 1 |

2 | Choose your favorite transuranic element (actinide or transactinide). 3 |

4 |
5 |
6 | 7 | Choose an element: 8 | 9 |
10 | 17 | 103 |
104 |
105 |
106 | -------------------------------------------------------------------------------- /examples/listbox/listbox.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This content is licensed according to the W3C Software License at 3 | * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 4 | */ 5 | /** 6 | * @namespace aria 7 | */ 8 | var aria = aria || {}; 9 | 10 | /** 11 | * @constructor 12 | * 13 | * @desc 14 | * Listbox object representing the state and interactions for a listbox widget 15 | * 16 | * @param listboxNode 17 | * The DOM node pointing to the listbox 18 | */ 19 | aria.Listbox = function(listboxNode) { 20 | this.listboxNode = listboxNode; 21 | this.activeDescendant = this.listboxNode.getAttribute( 22 | "aria-activedescendant" 23 | ); 24 | this.multiselectable = this.listboxNode.hasAttribute("aria-multiselectable"); 25 | this.moveUpDownEnabled = false; 26 | this.siblingList = null; 27 | this.upButton = null; 28 | this.downButton = null; 29 | this.moveButton = null; 30 | this.keysSoFar = ""; 31 | this.handleFocusChange = function() {}; 32 | this.handleItemChange = function(event, items) {}; 33 | this.registerEvents(); 34 | }; 35 | 36 | /** 37 | * @desc 38 | * Register events for the listbox interactions 39 | */ 40 | aria.Listbox.prototype.registerEvents = function() { 41 | this.listboxNode.addEventListener("focus", this.setupFocus.bind(this)); 42 | this.listboxNode.addEventListener("keydown", this.checkKeyPress.bind(this)); 43 | this.listboxNode.addEventListener("click", this.checkClickItem.bind(this)); 44 | }; 45 | 46 | /** 47 | * @desc 48 | * If there is no activeDescendant, focus on the first option 49 | */ 50 | aria.Listbox.prototype.setupFocus = function() { 51 | if (this.activeDescendant) { 52 | return; 53 | } 54 | 55 | this.focusFirstItem(); 56 | }; 57 | 58 | /** 59 | * @desc 60 | * Focus on the first option 61 | */ 62 | aria.Listbox.prototype.focusFirstItem = function() { 63 | var firstItem; 64 | 65 | firstItem = this.listboxNode.querySelector('[role="option"]'); 66 | 67 | if (firstItem) { 68 | this.focusItem(firstItem); 69 | } 70 | }; 71 | 72 | /** 73 | * @desc 74 | * Focus on the last option 75 | */ 76 | aria.Listbox.prototype.focusLastItem = function() { 77 | var itemList = this.listboxNode.querySelectorAll('[role="option"]'); 78 | 79 | if (itemList.length) { 80 | this.focusItem(itemList[itemList.length - 1]); 81 | } 82 | }; 83 | 84 | /** 85 | * @desc 86 | * Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects 87 | * an item. 88 | * 89 | * @param evt 90 | * The keydown event object 91 | */ 92 | aria.Listbox.prototype.checkKeyPress = function(evt) { 93 | var key = evt.which || evt.keyCode; 94 | var nextItem = document.getElementById(this.activeDescendant); 95 | 96 | if (!nextItem) { 97 | return; 98 | } 99 | 100 | switch (key) { 101 | case aria.KeyCode.PAGE_UP: 102 | case aria.KeyCode.PAGE_DOWN: 103 | if (this.moveUpDownEnabled) { 104 | evt.preventDefault(); 105 | 106 | if (key === aria.KeyCode.PAGE_UP) { 107 | this.moveUpItems(); 108 | } else { 109 | this.moveDownItems(); 110 | } 111 | } 112 | 113 | break; 114 | case aria.KeyCode.UP: 115 | case aria.KeyCode.DOWN: 116 | evt.preventDefault(); 117 | 118 | if (this.moveUpDownEnabled && evt.altKey) { 119 | if (key === aria.KeyCode.UP) { 120 | this.moveUpItems(); 121 | } else { 122 | this.moveDownItems(); 123 | } 124 | return; 125 | } 126 | 127 | if (key === aria.KeyCode.UP) { 128 | nextItem = nextItem.previousElementSibling; 129 | } else { 130 | nextItem = nextItem.nextElementSibling; 131 | } 132 | 133 | if (nextItem) { 134 | this.focusItem(nextItem); 135 | } 136 | 137 | break; 138 | case aria.KeyCode.HOME: 139 | evt.preventDefault(); 140 | this.focusFirstItem(); 141 | break; 142 | case aria.KeyCode.END: 143 | evt.preventDefault(); 144 | this.focusLastItem(); 145 | break; 146 | case aria.KeyCode.SPACE: 147 | evt.preventDefault(); 148 | this.toggleSelectItem(nextItem); 149 | break; 150 | case aria.KeyCode.BACKSPACE: 151 | case aria.KeyCode.DELETE: 152 | case aria.KeyCode.RETURN: 153 | if (!this.moveButton) { 154 | return; 155 | } 156 | 157 | var keyshortcuts = this.moveButton.getAttribute("aria-keyshortcuts"); 158 | if (key === aria.KeyCode.RETURN && keyshortcuts.indexOf("Enter") === -1) { 159 | return; 160 | } 161 | if ( 162 | (key === aria.KeyCode.BACKSPACE || key === aria.KeyCode.DELETE) && 163 | keyshortcuts.indexOf("Delete") === -1 164 | ) { 165 | return; 166 | } 167 | 168 | evt.preventDefault(); 169 | 170 | var nextUnselected = nextItem.nextElementSibling; 171 | while (nextUnselected) { 172 | if (nextUnselected.getAttribute("aria-selected") != "true") { 173 | break; 174 | } 175 | nextUnselected = nextUnselected.nextElementSibling; 176 | } 177 | if (!nextUnselected) { 178 | nextUnselected = nextItem.previousElementSibling; 179 | while (nextUnselected) { 180 | if (nextUnselected.getAttribute("aria-selected") != "true") { 181 | break; 182 | } 183 | nextUnselected = nextUnselected.previousElementSibling; 184 | } 185 | } 186 | 187 | this.moveItems(); 188 | 189 | if (!this.activeDescendant && nextUnselected) { 190 | this.focusItem(nextUnselected); 191 | } 192 | break; 193 | default: 194 | var itemToFocus = this.findItemToFocus(key); 195 | if (itemToFocus) { 196 | this.focusItem(itemToFocus); 197 | } 198 | break; 199 | } 200 | }; 201 | 202 | aria.Listbox.prototype.findItemToFocus = function(key) { 203 | var itemList = this.listboxNode.querySelectorAll('[role="option"]'); 204 | var character = String.fromCharCode(key); 205 | 206 | if (!this.keysSoFar) { 207 | for (var i = 0; i < itemList.length; i++) { 208 | if (itemList[i].getAttribute("id") == this.activeDescendant) { 209 | this.searchIndex = i; 210 | } 211 | } 212 | } 213 | this.keysSoFar += character; 214 | this.clearKeysSoFarAfterDelay(); 215 | 216 | var nextMatch = this.findMatchInRange( 217 | itemList, 218 | this.searchIndex + 1, 219 | itemList.length 220 | ); 221 | if (!nextMatch) { 222 | nextMatch = this.findMatchInRange(itemList, 0, this.searchIndex); 223 | } 224 | return nextMatch; 225 | }; 226 | 227 | aria.Listbox.prototype.clearKeysSoFarAfterDelay = function() { 228 | if (this.keyClear) { 229 | clearTimeout(this.keyClear); 230 | this.keyClear = null; 231 | } 232 | this.keyClear = setTimeout( 233 | function() { 234 | this.keysSoFar = ""; 235 | this.keyClear = null; 236 | }.bind(this), 237 | 500 238 | ); 239 | }; 240 | 241 | aria.Listbox.prototype.findMatchInRange = function(list, startIndex, endIndex) { 242 | // Find the first item starting with the keysSoFar substring, searching in 243 | // the specified range of items 244 | for (var n = startIndex; n < endIndex; n++) { 245 | var label = list[n].innerText; 246 | if (label && label.toUpperCase().indexOf(this.keysSoFar) === 0) { 247 | return list[n]; 248 | } 249 | } 250 | return null; 251 | }; 252 | 253 | /** 254 | * @desc 255 | * Check if an item is clicked on. If so, focus on it and select it. 256 | * 257 | * @param evt 258 | * The click event object 259 | */ 260 | aria.Listbox.prototype.checkClickItem = function(evt) { 261 | if (evt.target.getAttribute("role") === "option") { 262 | this.focusItem(evt.target); 263 | this.toggleSelectItem(evt.target); 264 | } 265 | }; 266 | 267 | /** 268 | * @desc 269 | * Toggle the aria-selected value 270 | * 271 | * @param element 272 | * The element to select 273 | */ 274 | aria.Listbox.prototype.toggleSelectItem = function(element) { 275 | if (this.multiselectable) { 276 | element.setAttribute( 277 | "aria-selected", 278 | element.getAttribute("aria-selected") === "true" ? "false" : "true" 279 | ); 280 | 281 | if (this.moveButton) { 282 | if (this.listboxNode.querySelector('[aria-selected="true"]')) { 283 | this.moveButton.setAttribute("aria-disabled", "false"); 284 | } else { 285 | this.moveButton.setAttribute("aria-disabled", "true"); 286 | } 287 | } 288 | } 289 | }; 290 | 291 | /** 292 | * @desc 293 | * Defocus the specified item 294 | * 295 | * @param element 296 | * The element to defocus 297 | */ 298 | aria.Listbox.prototype.defocusItem = function(element) { 299 | if (!element) { 300 | return; 301 | } 302 | if (!this.multiselectable) { 303 | element.removeAttribute("aria-selected"); 304 | } 305 | aria.Utils.removeClass(element, "focused"); 306 | }; 307 | 308 | /** 309 | * @desc 310 | * Focus on the specified item 311 | * 312 | * @param element 313 | * The element to focus 314 | */ 315 | aria.Listbox.prototype.focusItem = function(element) { 316 | this.defocusItem(document.getElementById(this.activeDescendant)); 317 | if (!this.multiselectable) { 318 | element.setAttribute("aria-selected", "true"); 319 | } 320 | aria.Utils.addClass(element, "focused"); 321 | this.listboxNode.setAttribute("aria-activedescendant", element.id); 322 | this.activeDescendant = element.id; 323 | 324 | if (this.listboxNode.scrollHeight > this.listboxNode.clientHeight) { 325 | var scrollBottom = 326 | this.listboxNode.clientHeight + this.listboxNode.scrollTop; 327 | var elementBottom = element.offsetTop + element.offsetHeight; 328 | if (elementBottom > scrollBottom) { 329 | this.listboxNode.scrollTop = 330 | elementBottom - this.listboxNode.clientHeight; 331 | } else if (element.offsetTop < this.listboxNode.scrollTop) { 332 | this.listboxNode.scrollTop = element.offsetTop; 333 | } 334 | } 335 | 336 | if (!this.multiselectable && this.moveButton) { 337 | this.moveButton.setAttribute("aria-disabled", false); 338 | } 339 | 340 | this.checkUpDownButtons(); 341 | this.handleFocusChange(element); 342 | }; 343 | 344 | /** 345 | * @desc 346 | * Enable/disable the up/down arrows based on the activeDescendant. 347 | */ 348 | aria.Listbox.prototype.checkUpDownButtons = function() { 349 | var activeElement = document.getElementById(this.activeDescendant); 350 | 351 | if (!this.moveUpDownEnabled) { 352 | return false; 353 | } 354 | 355 | if (!activeElement) { 356 | this.upButton.setAttribute("aria-disabled", "true"); 357 | this.downButton.setAttribute("aria-disabled", "true"); 358 | return; 359 | } 360 | 361 | if (this.upButton) { 362 | if (activeElement.previousElementSibling) { 363 | this.upButton.setAttribute("aria-disabled", false); 364 | } else { 365 | this.upButton.setAttribute("aria-disabled", "true"); 366 | } 367 | } 368 | 369 | if (this.downButton) { 370 | if (activeElement.nextElementSibling) { 371 | this.downButton.setAttribute("aria-disabled", false); 372 | } else { 373 | this.downButton.setAttribute("aria-disabled", "true"); 374 | } 375 | } 376 | }; 377 | 378 | /** 379 | * @desc 380 | * Add the specified items to the listbox. Assumes items are valid options. 381 | * 382 | * @param items 383 | * An array of items to add to the listbox 384 | */ 385 | aria.Listbox.prototype.addItems = function(items) { 386 | if (!items || !items.length) { 387 | return false; 388 | } 389 | 390 | items.forEach( 391 | function(item) { 392 | this.defocusItem(item); 393 | this.toggleSelectItem(item); 394 | this.listboxNode.append(item); 395 | }.bind(this) 396 | ); 397 | 398 | if (!this.activeDescendant) { 399 | this.focusItem(items[0]); 400 | } 401 | 402 | this.handleItemChange("added", items); 403 | }; 404 | 405 | /** 406 | * @desc 407 | * Remove all of the selected items from the listbox; Removes the focused items 408 | * in a single select listbox and the items with aria-selected in a multi 409 | * select listbox. 410 | * 411 | * @returns items 412 | * An array of items that were removed from the listbox 413 | */ 414 | aria.Listbox.prototype.deleteItems = function() { 415 | var itemsToDelete; 416 | 417 | if (this.multiselectable) { 418 | itemsToDelete = this.listboxNode.querySelectorAll('[aria-selected="true"]'); 419 | } else if (this.activeDescendant) { 420 | itemsToDelete = [document.getElementById(this.activeDescendant)]; 421 | } 422 | 423 | if (!itemsToDelete || !itemsToDelete.length) { 424 | return []; 425 | } 426 | 427 | itemsToDelete.forEach( 428 | function(item) { 429 | item.remove(); 430 | 431 | if (item.id === this.activeDescendant) { 432 | this.clearActiveDescendant(); 433 | } 434 | }.bind(this) 435 | ); 436 | 437 | this.handleItemChange("removed", itemsToDelete); 438 | 439 | return itemsToDelete; 440 | }; 441 | 442 | aria.Listbox.prototype.clearActiveDescendant = function() { 443 | this.activeDescendant = null; 444 | this.listboxNode.setAttribute("aria-activedescendant", null); 445 | 446 | if (this.moveButton) { 447 | this.moveButton.setAttribute("aria-disabled", "true"); 448 | } 449 | 450 | this.checkUpDownButtons(); 451 | }; 452 | 453 | /** 454 | * @desc 455 | * Shifts the currently focused item up on the list. No shifting occurs if the 456 | * item is already at the top of the list. 457 | */ 458 | aria.Listbox.prototype.moveUpItems = function() { 459 | var previousItem; 460 | 461 | if (!this.activeDescendant) { 462 | return; 463 | } 464 | 465 | currentItem = document.getElementById(this.activeDescendant); 466 | previousItem = currentItem.previousElementSibling; 467 | 468 | if (previousItem) { 469 | this.listboxNode.insertBefore(currentItem, previousItem); 470 | this.handleItemChange("moved_up", [currentItem]); 471 | } 472 | 473 | this.checkUpDownButtons(); 474 | }; 475 | 476 | /** 477 | * @desc 478 | * Shifts the currently focused item down on the list. No shifting occurs if 479 | * the item is already at the end of the list. 480 | */ 481 | aria.Listbox.prototype.moveDownItems = function() { 482 | var nextItem; 483 | 484 | if (!this.activeDescendant) { 485 | return; 486 | } 487 | 488 | currentItem = document.getElementById(this.activeDescendant); 489 | nextItem = currentItem.nextElementSibling; 490 | 491 | if (nextItem) { 492 | this.listboxNode.insertBefore(nextItem, currentItem); 493 | this.handleItemChange("moved_down", [currentItem]); 494 | } 495 | 496 | this.checkUpDownButtons(); 497 | }; 498 | 499 | /** 500 | * @desc 501 | * Delete the currently selected items and add them to the sibling list. 502 | */ 503 | aria.Listbox.prototype.moveItems = function() { 504 | if (!this.siblingList) { 505 | return; 506 | } 507 | 508 | var itemsToMove = this.deleteItems(); 509 | this.siblingList.addItems(itemsToMove); 510 | }; 511 | 512 | /** 513 | * @desc 514 | * Enable Up/Down controls to shift items up and down. 515 | * 516 | * @param upButton 517 | * Up button to trigger up shift 518 | * 519 | * @param downButton 520 | * Down button to trigger down shift 521 | */ 522 | aria.Listbox.prototype.enableMoveUpDown = function(upButton, downButton) { 523 | this.moveUpDownEnabled = true; 524 | this.upButton = upButton; 525 | this.downButton = downButton; 526 | upButton.addEventListener("click", this.moveUpItems.bind(this)); 527 | downButton.addEventListener("click", this.moveDownItems.bind(this)); 528 | }; 529 | 530 | /** 531 | * @desc 532 | * Enable Move controls. Moving removes selected items from the current 533 | * list and adds them to the sibling list. 534 | * 535 | * @param button 536 | * Move button to trigger delete 537 | * 538 | * @param siblingList 539 | * Listbox to move items to 540 | */ 541 | aria.Listbox.prototype.setupMove = function(button, siblingList) { 542 | this.siblingList = siblingList; 543 | this.moveButton = button; 544 | button.addEventListener("click", this.moveItems.bind(this)); 545 | }; 546 | 547 | aria.Listbox.prototype.setHandleItemChange = function(handlerFn) { 548 | this.handleItemChange = handlerFn; 549 | }; 550 | 551 | aria.Listbox.prototype.setHandleFocusChange = function(focusChangeHandler) { 552 | this.handleFocusChange = focusChangeHandler; 553 | }; 554 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "accessibility", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "prop-types": "^15.7.2", 7 | "react": "^16.11.0", 8 | "react-dom": "^16.11.0", 9 | "react-scripts": "3.2.0" 10 | }, 11 | "scripts": { 12 | "start": "react-scripts start", 13 | "build": "react-scripts build", 14 | "test": "react-scripts test", 15 | "eject": "react-scripts eject" 16 | }, 17 | "eslintConfig": { 18 | "extends": "react-app" 19 | }, 20 | "browserslist": { 21 | "production": [ 22 | ">0.2%", 23 | "not dead", 24 | "not op_mini all" 25 | ], 26 | "development": [ 27 | "last 1 chrome version", 28 | "last 1 firefox version", 29 | "last 1 safari version" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleemboyer/a11y-components-og/d83e7954e78efac287ae0dc597ee48c431ec1e4c/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleemboyer/a11y-components-og/d83e7954e78efac287ae0dc597ee48c431ec1e4c/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleemboyer/a11y-components-og/d83e7954e78efac287ae0dc597ee48c431ec1e4c/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { Dialog, Button } from "./components"; 3 | 4 | const App = () => { 5 | const openDialogRef = useRef(); 6 | const [showDialog, setShowDialog] = useState(false); 7 | 8 | return ( 9 |
10 | 20 | 21 | 22 | {showDialog && ( 23 | { 29 | setShowDialog(false); 30 | } 31 | }} 32 | secondaryButton={{ 33 | text: "Nope", 34 | onClick: () => { 35 | console.log("clicked nope"); 36 | } 37 | }} 38 | closeDialog={() => { 39 | setShowDialog(false); 40 | }} 41 | > 42 |

This is the body of the dialog.

43 |
    44 |
  • "Cancel" closes the dialog
  • 45 |
  • "Confirm" also closes the dialog
  • 46 |
47 | 48 |
49 | )} 50 |
51 | ); 52 | }; 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/Button/Button.css: -------------------------------------------------------------------------------- 1 | button { 2 | border: 2px solid black; 3 | background-color: white; 4 | font-weight: bold; 5 | font-size: 1.2rem; 6 | padding: 8px 10px; 7 | border-radius: 4px; 8 | } 9 | 10 | button:hover { 11 | cursor: pointer; 12 | background-color: #eee; 13 | } 14 | 15 | button:focus { 16 | outline: none; 17 | box-shadow: 0px 0px 0px 3px #c2185b; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Button/Button.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import "./Button.css"; 5 | 6 | const Button = ({ children, onClick, providedRef }) => { 7 | const defaultRef = useRef(null); 8 | const buttonRef = providedRef || defaultRef; 9 | 10 | return ( 11 | 20 | ); 21 | }; 22 | 23 | Button.propTypes = { 24 | children: PropTypes.node.isRequired, 25 | onClick: PropTypes.func.isRequired 26 | }; 27 | 28 | export default Button; 29 | -------------------------------------------------------------------------------- /src/components/Button/README.md: -------------------------------------------------------------------------------- 1 | # Button 2 | 3 | ## Properties 4 | 5 | ## Usage 6 | 7 | ## Accessibility 8 | 9 | ## Examples 10 | -------------------------------------------------------------------------------- /src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import Button from "./Button"; 2 | 3 | export { Button }; 4 | -------------------------------------------------------------------------------- /src/components/Dialog/Dialog.css: -------------------------------------------------------------------------------- 1 | #dialog-background { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | position: absolute; 6 | top: 0; 7 | right: 0; 8 | bottom: 0; 9 | left: 0; 10 | background-color: #37474fdd; 11 | } 12 | 13 | #dialog { 14 | display: flex; 15 | flex-direction: column; 16 | background-color: #ffffff; 17 | width: 100%; 18 | margin: 24px; 19 | border-radius: 12px; 20 | max-width: 600px; 21 | } 22 | 23 | #dialog-header h2 { 24 | margin: 0; 25 | padding: 20px; 26 | } 27 | 28 | #dialog-body { 29 | padding-left: 20px; 30 | padding-right: 20px; 31 | } 32 | 33 | #dialog-footer { 34 | display: flex; 35 | justify-content: flex-end; 36 | padding: 20px; 37 | } 38 | 39 | #dialog-footer :not(:last-child) { 40 | margin-right: 8px; 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Dialog/Dialog.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Button } from "../"; 4 | import "./Dialog.css"; 5 | 6 | const Dialog = ({ 7 | title, 8 | closeDialog, 9 | openerRef, 10 | primaryButton, 11 | secondaryButton, 12 | children 13 | }) => { 14 | const dialogRef = useRef(); 15 | const [listenersAdded, setListenersAdded] = useState(false); 16 | 17 | const close = () => { 18 | openerRef.current.focus(); 19 | closeDialog(); 20 | }; 21 | 22 | useEffect(() => { 23 | if (listenersAdded) { 24 | return; 25 | } 26 | 27 | if (dialogRef.current) { 28 | const focusableElements = dialogRef.current.querySelectorAll( 29 | "button,select" 30 | ); 31 | const firstFocusableElement = focusableElements[0]; 32 | const lastFocusableElement = 33 | focusableElements[focusableElements.length - 1]; 34 | firstFocusableElement.focus(); 35 | 36 | firstFocusableElement.addEventListener("keydown", e => { 37 | if (e.keyCode === 9 && e.shiftKey) { 38 | e.preventDefault(); 39 | lastFocusableElement.focus(); 40 | } 41 | }); 42 | 43 | lastFocusableElement.addEventListener("keydown", e => { 44 | if ((e.keyCode === 9) & !e.shiftKey) { 45 | e.preventDefault(); 46 | firstFocusableElement.focus(); 47 | } 48 | }); 49 | 50 | dialogRef.current.addEventListener("keydown", e => { 51 | if (e.keyCode === 27) { 52 | e.preventDefault(); 53 | closeDialog(); 54 | } 55 | }); 56 | 57 | setListenersAdded(true); 58 | } 59 | }); 60 | 61 | return ( 62 |
63 | 87 |
88 | ); 89 | }; 90 | 91 | Dialog.propTypes = { 92 | title: PropTypes.string.isRequired, 93 | closeDialog: PropTypes.func.isRequired, 94 | openerRef: PropTypes.oneOfType([ 95 | PropTypes.func, 96 | PropTypes.shape({ current: PropTypes.instanceOf(Element) }) 97 | ]).isRequired, 98 | primaryButton: PropTypes.shape({ 99 | text: PropTypes.string, 100 | onClick: PropTypes.func 101 | }).isRequired, 102 | secondaryButton: PropTypes.shape({ 103 | text: PropTypes.string, 104 | onClick: PropTypes.func 105 | }), 106 | children: PropTypes.oneOfType([ 107 | PropTypes.arrayOf(PropTypes.node), 108 | PropTypes.node 109 | ]).isRequired 110 | }; 111 | 112 | export default Dialog; 113 | -------------------------------------------------------------------------------- /src/components/Dialog/README.md: -------------------------------------------------------------------------------- 1 | # Dialog 2 | 3 | ## Properties 4 | 5 | ## Usage 6 | 7 | ## Accessibility 8 | 9 | ## Examples 10 | -------------------------------------------------------------------------------- /src/components/Dialog/index.js: -------------------------------------------------------------------------------- 1 | import Dialog from "./Dialog"; 2 | 3 | export { Dialog }; 4 | -------------------------------------------------------------------------------- /src/components/Listbox/Listbox.css: -------------------------------------------------------------------------------- 1 | .listbox-area { 2 | padding: 20px; 3 | background: #eee; 4 | border: 1px solid #aaa; 5 | font-size: 0; 6 | } 7 | 8 | .left-area { 9 | display: inline-block; 10 | font-size: 1rem; 11 | vertical-align: top; 12 | width: 50%; 13 | padding-right: 10px; 14 | } 15 | 16 | [role="listbox"] { 17 | padding: 0; 18 | background: white; 19 | border: 1px solid #aaa; 20 | } 21 | 22 | [role="option"] { 23 | display: block; 24 | padding: 0 1em 0 1.5em; 25 | position: relative; 26 | line-height: 1.8; 27 | cursor: pointer; 28 | } 29 | 30 | [role="option"].focused { 31 | background: #bde4ff; 32 | } 33 | 34 | [role="option"][aria-selected="true"]::before { 35 | content: "✓"; 36 | position: absolute; 37 | left: 0.5em; 38 | color: green; 39 | } 40 | 41 | #exp_wrapper { 42 | position: relative; 43 | } 44 | 45 | #exp_elem { 46 | display: block; 47 | margin-bottom: 4px; 48 | } 49 | 50 | #exp_button { 51 | border: 1px solid grey; 52 | border-radius: 6px; 53 | font-size: 1rem; 54 | text-align: left; 55 | padding: 5px 10px; 56 | width: 160px; 57 | position: relative; 58 | } 59 | 60 | #exp_button::after { 61 | width: 0; 62 | height: 0; 63 | border-left: 6px solid transparent; 64 | border-right: 6px solid transparent; 65 | border-top: 8px solid grey; 66 | content: " "; 67 | position: absolute; 68 | right: 6px; 69 | top: 10px; 70 | } 71 | 72 | #exp_button[aria-expanded="true"]::after { 73 | width: 0; 74 | height: 0; 75 | border-left: 6px solid transparent; 76 | border-right: 6px solid transparent; 77 | border-top: 0; 78 | border-bottom: 8px solid grey; 79 | content: " "; 80 | position: absolute; 81 | right: 6px; 82 | top: 10px; 83 | } 84 | 85 | #exp_elem_list { 86 | border: 1px solid grey; 87 | border-radius: 4px; 88 | max-height: 10em; 89 | overflow-y: auto; 90 | position: absolute; 91 | margin: 0; 92 | width: 160px; 93 | } 94 | 95 | .hidden { 96 | display: none; 97 | } 98 | -------------------------------------------------------------------------------- /src/components/Listbox/Listbox.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { Button } from ".."; 5 | 6 | import "./Listbox.css"; 7 | 8 | const optionIdStringPrefix = "option_"; 9 | 10 | const getIndexOfOption = element => { 11 | if (element.id.startsWith(optionIdStringPrefix)) { 12 | return +element.id.substring(optionIdStringPrefix.length); 13 | } 14 | 15 | return -1; 16 | }; 17 | 18 | const generateOptionId = indexOfOption => { 19 | if (indexOfOption < 0) { 20 | return ""; 21 | } 22 | 23 | return `${optionIdStringPrefix}${indexOfOption}`; 24 | }; 25 | 26 | const Listbox = ({ options, label, onChange }) => { 27 | const [expanded, setExpanded] = useState(false); 28 | const [indexOfSelectedOption, setIndexOfSelectedOption] = useState(-1); 29 | 30 | const selectOption = indexOfOption => { 31 | onChange(options[indexOfOption]); 32 | setIndexOfSelectedOption(indexOfOption); 33 | }; 34 | 35 | const focusElement = element => { 36 | element.className = "focused"; 37 | }; 38 | 39 | const defocusElement = element => { 40 | element.className = ""; 41 | }; 42 | 43 | const focusFirstOption = () => { 44 | const listboxNode = document.getElementById("exp_elem_list"); 45 | const firstOption = listboxNode.querySelector('[role="option"]'); 46 | 47 | if (firstOption) { 48 | selectOption(0); 49 | } 50 | }; 51 | 52 | return ( 53 |
54 |
55 | {label} 56 |
57 | 76 |
    83 | {options.map((option, index) => { 84 | const listItemId = generateOptionId(index); 85 | return ( 86 |
  • { 88 | setExpanded(false); 89 | selectOption(index); 90 | }} 91 | onMouseEnter={({ target }) => { 92 | focusElement(target); 93 | }} 94 | onMouseLeave={({ target }) => { 95 | defocusElement(target); 96 | }} 97 | className={index === indexOfSelectedOption ? "focused" : ""} 98 | id={listItemId} 99 | key={listItemId} 100 | role="option" 101 | aria-selected={index === indexOfSelectedOption} 102 | > 103 | {option.name} 104 |
  • 105 | ); 106 | })} 107 |
108 |
109 |
110 |
111 | ); 112 | }; 113 | 114 | Listbox.propTypes = { 115 | options: PropTypes.arrayOf( 116 | PropTypes.shape({ 117 | name: PropTypes.string.isRequired, 118 | value: PropTypes.any.isRequired 119 | }) 120 | ).isRequired, 121 | label: PropTypes.node.isRequired 122 | }; 123 | 124 | export default Listbox; 125 | -------------------------------------------------------------------------------- /src/components/Listbox/README.md: -------------------------------------------------------------------------------- 1 | # Listbox 2 | 3 | Documentation link: [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices/#Listbox) 4 | 5 | ## Properties 6 | 7 | ## Usage 8 | 9 | ## Accessibility 10 | 11 | ## Examples 12 | -------------------------------------------------------------------------------- /src/components/Listbox/index.js: -------------------------------------------------------------------------------- 1 | import Listbox from "./Listbox"; 2 | 3 | export { Listbox }; 4 | -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | ## How to Add a Component 4 | 5 | 1. Add a new directory with the component's name to `src/components/`. Make sure it uses proper capitalization, like `` and ``. 6 | 2. Add a `.css`, `.js`, `README.md`, and `index.js` file to the directory from 1. 7 | 3. Update [the list below](#listofcomponents) with the name of the component and a link to its directory. 8 | 4. When the component is ready for use (AKA its `index.js` file is exporting the component), update the [`index.js` file in this directory](./index.js) to properly export the component. 9 | 10 | ## List of Components 11 | 12 | For now, this is a list of all the components. Eventually, this might be a guide on how to use these components. 13 | 14 | - [Button](./Button) 15 | - [Dialog](./Dialog) 16 | - [Listbox](./Listbox) 17 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { Button } from "./Button"; 2 | 3 | export { Dialog } from "./Dialog"; 4 | 5 | export { Listbox } from "./Listbox"; 6 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: sans-serif; 3 | box-sizing: border-box; 4 | } 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | --------------------------------------------------------------------------------