├── .gitignore ├── LICENSE ├── README.md ├── dist ├── demo-icons.svg ├── demo.css ├── demo.html ├── fancyselect.css ├── fancyselect.js ├── fancyselect.min.css └── fancyselect.min.js ├── gulpfile.babel.js ├── package-lock.json ├── package.json └── src ├── fancyselect.css └── fancyselect.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mohammed Bassit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FancySelect 2 | 3 | FancySelect examples 4 | 5 | A tiny drop-in replacement for native HTML single select elements written in vanilla ES6. 6 | 7 | [**View demo**](https://mdbassit.github.io/FancySelect/demo.html) 8 | 9 | ## Motivation 10 | 11 | **Why not just style a native select element with CSS?** 12 | Absolutely do that if it's enough for your use case. The main reason I created this project is because I needed a drop down list of icons that didn't suck. 13 | 14 | ## Features 15 | 16 | * Zero dependencies 17 | * Very easy to use 18 | * Customizable 19 | * Icon support 20 | * Fully accessible 21 | * Works on all modern browsers (no IE support) 22 | * No multi-select support (not accessible) 23 | 24 | ## Getting Started 25 | 26 | ### Basic usage 27 | 28 | Download the [latest version](https://github.com/mdbassit/FancySelect/releases/latest), and add the script and style to your page: 29 | ```html 30 | 31 | 32 | ``` 33 | 34 | Or include from a CDN (not recommended in production): 35 | ```html 36 | 37 | 38 | ``` 39 | 40 | The native select elements will be replaced automatically. 41 | 42 | ### Excluding specific elements 43 | 44 | Once you include FancySelect in your page, it will replace all native select elements with a custom listbox. If you would like to exclude some select elements, simply add the CSS class `fsb-ignore`. 45 | 46 | ```html 47 | 48 | 49 | 54 | 55 | 56 | 57 | 62 | ``` 63 | 64 | ### Updating options 65 | 66 | If there is a need to programmatically update a custom listbox's options, you first need to update the native select's options, then call FancySelect.update() with the native select element as an argument. 67 | 68 | ```js 69 | const myselect = document.getElementById('my-select'); 70 | const newItems = ['Californium', 'Vibranium', 'Uranium']; 71 | 72 | // Add new options to the native select element 73 | newItems.forEach(item => { 74 | const option = document.createElement('option'); 75 | option.textContent = item; 76 | myselect.appendChild(option); 77 | // Please don't add select options to the DOM individually in production. Use a documentFragment. 78 | }); 79 | 80 | // Update the custom listbox 81 | FancySelect.update(myselect); 82 | ``` 83 | 84 | ### Disabling and enabling 85 | 86 | FancySelect detects the disabled state of a native select automatically and applies it to the custom listbox. If a native select element's disabled state changes after FancySelect's initialization, calling FancySelect.update() will update it. 87 | 88 | ```js 89 | const myselect = document.getElementById('my-select'); 90 | 91 | // Disable the native select element 92 | myselect.disabled = true; 93 | 94 | // Update the custom listbox 95 | FancySelect.update(myselect); 96 | ``` 97 | 98 | ### Change and input events 99 | 100 | An `input` and a `change` events are triggered on the original native select element whenever a new option is selected on the custom select box. 101 | 102 | ### Customization 103 | 104 | The look and feel of the listbox and the popup button can be customized with CSS variables. 105 | 106 | ```html 107 |
108 | 109 | 114 |
115 | 116 | 138 | ``` 139 | 140 | Check out the included demo for more examples. 141 | 142 | ### Icons 143 | 144 | You can add icons to the select options by setting a data-icon attribute to a valid SVG sprite URI. 145 | 146 | ```html 147 | 148 | 153 | ``` 154 | The icons can also be defined in the same document. 155 | 156 | ```html 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 175 | ``` 176 | 177 | **Note:** Currently, only SVG sprites are supported, and that's unlikely to change in the future. [Learn more about SVG sprites](https://css-tricks.com/svg-sprites-use-better-icon-fonts/). 178 | 179 | 180 | 181 | ## Building from source 182 | 183 | Install the development dependencies: 184 | ```bash 185 | npm install 186 | ``` 187 | 188 | Run the build script: 189 | ```bash 190 | npm run build 191 | ``` 192 | The built version will be in the `dist` directory in both minified and full copies. 193 | 194 | ## Contributing 195 | 196 | If you find a bug or would like to implement a missing feature, please create an issue first before submitting a pull request (PR). 197 | 198 | When submitting a PR, please do not include the changes to the `dist` directory in your commits. 199 | 200 | ## Credit 201 | 202 | While this implementation may be different, most of the specifications were inspired by: 203 | 204 | * [Collapsible Dropdown Listbox Example | WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/examples/listbox/listbox-collapsible.html) 205 | * [<select> your poison](https://www.24a11y.com/2019/select-your-poison/) 206 | * [<select> your poison part 2: test all the things](https://www.24a11y.com/2019/select-your-poison-part-2/) 207 | 208 | ## License 209 | 210 | Copyright (c) 2021 Momo Bassit. 211 | **FancySelect** is licensed under the [MIT license](https://github.com/mdbassit/FancySelect/blob/main/LICENSE). 212 | -------------------------------------------------------------------------------- /dist/demo-icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /dist/demo.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 150vh; 3 | margin: 30px; 4 | color: #444; 5 | background-color: #eee; 6 | font-family: 'Lato', sans-serif; 7 | font-size: 16px; 8 | } 9 | 10 | h1 { 11 | margin-bottom: 1em; 12 | } 13 | 14 | label { 15 | display: block; 16 | margin-bottom: .5em; 17 | } 18 | 19 | .container { 20 | width: 100%; 21 | max-width: 500px; 22 | margin: 0 auto; 23 | } 24 | 25 | .example { 26 | margin-bottom: 1.5em; 27 | } 28 | 29 | .full-width select, 30 | .full-width .fsb-select { 31 | width: 100%; 32 | } 33 | 34 | .custom-style { 35 | --fsb-border: 0; 36 | --fsb-radius: 2em; 37 | --fsb-color: #fff; 38 | --fsb-background: #2F86A6; 39 | --fsb-padding: .75em 1.5em; 40 | --fsb-arrow-padding: 1.5em; 41 | --fsb-arrow-size: .5em; 42 | --fsb-list-height: 200px; 43 | --fsb-list-radius: .75em; 44 | --fsb-list-background: #34BE82; 45 | --fsb-hover-background: #2FDD92; 46 | } 47 | 48 | .icons { 49 | --fsb-border: 1px solid #fc0; 50 | --fsb-radius: 10px; 51 | --fsb-padding: .75em; 52 | --fsb-arrow-padding: 1em; 53 | --fsb-hover-background: #fc0; 54 | --fsb-list-height: 350px; 55 | } 56 | 57 | @media screen and (min-height: 680px) { 58 | .auto-position { 59 | position: absolute; 60 | width: calc(100% - 60px); 61 | max-width: 500px; 62 | bottom: 20px; 63 | } 64 | } -------------------------------------------------------------------------------- /dist/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | View fancySelect on GitHub 13 |

fancySelect examples

14 |
15 | 16 | 44 |
45 |
46 | 47 | 75 |
76 |
77 | 78 | 106 |
107 |
108 | 109 | 137 |
138 |
139 | 140 | 168 |
169 |
170 | 171 | 172 | -------------------------------------------------------------------------------- /dist/fancyselect.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fsb-border: 1px solid #ccc; 3 | --fsb-radius: 5px; 4 | --fsb-color: inherit; 5 | --fsb-background: #fff; 6 | --fsb-font-size: 1rem; 7 | --fsb-shadow: 0 1px 1px rgba(0, 0, 0, .1); 8 | --fsb-padding: 8px; 9 | --fsb-padding-right: var(--fsb-padding); 10 | --fsb-arrow-size: 6px; 11 | --fsb-arrow-padding: var(--fsb-padding); 12 | --fsb-arrow-color: currentColor; 13 | --fsb-icon-color: currentColor; 14 | --fsb-list-height: 300px; 15 | --fsb-list-border: var(--fsb-border); 16 | --fsb-list-radius: 3px; 17 | --fsb-list-color: var(--fsb-color); 18 | --fsb-list-background: var(--fsb-background); 19 | --fsb-hover-color: var(--fsb-color); 20 | --fsb-hover-background: #ddd; 21 | --fsb-disabled-opacity: .3; 22 | } 23 | 24 | .fsb-original-select { 25 | display: inline-block; 26 | margin: 0; 27 | padding: 8px 22px 8px 8px; 28 | padding: var(--fsb-padding); 29 | padding-right: calc(var(--fsb-arrow-padding) * 2 + var(--fsb-arrow-size)); 30 | font-family: inherit; 31 | line-height: inherit; 32 | -webkit-appearance: none; 33 | -moz-appearance: none; 34 | appearance: none; 35 | } 36 | 37 | select::-ms-expand { 38 | display: none; 39 | } 40 | 41 | .fsb-original-select[disabled] { 42 | color: rgba(0, 0, 0, .3); 43 | cursor: not-allowed; 44 | } 45 | 46 | .fsb-select { 47 | display: inline-block; 48 | position: relative; 49 | } 50 | 51 | select[disabled] + .fsb-select { 52 | cursor: not-allowed; 53 | } 54 | 55 | .fsb-select, 56 | .fsb-original-select { 57 | min-width: 0; 58 | border: 1px solid #ccc; 59 | border: var(--fsb-border); 60 | border-radius: 5px; 61 | border-radius: var(--fsb-radius); 62 | box-sizing: border-box; 63 | color: inherit; 64 | color: var(--fsb-color); 65 | background-color: #fff; 66 | background-color: var(--fsb-background); 67 | font-size: 1em; 68 | font-size: var(--fsb-font-size); 69 | box-shadow: none; 70 | box-shadow: var(--fsb-shadow); 71 | } 72 | 73 | .fsb-select svg { 74 | width: 1em; 75 | height: 1em; 76 | margin-right: 8px; 77 | margin-right: var(--fsb-padding-right); 78 | fill: currentColor; 79 | fill: var(--fsb-icon-color); 80 | pointer-events: none; 81 | } 82 | 83 | .fsb-label { 84 | display: none; 85 | } 86 | 87 | /* While it's common sense to avoid using !important as much as possible, it is used 88 | * here to prevent inheriting style from other rules that may target buttons. */ 89 | .fsb-button { 90 | display: flex !important; 91 | align-items: center; 92 | position: relative !important; 93 | width: 100% !important; 94 | height: 100% !important; 95 | margin: 0 !important; 96 | padding: 8px 22px 8px 8px !important; 97 | padding: var(--fsb-padding) !important; 98 | padding-right: calc(var(--fsb-arrow-size) + var(--fsb-arrow-padding) + var(--fsb-padding-right)) !important; 99 | border: 0 !important; 100 | border-radius: inherit !important; 101 | color: inherit !important; 102 | background-color: inherit !important; 103 | font-size: 1em !important; 104 | font-family: inherit !important; 105 | text-align: inherit !important; 106 | white-space: nowrap; 107 | text-overflow: ellipsis; 108 | overflow: hidden; 109 | } 110 | 111 | .fsb-button > span { 112 | white-space: nowrap; 113 | text-overflow: ellipsis; 114 | overflow: hidden; 115 | } 116 | 117 | .fsb-button > span, 118 | .fsb-option > span { 119 | pointer-events: none; 120 | } 121 | 122 | select[disabled] + .fsb-select .fsb-button { 123 | opacity: .4; 124 | pointer-events: none; 125 | } 126 | 127 | .fsb-button:after, 128 | select[disabled] + .fsb-select .fsb-button[aria-expanded="true"]:after { 129 | content: ''; 130 | display: block; 131 | position: absolute; 132 | width: 6px; 133 | width: var(--fsb-arrow-size); 134 | height: 6px; 135 | height: var(--fsb-arrow-size); 136 | right: 8px; 137 | right: var(--fsb-arrow-padding); 138 | top: 50%; 139 | transform: translateY(-65%) rotateZ(45deg); 140 | border: solid currentColor; 141 | border: solid var(--fsb-arrow-color); 142 | border-width: 0 1.5px 1.5px 0; 143 | box-sizing: border-box; 144 | transition: transform .3s ease-in-out; 145 | pointer-events: none; 146 | } 147 | 148 | .fsb-button[aria-expanded="true"]:after { 149 | transform: translateY(-35%) rotateZ(225deg); 150 | } 151 | 152 | .fsb-list, 153 | select[disabled] + .fsb-select .fsb-list { 154 | display: block; 155 | visibility: hidden; 156 | position: absolute; 157 | min-width: 100%; 158 | height: 0; 159 | margin: 0; 160 | left: 0; 161 | top: 100%; 162 | z-index: 1; 163 | padding: 0; 164 | border: inherit; 165 | border: var(--fsb-list-border); 166 | border-radius: inherit; 167 | border-radius: var(--fsb-list-radius); 168 | box-sizing: border-box; 169 | color: inherit; 170 | color: var(--fsb-list-color); 171 | background-color: inherit; 172 | background-color: var(--fsb-list-background); 173 | box-shadow: 0 2px 8px rgba(0, 0, 0, .2); 174 | opacity: 0; 175 | transition: opacity .2s ease-in-out; 176 | overflow: auto; 177 | } 178 | 179 | .fsb-top .fsb-list { 180 | top: auto; 181 | bottom: 100%; 182 | box-shadow: 0 -2px 8px rgba(0, 0, 0, .2); 183 | } 184 | 185 | .fsb-button[aria-expanded="true"] + .fsb-list { 186 | height: auto; 187 | max-height: var(--fsb-list-height); 188 | visibility: visible; 189 | opacity: 1; 190 | } 191 | 192 | .fsb-option { 193 | display: flex; 194 | align-items: center; 195 | padding: var(--fsb-padding); 196 | white-space: nowrap; 197 | text-overflow: ellipsis; 198 | overflow: hidden; 199 | } 200 | 201 | .fsb-option:not([aria-disabled="true"]):focus { 202 | outline: none; 203 | color: var(--fsb-hover-color); 204 | background-color: var(--fsb-hover-background); 205 | } 206 | 207 | .fsb-option[aria-disabled="true"] { 208 | opacity: var(--fsb-disabled-opacity); 209 | } 210 | 211 | .fsb-resize { 212 | display: block; 213 | height: 0; 214 | padding-right: 14px; 215 | padding-right: calc(var(--fsb-arrow-padding) * 2 + var(--fsb-arrow-size) - var(--fsb-padding-right)); 216 | box-sizing: border-box; 217 | } 218 | 219 | .fsb-resize > * { 220 | display: block; 221 | } -------------------------------------------------------------------------------- /dist/fancyselect.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2021 Momo Bassit. 3 | * Licensed under the MIT License (MIT) 4 | * https://github.com/mdbassit/fancySelect 5 | */ 6 | 7 | (function (window, document, autoInitialize) { 8 | 9 | var currentElement = null; 10 | var currentFocus = null; 11 | var searchString = ''; 12 | var searchTimeout = null; 13 | var counter = 0; 14 | 15 | /** 16 | * Initialize the custom select box elements. 17 | * @param {string} [selector] An optional selector representing native select elements. 18 | */ 19 | function init(selector) { 20 | selector = selector || 'select:not(.fsb-ignore)'; 21 | 22 | // Replace all eligible native select elements with custom select boxes 23 | document.querySelectorAll(selector).forEach(replaceNativeSelect); 24 | } 25 | 26 | /** 27 | * Replace a native select element with a custom select box. 28 | * @param {object} select The native select. 29 | * @param {function} [renderer] An optional custom item label renderer. 30 | */ 31 | function replaceNativeSelect(select, renderer) { 32 | // Skip if the native select has already been processed 33 | if (select.nextElementSibling && select.nextElementSibling.classList.contains('fsb-select')) { 34 | return; 35 | } 36 | 37 | var options = select.children; 38 | var parentNode = select.parentNode; 39 | var customSelect = document.createElement('span'); 40 | var label = document.createElement('span'); 41 | var button = document.createElement('button'); 42 | var list = document.createElement('span'); 43 | var widthAdjuster = document.createElement('span'); 44 | var index = counter++; 45 | 46 | // Add a custom CSS class to the native select element 47 | select.classList.add('fsb-original-select'); 48 | 49 | // Label for accessibility 50 | label.id = "fsb_" + index + "_label"; 51 | label.className = 'fsb-label'; 52 | label.textContent = getNativeSelectLabel(select, parentNode); 53 | 54 | // List box button 55 | button.id = "fsb_" + index + "_button"; 56 | button.className = 'fsb-button'; 57 | button.innerHTML = ' '; 58 | button.setAttribute('type', 'button'); 59 | button.setAttribute('aria-disabled', select.disabled); 60 | button.setAttribute('aria-haspopup', 'listbox'); 61 | button.setAttribute('aria-expanded', 'false'); 62 | button.setAttribute('aria-labelledby', "fsb_" + index + "_label fsb_" + index + "_button"); 63 | 64 | // List box 65 | list.className = 'fsb-list'; 66 | list.setAttribute('role', 'listbox'); 67 | list.setAttribute('tabindex', '-1'); 68 | list.setAttribute('aria-labelledby', "fsb_" + index + "_label"); 69 | 70 | // List items 71 | for (var i = 0, len = options.length; i < len; i++) { 72 | var _getItemFromOption = getItemFromOption(options[i], renderer),item = _getItemFromOption.item,selected = _getItemFromOption.selected,itemLabel = _getItemFromOption.itemLabel; 73 | 74 | list.appendChild(item); 75 | 76 | if (selected) { 77 | button.innerHTML = itemLabel; 78 | } 79 | } 80 | 81 | // Custom select box container 82 | customSelect.className = 'fsb-select'; 83 | customSelect.appendChild(label); 84 | customSelect.appendChild(button); 85 | customSelect.appendChild(list); 86 | customSelect.appendChild(widthAdjuster); 87 | 88 | // Hide the native select 89 | select.style.display = 'none'; 90 | 91 | // Insert the custom select box after the native select 92 | if (select.nextSibling) { 93 | parentNode.insertBefore(customSelect, select.nextSibling); 94 | } else { 95 | parentNode.appendChild(customSelect); 96 | } 97 | 98 | // Force the select box to take the width of the longest item by default 99 | if (list.firstElementChild) { 100 | var span = document.createElement('span'); 101 | 102 | span.style.width = list.firstElementChild.offsetWidth + "px"; 103 | widthAdjuster.className = 'fsb-resize'; 104 | widthAdjuster.appendChild(span); 105 | } 106 | } 107 | 108 | /** 109 | * Update the custom select box attached to a native select. 110 | * @param {object} select The native select. 111 | * @param {function} [renderer] An optional custom item label renderer. 112 | */ 113 | function updateFromNativeSelect(select, renderer) { 114 | var options = select.children; 115 | var parentNode = select.parentNode; 116 | var customSelect = select.nextElementSibling; 117 | 118 | // Abort if this native select hasn't been initialized 119 | if (!customSelect || !customSelect.classList.contains('fsb-select')) { 120 | return; 121 | } 122 | 123 | var label = customSelect.firstElementChild; 124 | var button = label.nextElementSibling; 125 | var list = button.nextElementSibling; 126 | var widthAdjuster = list.nextElementSibling; 127 | var listContent = document.createDocumentFragment(); 128 | 129 | // Update the accessibility label 130 | label.textContent = getNativeSelectLabel(select, parentNode); 131 | 132 | // Update the button status 133 | button.setAttribute('aria-disabled', select.disabled); 134 | 135 | // Generate the list items 136 | for (var i = 0, len = options.length; i < len; i++) { 137 | var _getItemFromOption2 = getItemFromOption(options[i], renderer),item = _getItemFromOption2.item,selected = _getItemFromOption2.selected,itemLabel = _getItemFromOption2.itemLabel; 138 | 139 | listContent.appendChild(item); 140 | 141 | if (selected) { 142 | button.innerHTML = itemLabel; 143 | } 144 | } 145 | 146 | // Clear the list box 147 | while (list.firstChild) { 148 | list.removeChild(list.firstChild); 149 | } 150 | 151 | // Update the list items 152 | list.appendChild(listContent); 153 | 154 | // Force the select box to take the width of the longest item by default 155 | if (list.firstElementChild) { 156 | widthAdjuster.firstElementChild.style.width = list.firstElementChild.offsetWidth + "px"; 157 | } 158 | } 159 | 160 | /** 161 | * Try to guess the native select element's label if any. 162 | * @param {object} select The native select. 163 | * @param {object} parent The parent node. 164 | * @return {string} The native select's label or an empty string. 165 | */ 166 | function getNativeSelectLabel(select, parent) { 167 | var id = select.id; 168 | var labelElement; 169 | 170 | // If the select element is inside a label element 171 | if (parent.nodeName === 'LABEL') { 172 | labelElement = parent; 173 | 174 | // Or if the select element has an ID, and there is a label element 175 | // with an attribute "for" that points to that ID 176 | } else if (id !== undefined) { 177 | labelElement = document.querySelector("label[for=\"" + id + "\"]"); 178 | } 179 | 180 | // If a label element is found, return the first non empty child text node 181 | if (labelElement) { 182 | var textNodes = [].filter.call(labelElement.childNodes, function (n) {return n.nodeType === 3;}); 183 | var texts = textNodes.map(function (n) {return n.textContent.replace(/\s+/g, ' ').trim();}); 184 | var label = texts.filter(function (l) {return l !== '';})[0]; 185 | 186 | if (label) { 187 | // Open the list box on click on the label element 188 | labelElement.onclick = function (event) { 189 | select.nextElementSibling.querySelector('button').click(); 190 | event.preventDefault(); 191 | event.stopImmediatePropagation(); 192 | }; 193 | 194 | return label; 195 | } 196 | } 197 | 198 | return ''; 199 | } 200 | 201 | /** 202 | * Generate a listbox item from a native select option. 203 | * @param {object} option The native select option. 204 | * @param {function} [renderer] An optional custom item label renderer. 205 | * @return {object} The listbox item, its selected state and its label. 206 | */ 207 | function getItemFromOption(option, renderer) { 208 | var item = document.createElement('span'); 209 | var selected = option.selected; 210 | var itemLabel = getItemLabel(option, renderer); 211 | 212 | item.className = 'fsb-option'; 213 | item.innerHTML = itemLabel; 214 | item.setAttribute('role', 'option'); 215 | item.setAttribute('tabindex', '-1'); 216 | item.setAttribute('aria-selected', selected); 217 | 218 | if (option.disabled) { 219 | item.setAttribute('aria-disabled', option.disabled); 220 | } 221 | 222 | return { item: item, selected: selected, itemLabel: itemLabel }; 223 | } 224 | 225 | /** 226 | * Render a listbox item's label. 227 | * @param {object} option The native select option. 228 | * @param {function} [renderer] An optional custom item label renderer. 229 | * @return {string} The listbox item's label. 230 | */ 231 | function getItemLabel(option, renderer) { 232 | if (typeof renderer === 'function') { 233 | return renderer(option); 234 | } 235 | 236 | var text = option.text; 237 | var icon = option.getAttribute('data-icon'); 238 | var label = text !== '' ? text : ' '; 239 | 240 | // Wrap label in a span to better handle long text 241 | label = "" + label + ""; 242 | 243 | if (icon !== null) { 244 | label = " " + label; 245 | } 246 | 247 | return label; 248 | } 249 | 250 | /** 251 | * Open a list box. 252 | * @param {object} button The button to which the list box is attached. 253 | */ 254 | function openListBox(button) { 255 | var rect = button.getBoundingClientRect(); 256 | var list = button.nextElementSibling; 257 | var selectedItem = list.querySelector('[aria-selected="true"]'); 258 | 259 | if (!selectedItem) { 260 | selectedItem = list.firstElementChild; 261 | } 262 | 263 | // Open the list box and focus the selected item 264 | button.parentNode.className = 'fsb-select'; 265 | button.setAttribute('aria-expanded', 'true'); 266 | selectedItem.focus(); 267 | currentElement = button; 268 | currentFocus = selectedItem; 269 | 270 | // Position the list box on top of the button if there isn't enough space on the bottom 271 | if (rect.y + rect.height + list.offsetHeight > document.documentElement.clientHeight) { 272 | button.parentNode.className = 'fsb-select fsb-top'; 273 | } 274 | } 275 | 276 | /** 277 | * Close the active list box. 278 | * @param {boolean} focusButton If true, set focus on the button to which the list box is attached. 279 | */ 280 | function closeListBox(focusButton) { 281 | var activeListBox = document.querySelector('.fsb-button[aria-expanded="true"]'); 282 | 283 | if (activeListBox) { 284 | activeListBox.setAttribute('aria-expanded', 'false'); 285 | 286 | if (focusButton) { 287 | activeListBox.focus(); 288 | } 289 | 290 | // Clear the search string in case someone is a ninja!!! 291 | searchString = ''; 292 | searchTimeout = null; 293 | } 294 | 295 | currentElement = null; 296 | currentFocus = null; 297 | } 298 | 299 | /** 300 | * Set the selected item. 301 | * @param {object} item The item to be selected. 302 | */ 303 | function selectItem(item) { 304 | var list = item.parentNode; 305 | var button = list.previousElementSibling; 306 | var itemIndex = [].indexOf.call(list.children, item); 307 | var selectedItem = list.querySelector('[aria-selected="true"]'); 308 | var originalSelect = list.parentNode.previousElementSibling; 309 | 310 | 311 | if (selectedItem) { 312 | selectedItem.setAttribute('aria-selected', 'false'); 313 | } 314 | 315 | item.setAttribute('aria-selected', 'true'); 316 | button.innerHTML = item.innerHTML; 317 | 318 | // Update the original select 319 | originalSelect.selectedIndex = itemIndex; 320 | originalSelect.dispatchEvent(new Event('input', { bubbles: true })); 321 | originalSelect.dispatchEvent(new Event('change', { bubbles: true })); 322 | } 323 | 324 | /** 325 | * Get the next item that matches a string. 326 | * @param {object} list The active list box. 327 | * @param {string} search The search string. 328 | * @return {object} The item that matches the string. 329 | */ 330 | function getMatchingItem(list, search) { 331 | var items = [].map.call(list.children, function (item) {return item.textContent.trim().toLowerCase();}); 332 | var firstMatch = filterItems(items, search)[0]; 333 | 334 | // If an exact match is found, return it 335 | if (firstMatch) { 336 | return list.children[items.indexOf(firstMatch)]; 337 | 338 | // If the search string is the same character repeated multiple times 339 | // we need to cycle through the items starting with that character 340 | } else if (isRepeatedCharacter(search)) { 341 | // Get all the items matching the character 342 | var matches = filterItems(items, search[0]); 343 | 344 | // The match we want depends on the length of the repeated string 345 | // e.g: "aa" means the second item starting with "a" 346 | var matchIndex = (search.length - 1) % matches.length; 347 | 348 | // Return the match 349 | var match = matches[matchIndex]; 350 | return list.children[items.indexOf(match)]; 351 | } 352 | 353 | return null; 354 | } 355 | 356 | /** 357 | * Focus the next item that matches a string. 358 | * @param {object} list The active list box. 359 | */ 360 | function focusMatchingItem(list) { 361 | var item = getMatchingItem(list, searchString); 362 | 363 | if (item) { 364 | item.focus(); 365 | } 366 | } 367 | 368 | /** 369 | * Filter an array of string. 370 | * @param {array} items. 371 | * @param {string} filter The filter string. 372 | * @return {array} The array items that matches the filter. 373 | */ 374 | function filterItems(items, filter) { 375 | return items.filter(function (item) {return item.indexOf(filter.toLowerCase()) === 0;}); 376 | } 377 | 378 | /** 379 | * Check if the the user is typing printable characters. 380 | * @param {object} event A keydown event. 381 | * @return {boolean} True if the key pressed is a printable character. 382 | */ 383 | function isTyping(event) { 384 | var key = event.key,altKey = event.altKey,ctrlKey = event.ctrlKey,metaKey = event.metaKey; 385 | 386 | if (key.length === 1 && !altKey && !ctrlKey && !metaKey) { 387 | if (searchTimeout) { 388 | window.clearTimeout(searchTimeout); 389 | } 390 | 391 | searchTimeout = window.setTimeout(function () { 392 | searchString = ''; 393 | }, 500); 394 | 395 | searchString += key; 396 | return true; 397 | } 398 | 399 | return false; 400 | } 401 | 402 | /** 403 | * Check if a string is the same character repeated multiple times. 404 | * @param {string} str The string to check. 405 | * @return {boolean} True if the string the same character repeated multiple times (e.g "aaa"). 406 | */ 407 | function isRepeatedCharacter(str) { 408 | var characters = str.split(''); 409 | return characters.every(function (char) {return char === characters[0];}); 410 | } 411 | 412 | /** 413 | * Find and focus the closest active option. 414 | * @param {object} option The starting option. 415 | * @param {string} dir The direction of the lookup (next, prev). 416 | */ 417 | function focusClosestActiveOption(option, dir) { 418 | if (!option) { 419 | return; 420 | } 421 | 422 | // Focus the starting option itself if it's active 423 | if (!option.getAttribute('aria-disabled')) { 424 | currentFocus = option; 425 | return option.focus(); 426 | } 427 | 428 | var options = Array.from(option.parentNode.children); 429 | var index = options.indexOf(option); 430 | 431 | if (dir === 'next') { 432 | for (var i = index + 1, len = options.length; i < len; i++) { 433 | if (!options[i].getAttribute('aria-disabled')) { 434 | currentFocus = options[i]; 435 | return options[i].focus(); 436 | } 437 | } 438 | } else { 439 | for (var _i = index - 1; _i >= 0; _i--) { 440 | if (!options[_i].getAttribute('aria-disabled')) { 441 | currentFocus = options[_i]; 442 | return options[_i].focus(); 443 | } 444 | } 445 | } 446 | } 447 | 448 | /** 449 | * Shortcut for addEventListener with delegation support. 450 | * @param {object} context The context to which the listener is attached. 451 | * @param {string} type Event type. 452 | * @param {(string|function)} selector Event target if delegation is used, event handler if not. 453 | * @param {function} [fn] Event handler if delegation is used. 454 | */ 455 | function addListener(context, type, selector, fn) { 456 | var matches = Element.prototype.matches || Element.prototype.msMatchesSelector; 457 | 458 | // Delegate event to the target of the selector 459 | if (typeof selector === 'string') { 460 | context.addEventListener(type, function (event) { 461 | if (matches.call(event.target, selector)) { 462 | fn.call(event.target, event); 463 | } 464 | }); 465 | 466 | // If the selector is not a string then it's a function 467 | // in which case we need regular event listener 468 | } else { 469 | fn = selector; 470 | context.addEventListener(type, fn); 471 | } 472 | } 473 | 474 | /** 475 | * Call a function only when the DOM is ready. 476 | * @param {function} fn The function to call. 477 | * @param {array} [args] Arguments to pass to the function. 478 | */ 479 | function DOMReady(fn, args) { 480 | args = args !== undefined ? args : []; 481 | 482 | if (document.readyState !== 'loading') { 483 | fn.apply(void 0, args); 484 | } else { 485 | document.addEventListener('DOMContentLoaded', function () { 486 | fn.apply(void 0, args); 487 | }); 488 | } 489 | } 490 | 491 | // On click on the list box button 492 | addListener(document, 'click', '.fsb-button', function (event) { 493 | var isClickToClose = currentElement === event.target; 494 | 495 | closeListBox(); 496 | 497 | if (!isClickToClose) { 498 | openListBox(event.target); 499 | } 500 | 501 | event.preventDefault(); 502 | event.stopImmediatePropagation(); 503 | }); 504 | 505 | // On key press on the list box button 506 | addListener(document, 'keydown', '.fsb-button', function (event) { 507 | var button = event.target; 508 | var list = button.nextElementSibling; 509 | var preventDefault = true; 510 | 511 | switch (event.key) { 512 | case 'ArrowUp': 513 | case 'ArrowDown': 514 | case 'Enter': 515 | case ' ': 516 | openListBox(button); 517 | break; 518 | default: 519 | if (isTyping(event)) { 520 | openListBox(button); 521 | focusMatchingItem(list); 522 | } else { 523 | preventDefault = false; 524 | }} 525 | 526 | 527 | if (preventDefault) { 528 | event.preventDefault(); 529 | } 530 | }); 531 | 532 | // When the mouse moves on an item, focus it. 533 | // Use mousemove instead of mouseover to prevent accidental focus on the wrong item, 534 | // namely when the list box is opened with a keyboard shortcut, and the mouse arrow 535 | // just happens to be on an item. 536 | addListener(document.documentElement, 'mousemove', '.fsb-option:not([aria-disabled="true"])', function (event) { 537 | event.target.focus(); 538 | currentFocus = event.target; 539 | }); 540 | 541 | // On click on an item 542 | addListener(document, 'click', '.fsb-option', function (event) { 543 | var item = event.target; 544 | 545 | if (!item.getAttribute('aria-disabled')) { 546 | selectItem(item); 547 | closeListBox(true); 548 | } else { 549 | event.stopImmediatePropagation(); 550 | currentFocus.focus(); 551 | } 552 | }); 553 | 554 | // On key press on an item 555 | addListener(document, 'keydown', '.fsb-option', function (event) { 556 | var item = event.target; 557 | var list = item.parentNode; 558 | var preventDefault = true; 559 | 560 | switch (event.key) { 561 | case 'ArrowUp': 562 | case 'ArrowLeft': 563 | focusClosestActiveOption(item.previousElementSibling, 'prev'); 564 | break; 565 | case 'ArrowDown': 566 | case 'ArrowRight': 567 | focusClosestActiveOption(item.nextElementSibling, 'next'); 568 | break; 569 | case 'Home': 570 | focusClosestActiveOption(list.firstElementChild, 'next'); 571 | break; 572 | case 'End': 573 | focusClosestActiveOption(list.lastElementChild, 'prev'); 574 | break; 575 | case 'PageUp': 576 | case 'PageDown': 577 | // Disable Page Up and Page Down keys 578 | break; 579 | case 'Tab': 580 | selectItem(item); 581 | closeListBox(); 582 | preventDefault = false; 583 | break; 584 | case 'Enter': 585 | case ' ': 586 | selectItem(item); 587 | case 'Escape': 588 | closeListBox(true); 589 | break; 590 | default: 591 | if (isTyping(event)) { 592 | focusMatchingItem(list); 593 | } else { 594 | preventDefault = false; 595 | }} 596 | 597 | 598 | if (preventDefault) { 599 | event.preventDefault(); 600 | } 601 | }); 602 | 603 | // On click outside the custom select box, close it 604 | addListener(document, 'click', function (event) { 605 | closeListBox(); 606 | }); 607 | 608 | // Expose the constructor to the global scope 609 | window.FancySelect = function () { 610 | function FancySelect() { 611 | DOMReady(init); 612 | } 613 | 614 | // Available methodes 615 | FancySelect.init = init; 616 | FancySelect.replace = replaceNativeSelect; 617 | FancySelect.update = updateFromNativeSelect; 618 | 619 | return FancySelect; 620 | }(); 621 | 622 | // Initialize the custom select boxes when the DOM is ready 623 | if (autoInitialize) { 624 | DOMReady(init); 625 | } 626 | 627 | })(window, document, typeof FancySelectAutoInitialize !== 'undefined' ? FancySelectAutoInitialize : true); -------------------------------------------------------------------------------- /dist/fancyselect.min.css: -------------------------------------------------------------------------------- 1 | :root{--fsb-border:1px solid #ccc;--fsb-radius:5px;--fsb-color:inherit;--fsb-background:#fff;--fsb-font-size:1rem;--fsb-shadow:0 1px 1px rgba(0, 0, 0, .1);--fsb-padding:8px;--fsb-padding-right:var(--fsb-padding);--fsb-arrow-size:6px;--fsb-arrow-padding:var(--fsb-padding);--fsb-arrow-color:currentColor;--fsb-icon-color:currentColor;--fsb-list-height:300px;--fsb-list-border:var(--fsb-border);--fsb-list-radius:3px;--fsb-list-color:var(--fsb-color);--fsb-list-background:var(--fsb-background);--fsb-hover-color:var(--fsb-color);--fsb-hover-background:#ddd;--fsb-disabled-opacity:.3}.fsb-original-select{display:inline-block;margin:0;padding:8px 22px 8px 8px;padding:var(--fsb-padding);padding-right:calc(var(--fsb-arrow-padding) * 2 + var(--fsb-arrow-size));font-family:inherit;line-height:inherit;-webkit-appearance:none;-moz-appearance:none;appearance:none}select::-ms-expand{display:none}.fsb-original-select[disabled]{color:rgba(0,0,0,.3);cursor:not-allowed}.fsb-select{display:inline-block;position:relative}select[disabled]+.fsb-select{cursor:not-allowed}.fsb-original-select,.fsb-select{min-width:0;border:1px solid #ccc;border:var(--fsb-border);border-radius:5px;border-radius:var(--fsb-radius);box-sizing:border-box;color:inherit;color:var(--fsb-color);background-color:#fff;background-color:var(--fsb-background);font-size:1em;font-size:var(--fsb-font-size);box-shadow:none;box-shadow:var(--fsb-shadow)}.fsb-select svg{width:1em;height:1em;margin-right:8px;margin-right:var(--fsb-padding-right);fill:currentColor;fill:var(--fsb-icon-color);pointer-events:none}.fsb-label{display:none}.fsb-button{display:flex!important;align-items:center;position:relative!important;width:100%!important;height:100%!important;margin:0!important;padding:8px 22px 8px 8px!important;padding:var(--fsb-padding)!important;padding-right:calc(var(--fsb-arrow-size) + var(--fsb-arrow-padding) + var(--fsb-padding-right))!important;border:0!important;border-radius:inherit!important;color:inherit!important;background-color:inherit!important;font-size:1em!important;font-family:inherit!important;text-align:inherit!important;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.fsb-button>span{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.fsb-button>span,.fsb-option>span{pointer-events:none}select[disabled]+.fsb-select .fsb-button{opacity:.4;pointer-events:none}.fsb-button:after,select[disabled]+.fsb-select .fsb-button[aria-expanded=true]:after{content:'';display:block;position:absolute;width:6px;width:var(--fsb-arrow-size);height:6px;height:var(--fsb-arrow-size);right:8px;right:var(--fsb-arrow-padding);top:50%;transform:translateY(-65%) rotateZ(45deg);border:solid currentColor;border:solid var(--fsb-arrow-color);border-width:0 1.5px 1.5px 0;box-sizing:border-box;transition:transform .3s ease-in-out;pointer-events:none}.fsb-button[aria-expanded=true]:after{transform:translateY(-35%) rotateZ(225deg)}.fsb-list,select[disabled]+.fsb-select .fsb-list{display:block;visibility:hidden;position:absolute;min-width:100%;height:0;margin:0;left:0;top:100%;z-index:1;padding:0;border:inherit;border:var(--fsb-list-border);border-radius:inherit;border-radius:var(--fsb-list-radius);box-sizing:border-box;color:inherit;color:var(--fsb-list-color);background-color:inherit;background-color:var(--fsb-list-background);box-shadow:0 2px 8px rgba(0,0,0,.2);opacity:0;transition:opacity .2s ease-in-out;overflow:auto}.fsb-top .fsb-list{top:auto;bottom:100%;box-shadow:0 -2px 8px rgba(0,0,0,.2)}.fsb-button[aria-expanded=true]+.fsb-list{height:auto;max-height:var(--fsb-list-height);visibility:visible;opacity:1}.fsb-option{display:flex;align-items:center;padding:var(--fsb-padding);white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.fsb-option:not([aria-disabled=true]):focus{outline:0;color:var(--fsb-hover-color);background-color:var(--fsb-hover-background)}.fsb-option[aria-disabled=true]{opacity:var(--fsb-disabled-opacity)}.fsb-resize{display:block;height:0;padding-right:14px;padding-right:calc(var(--fsb-arrow-padding) * 2 + var(--fsb-arrow-size) - var(--fsb-padding-right));box-sizing:border-box}.fsb-resize>*{display:block} -------------------------------------------------------------------------------- /dist/fancyselect.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2021 Momo Bassit. 3 | * Licensed under the MIT License (MIT) 4 | * https://github.com/mdbassit/fancySelect 5 | */ 6 | !function(a,m,e){var r=null,l=null,s="",o=null,g=0;function t(e){m.querySelectorAll(e=e||"select:not(.fsb-ignore)").forEach(n)}function n(e,t){if(!e.nextElementSibling||!e.nextElementSibling.classList.contains("fsb-select")){var n=e.children,i=e.parentNode,a=m.createElement("span"),r=m.createElement("span"),l=m.createElement("button"),s=m.createElement("span"),o=m.createElement("span"),c=g++;e.classList.add("fsb-original-select"),r.id="fsb_"+c+"_label",r.className="fsb-label",r.textContent=h(e,i),l.id="fsb_"+c+"_button",l.className="fsb-button",l.innerHTML=" ",l.setAttribute("type","button"),l.setAttribute("aria-disabled",e.disabled),l.setAttribute("aria-haspopup","listbox"),l.setAttribute("aria-expanded","false"),l.setAttribute("aria-labelledby","fsb_"+c+"_label fsb_"+c+"_button"),s.className="fsb-list",s.setAttribute("role","listbox"),s.setAttribute("tabindex","-1"),s.setAttribute("aria-labelledby","fsb_"+c+"_label");for(var d=0,u=n.length;d"+t+"",null!==e&&(t=' '+t);return t}(e,t);return n.className="fsb-option",n.innerHTML=t,n.setAttribute("role","option"),n.setAttribute("tabindex","-1"),n.setAttribute("aria-selected",i),e.disabled&&n.setAttribute("aria-disabled",e.disabled),{item:n,selected:i,itemLabel:t}}function c(e){var t=e.getBoundingClientRect(),n=e.nextElementSibling,i=(i=n.querySelector('[aria-selected="true"]'))||n.firstElementChild;e.parentNode.className="fsb-select",e.setAttribute("aria-expanded","true"),i.focus(),r=e,l=i,t.y+t.height+n.offsetHeight>m.documentElement.clientHeight&&(e.parentNode.className="fsb-select fsb-top")}function d(e){var t=m.querySelector('.fsb-button[aria-expanded="true"]');t&&(t.setAttribute("aria-expanded","false"),e&&t.focus(),s="",o=null),l=r=null}function u(e){var t=e.parentNode,n=t.previousElementSibling,i=[].indexOf.call(t.children,e),a=t.querySelector('[aria-selected="true"]'),t=t.parentNode.previousElementSibling;a&&a.setAttribute("aria-selected","false"),e.setAttribute("aria-selected","true"),n.innerHTML=e.innerHTML,t.selectedIndex=i,t.dispatchEvent(new Event("input",{bubbles:!0})),t.dispatchEvent(new Event("change",{bubbles:!0}))}function f(e){e=function(e,t){var n,i=[].map.call(e.children,function(e){return e.textContent.trim().toLowerCase()}),a=b(i,t)[0];if(a)return e.children[i.indexOf(a)];if((n=t.split("")).every(function(e){return e===n[0]})){a=b(i,t[0]),a=a[(t.length-1)%a.length];return e.children[i.indexOf(a)]}return null}(e,s);e&&e.focus()}function b(e,t){return e.filter(function(e){return 0===e.indexOf(t.toLowerCase())})}function p(e){var t=e.key,n=e.altKey,i=e.ctrlKey,e=e.metaKey;return!(1!==t.length||n||i||e)&&(o&&a.clearTimeout(o),o=a.setTimeout(function(){s=""},500),s+=t,1)}function E(e,t){if(e){if(!e.getAttribute("aria-disabled"))return(l=e).focus();var n=Array.from(e.parentNode.children),e=n.indexOf(e);if("next"===t){for(var i=e+1,a=n.length;i 0.25%, not dead", 24 | "babel": { 25 | "presets": [ 26 | [ 27 | "@babel/preset-env", 28 | { 29 | "loose": true 30 | } 31 | ] 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/fancyselect.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fsb-border: 1px solid #ccc; 3 | --fsb-radius: 5px; 4 | --fsb-color: inherit; 5 | --fsb-background: #fff; 6 | --fsb-font-size: 1rem; 7 | --fsb-shadow: 0 1px 1px rgba(0, 0, 0, .1); 8 | --fsb-padding: 8px; 9 | --fsb-padding-right: var(--fsb-padding); 10 | --fsb-arrow-size: 6px; 11 | --fsb-arrow-padding: var(--fsb-padding); 12 | --fsb-arrow-color: currentColor; 13 | --fsb-icon-color: currentColor; 14 | --fsb-list-height: 300px; 15 | --fsb-list-border: var(--fsb-border); 16 | --fsb-list-radius: 3px; 17 | --fsb-list-color: var(--fsb-color); 18 | --fsb-list-background: var(--fsb-background); 19 | --fsb-hover-color: var(--fsb-color); 20 | --fsb-hover-background: #ddd; 21 | --fsb-disabled-opacity: .3; 22 | } 23 | 24 | .fsb-original-select { 25 | display: inline-block; 26 | margin: 0; 27 | padding: 8px 22px 8px 8px; 28 | padding: var(--fsb-padding); 29 | padding-right: calc(var(--fsb-arrow-padding) * 2 + var(--fsb-arrow-size)); 30 | font-family: inherit; 31 | line-height: inherit; 32 | -webkit-appearance: none; 33 | -moz-appearance: none; 34 | appearance: none; 35 | } 36 | 37 | select::-ms-expand { 38 | display: none; 39 | } 40 | 41 | .fsb-original-select[disabled] { 42 | color: rgba(0, 0, 0, .3); 43 | cursor: not-allowed; 44 | } 45 | 46 | .fsb-select { 47 | display: inline-block; 48 | position: relative; 49 | } 50 | 51 | select[disabled] + .fsb-select { 52 | cursor: not-allowed; 53 | } 54 | 55 | .fsb-select, 56 | .fsb-original-select { 57 | min-width: 0; 58 | border: 1px solid #ccc; 59 | border: var(--fsb-border); 60 | border-radius: 5px; 61 | border-radius: var(--fsb-radius); 62 | box-sizing: border-box; 63 | color: inherit; 64 | color: var(--fsb-color); 65 | background-color: #fff; 66 | background-color: var(--fsb-background); 67 | font-size: 1em; 68 | font-size: var(--fsb-font-size); 69 | box-shadow: none; 70 | box-shadow: var(--fsb-shadow); 71 | } 72 | 73 | .fsb-select svg { 74 | width: 1em; 75 | height: 1em; 76 | margin-right: 8px; 77 | margin-right: var(--fsb-padding-right); 78 | fill: currentColor; 79 | fill: var(--fsb-icon-color); 80 | pointer-events: none; 81 | } 82 | 83 | .fsb-label { 84 | display: none; 85 | } 86 | 87 | /* While it's common sense to avoid using !important as much as possible, it is used 88 | * here to prevent inheriting style from other rules that may target buttons. */ 89 | .fsb-button { 90 | display: flex !important; 91 | align-items: center; 92 | position: relative !important; 93 | width: 100% !important; 94 | height: 100% !important; 95 | margin: 0 !important; 96 | padding: 8px 22px 8px 8px !important; 97 | padding: var(--fsb-padding) !important; 98 | padding-right: calc(var(--fsb-arrow-size) + var(--fsb-arrow-padding) + var(--fsb-padding-right)) !important; 99 | border: 0 !important; 100 | border-radius: inherit !important; 101 | color: inherit !important; 102 | background-color: inherit !important; 103 | font-size: 1em !important; 104 | font-family: inherit !important; 105 | text-align: inherit !important; 106 | white-space: nowrap; 107 | text-overflow: ellipsis; 108 | overflow: hidden; 109 | } 110 | 111 | .fsb-button > span { 112 | white-space: nowrap; 113 | text-overflow: ellipsis; 114 | overflow: hidden; 115 | } 116 | 117 | .fsb-button > span, 118 | .fsb-option > span { 119 | pointer-events: none; 120 | } 121 | 122 | select[disabled] + .fsb-select .fsb-button { 123 | opacity: .4; 124 | pointer-events: none; 125 | } 126 | 127 | .fsb-button:after, 128 | select[disabled] + .fsb-select .fsb-button[aria-expanded="true"]:after { 129 | content: ''; 130 | display: block; 131 | position: absolute; 132 | width: 6px; 133 | width: var(--fsb-arrow-size); 134 | height: 6px; 135 | height: var(--fsb-arrow-size); 136 | right: 8px; 137 | right: var(--fsb-arrow-padding); 138 | top: 50%; 139 | transform: translateY(-65%) rotateZ(45deg); 140 | border: solid currentColor; 141 | border: solid var(--fsb-arrow-color); 142 | border-width: 0 1.5px 1.5px 0; 143 | box-sizing: border-box; 144 | transition: transform .3s ease-in-out; 145 | pointer-events: none; 146 | } 147 | 148 | .fsb-button[aria-expanded="true"]:after { 149 | transform: translateY(-35%) rotateZ(225deg); 150 | } 151 | 152 | .fsb-list, 153 | select[disabled] + .fsb-select .fsb-list { 154 | display: block; 155 | visibility: hidden; 156 | position: absolute; 157 | min-width: 100%; 158 | height: 0; 159 | margin: 0; 160 | left: 0; 161 | top: 100%; 162 | z-index: 1; 163 | padding: 0; 164 | border: inherit; 165 | border: var(--fsb-list-border); 166 | border-radius: inherit; 167 | border-radius: var(--fsb-list-radius); 168 | box-sizing: border-box; 169 | color: inherit; 170 | color: var(--fsb-list-color); 171 | background-color: inherit; 172 | background-color: var(--fsb-list-background); 173 | box-shadow: 0 2px 8px rgba(0, 0, 0, .2); 174 | opacity: 0; 175 | transition: opacity .2s ease-in-out; 176 | overflow: auto; 177 | } 178 | 179 | .fsb-top .fsb-list { 180 | top: auto; 181 | bottom: 100%; 182 | box-shadow: 0 -2px 8px rgba(0, 0, 0, .2); 183 | } 184 | 185 | .fsb-button[aria-expanded="true"] + .fsb-list { 186 | height: auto; 187 | max-height: var(--fsb-list-height); 188 | visibility: visible; 189 | opacity: 1; 190 | } 191 | 192 | .fsb-option { 193 | display: flex; 194 | align-items: center; 195 | padding: var(--fsb-padding); 196 | white-space: nowrap; 197 | text-overflow: ellipsis; 198 | overflow: hidden; 199 | } 200 | 201 | .fsb-option:not([aria-disabled="true"]):focus { 202 | outline: none; 203 | color: var(--fsb-hover-color); 204 | background-color: var(--fsb-hover-background); 205 | } 206 | 207 | .fsb-option[aria-disabled="true"] { 208 | opacity: var(--fsb-disabled-opacity); 209 | } 210 | 211 | .fsb-resize { 212 | display: block; 213 | height: 0; 214 | padding-right: 14px; 215 | padding-right: calc(var(--fsb-arrow-padding) * 2 + var(--fsb-arrow-size) - var(--fsb-padding-right)); 216 | box-sizing: border-box; 217 | } 218 | 219 | .fsb-resize > * { 220 | display: block; 221 | } -------------------------------------------------------------------------------- /src/fancyselect.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2021 Momo Bassit. 3 | * Licensed under the MIT License (MIT) 4 | * https://github.com/mdbassit/fancySelect 5 | */ 6 | 7 | (function (window, document, autoInitialize) { 8 | 9 | let currentElement = null; 10 | let currentFocus = null; 11 | let searchString = ''; 12 | let searchTimeout = null; 13 | let counter = 0; 14 | 15 | /** 16 | * Initialize the custom select box elements. 17 | * @param {string} [selector] An optional selector representing native select elements. 18 | */ 19 | function init(selector) { 20 | selector = selector || 'select:not(.fsb-ignore)'; 21 | 22 | // Replace all eligible native select elements with custom select boxes 23 | document.querySelectorAll(selector).forEach(replaceNativeSelect); 24 | } 25 | 26 | /** 27 | * Replace a native select element with a custom select box. 28 | * @param {object} select The native select. 29 | * @param {function} [renderer] An optional custom item label renderer. 30 | */ 31 | function replaceNativeSelect(select, renderer) { 32 | // Skip if the native select has already been processed 33 | if (select.nextElementSibling && select.nextElementSibling.classList.contains('fsb-select')) { 34 | return; 35 | } 36 | 37 | const options = select.children; 38 | const parentNode = select.parentNode; 39 | const customSelect = document.createElement('span'); 40 | const label = document.createElement('span'); 41 | const button = document.createElement('button'); 42 | const list = document.createElement('span'); 43 | const widthAdjuster = document.createElement('span'); 44 | const index = counter++; 45 | 46 | // Add a custom CSS class to the native select element 47 | select.classList.add('fsb-original-select'); 48 | 49 | // Label for accessibility 50 | label.id = `fsb_${index}_label`; 51 | label.className = 'fsb-label'; 52 | label.textContent = getNativeSelectLabel(select, parentNode); 53 | 54 | // List box button 55 | button.id = `fsb_${index}_button`; 56 | button.className = 'fsb-button'; 57 | button.innerHTML = ' '; 58 | button.setAttribute('type', 'button'); 59 | button.setAttribute('aria-disabled', select.disabled); 60 | button.setAttribute('aria-haspopup', 'listbox'); 61 | button.setAttribute('aria-expanded', 'false'); 62 | button.setAttribute('aria-labelledby', `fsb_${index}_label fsb_${index}_button`); 63 | 64 | // List box 65 | list.className = 'fsb-list'; 66 | list.setAttribute('role', 'listbox'); 67 | list.setAttribute('tabindex', '-1'); 68 | list.setAttribute('aria-labelledby', `fsb_${index}_label`); 69 | 70 | // List items 71 | for (let i = 0, len = options.length; i < len; i++) { 72 | const { item, selected, itemLabel } = getItemFromOption(options[i], renderer); 73 | 74 | list.appendChild(item); 75 | 76 | if (selected) { 77 | button.innerHTML = itemLabel; 78 | } 79 | } 80 | 81 | // Custom select box container 82 | customSelect.className = 'fsb-select'; 83 | customSelect.appendChild(label); 84 | customSelect.appendChild(button); 85 | customSelect.appendChild(list); 86 | customSelect.appendChild(widthAdjuster); 87 | 88 | // Hide the native select 89 | select.style.display = 'none'; 90 | 91 | // Insert the custom select box after the native select 92 | if (select.nextSibling) { 93 | parentNode.insertBefore(customSelect, select.nextSibling); 94 | } else { 95 | parentNode.appendChild(customSelect); 96 | } 97 | 98 | // Force the select box to take the width of the longest item by default 99 | if (list.firstElementChild) { 100 | const span = document.createElement('span'); 101 | 102 | span.style.width = `${list.firstElementChild.offsetWidth}px`; 103 | widthAdjuster.className = 'fsb-resize' 104 | widthAdjuster.appendChild(span); 105 | } 106 | } 107 | 108 | /** 109 | * Update the custom select box attached to a native select. 110 | * @param {object} select The native select. 111 | * @param {function} [renderer] An optional custom item label renderer. 112 | */ 113 | function updateFromNativeSelect(select, renderer) { 114 | const options = select.children; 115 | const parentNode = select.parentNode; 116 | const customSelect = select.nextElementSibling; 117 | 118 | // Abort if this native select hasn't been initialized 119 | if (!customSelect || !customSelect.classList.contains('fsb-select')) { 120 | return; 121 | } 122 | 123 | const label = customSelect.firstElementChild; 124 | const button = label.nextElementSibling; 125 | const list = button.nextElementSibling; 126 | const widthAdjuster = list.nextElementSibling; 127 | const listContent = document.createDocumentFragment(); 128 | 129 | // Update the accessibility label 130 | label.textContent = getNativeSelectLabel(select, parentNode); 131 | 132 | // Update the button status 133 | button.setAttribute('aria-disabled', select.disabled); 134 | 135 | // Generate the list items 136 | for (let i = 0, len = options.length; i < len; i++) { 137 | const { item, selected, itemLabel } = getItemFromOption(options[i], renderer); 138 | 139 | listContent.appendChild(item); 140 | 141 | if (selected) { 142 | button.innerHTML = itemLabel; 143 | } 144 | } 145 | 146 | // Clear the list box 147 | while (list.firstChild) { 148 | list.removeChild(list.firstChild); 149 | } 150 | 151 | // Update the list items 152 | list.appendChild(listContent); 153 | 154 | // Force the select box to take the width of the longest item by default 155 | if (list.firstElementChild) { 156 | widthAdjuster.firstElementChild.style.width = `${list.firstElementChild.offsetWidth}px`; 157 | } 158 | } 159 | 160 | /** 161 | * Try to guess the native select element's label if any. 162 | * @param {object} select The native select. 163 | * @param {object} parent The parent node. 164 | * @return {string} The native select's label or an empty string. 165 | */ 166 | function getNativeSelectLabel(select, parent) { 167 | const id = select.id; 168 | let labelElement; 169 | 170 | // If the select element is inside a label element 171 | if (parent.nodeName === 'LABEL') { 172 | labelElement = parent; 173 | 174 | // Or if the select element has an ID, and there is a label element 175 | // with an attribute "for" that points to that ID 176 | } else if (id !== undefined) { 177 | labelElement = document.querySelector(`label[for="${id}"]`); 178 | } 179 | 180 | // If a label element is found, return the first non empty child text node 181 | if (labelElement) { 182 | const textNodes = [].filter.call(labelElement.childNodes, n => n.nodeType === 3); 183 | const texts = textNodes.map(n => n.textContent.replace(/\s+/g, ' ').trim()); 184 | const label = texts.filter(l => l !== '')[0]; 185 | 186 | if (label) { 187 | // Open the list box on click on the label element 188 | labelElement.onclick = event => { 189 | select.nextElementSibling.querySelector('button').click(); 190 | event.preventDefault(); 191 | event.stopImmediatePropagation(); 192 | } 193 | 194 | return label; 195 | } 196 | } 197 | 198 | return ''; 199 | } 200 | 201 | /** 202 | * Generate a listbox item from a native select option. 203 | * @param {object} option The native select option. 204 | * @param {function} [renderer] An optional custom item label renderer. 205 | * @return {object} The listbox item, its selected state and its label. 206 | */ 207 | function getItemFromOption(option, renderer) { 208 | const item = document.createElement('span'); 209 | const selected = option.selected; 210 | const itemLabel = getItemLabel(option, renderer); 211 | 212 | item.className = 'fsb-option'; 213 | item.innerHTML = itemLabel; 214 | item.setAttribute('role', 'option'); 215 | item.setAttribute('tabindex', '-1'); 216 | item.setAttribute('aria-selected', selected); 217 | 218 | if (option.disabled) { 219 | item.setAttribute('aria-disabled', option.disabled); 220 | } 221 | 222 | return { item, selected, itemLabel }; 223 | } 224 | 225 | /** 226 | * Render a listbox item's label. 227 | * @param {object} option The native select option. 228 | * @param {function} [renderer] An optional custom item label renderer. 229 | * @return {string} The listbox item's label. 230 | */ 231 | function getItemLabel(option, renderer) { 232 | if (typeof renderer === 'function') { 233 | return renderer(option); 234 | } 235 | 236 | const text = option.text; 237 | const icon = option.getAttribute('data-icon'); 238 | let label = text !== '' ? text : ' '; 239 | 240 | // Wrap label in a span to better handle long text 241 | label = `${label}`; 242 | 243 | if (icon !== null) { 244 | label = ` ${label}`; 245 | } 246 | 247 | return label; 248 | } 249 | 250 | /** 251 | * Open a list box. 252 | * @param {object} button The button to which the list box is attached. 253 | */ 254 | function openListBox(button) { 255 | const rect = button.getBoundingClientRect(); 256 | const list = button.nextElementSibling; 257 | let selectedItem = list.querySelector('[aria-selected="true"]'); 258 | 259 | if (!selectedItem) { 260 | selectedItem = list.firstElementChild; 261 | } 262 | 263 | // Open the list box and focus the selected item 264 | button.parentNode.className = 'fsb-select'; 265 | button.setAttribute('aria-expanded', 'true'); 266 | selectedItem.focus(); 267 | currentElement = button; 268 | currentFocus = selectedItem; 269 | 270 | // Position the list box on top of the button if there isn't enough space on the bottom 271 | if (rect.y + rect.height + list.offsetHeight > document.documentElement.clientHeight) { 272 | button.parentNode.className = 'fsb-select fsb-top'; 273 | } 274 | } 275 | 276 | /** 277 | * Close the active list box. 278 | * @param {boolean} focusButton If true, set focus on the button to which the list box is attached. 279 | */ 280 | function closeListBox(focusButton) { 281 | const activeListBox = document.querySelector('.fsb-button[aria-expanded="true"]'); 282 | 283 | if (activeListBox) { 284 | activeListBox.setAttribute('aria-expanded', 'false'); 285 | 286 | if (focusButton) { 287 | activeListBox.focus(); 288 | } 289 | 290 | // Clear the search string in case someone is a ninja!!! 291 | searchString = ''; 292 | searchTimeout = null; 293 | } 294 | 295 | currentElement = null; 296 | currentFocus = null; 297 | } 298 | 299 | /** 300 | * Set the selected item. 301 | * @param {object} item The item to be selected. 302 | */ 303 | function selectItem(item) { 304 | const list = item.parentNode; 305 | const button = list.previousElementSibling; 306 | const itemIndex = [].indexOf.call(list.children, item); 307 | const selectedItem = list.querySelector('[aria-selected="true"]'); 308 | const originalSelect = list.parentNode.previousElementSibling; 309 | 310 | 311 | if (selectedItem) { 312 | selectedItem.setAttribute('aria-selected', 'false'); 313 | } 314 | 315 | item.setAttribute('aria-selected', 'true'); 316 | button.innerHTML = item.innerHTML; 317 | 318 | // Update the original select 319 | originalSelect.selectedIndex = itemIndex; 320 | originalSelect.dispatchEvent(new Event('input', { bubbles: true })); 321 | originalSelect.dispatchEvent(new Event('change', { bubbles: true })); 322 | } 323 | 324 | /** 325 | * Get the next item that matches a string. 326 | * @param {object} list The active list box. 327 | * @param {string} search The search string. 328 | * @return {object} The item that matches the string. 329 | */ 330 | function getMatchingItem(list, search) { 331 | const items = [].map.call(list.children, item => item.textContent.trim().toLowerCase()); 332 | const firstMatch = filterItems(items, search)[0]; 333 | 334 | // If an exact match is found, return it 335 | if (firstMatch) { 336 | return list.children[items.indexOf(firstMatch)]; 337 | 338 | // If the search string is the same character repeated multiple times 339 | // we need to cycle through the items starting with that character 340 | } else if (isRepeatedCharacter(search)) { 341 | // Get all the items matching the character 342 | const matches = filterItems(items, search[0]); 343 | 344 | // The match we want depends on the length of the repeated string 345 | // e.g: "aa" means the second item starting with "a" 346 | const matchIndex = (search.length - 1) % matches.length; 347 | 348 | // Return the match 349 | const match = matches[matchIndex]; 350 | return list.children[items.indexOf(match)]; 351 | } 352 | 353 | return null; 354 | } 355 | 356 | /** 357 | * Focus the next item that matches a string. 358 | * @param {object} list The active list box. 359 | */ 360 | function focusMatchingItem(list) { 361 | const item = getMatchingItem(list, searchString); 362 | 363 | if (item) { 364 | item.focus(); 365 | } 366 | } 367 | 368 | /** 369 | * Filter an array of string. 370 | * @param {array} items. 371 | * @param {string} filter The filter string. 372 | * @return {array} The array items that matches the filter. 373 | */ 374 | function filterItems(items, filter) { 375 | return items.filter(item => item.indexOf(filter.toLowerCase()) === 0); 376 | } 377 | 378 | /** 379 | * Check if the the user is typing printable characters. 380 | * @param {object} event A keydown event. 381 | * @return {boolean} True if the key pressed is a printable character. 382 | */ 383 | function isTyping(event) { 384 | const { key, altKey, ctrlKey, metaKey } = event; 385 | 386 | if (key.length === 1 && !altKey && !ctrlKey && !metaKey) { 387 | if (searchTimeout) { 388 | window.clearTimeout(searchTimeout); 389 | } 390 | 391 | searchTimeout = window.setTimeout(() => { 392 | searchString = ''; 393 | }, 500); 394 | 395 | searchString += key; 396 | return true; 397 | } 398 | 399 | return false; 400 | } 401 | 402 | /** 403 | * Check if a string is the same character repeated multiple times. 404 | * @param {string} str The string to check. 405 | * @return {boolean} True if the string the same character repeated multiple times (e.g "aaa"). 406 | */ 407 | function isRepeatedCharacter(str) { 408 | const characters = str.split(''); 409 | return characters.every(char => char === characters[0]); 410 | } 411 | 412 | /** 413 | * Find and focus the closest active option. 414 | * @param {object} option The starting option. 415 | * @param {string} dir The direction of the lookup (next, prev). 416 | */ 417 | function focusClosestActiveOption(option, dir) { 418 | if (!option) { 419 | return; 420 | } 421 | 422 | // Focus the starting option itself if it's active 423 | if (!option.getAttribute('aria-disabled')) { 424 | currentFocus = option; 425 | return option.focus(); 426 | } 427 | 428 | const options = Array.from(option.parentNode.children); 429 | const index = options.indexOf(option); 430 | 431 | if (dir === 'next') { 432 | for (let i = index + 1, len = options.length; i < len; i++) { 433 | if (!options[i].getAttribute('aria-disabled')) { 434 | currentFocus = options[i]; 435 | return options[i].focus(); 436 | } 437 | } 438 | } else { 439 | for (let i = index - 1; i >= 0; i--) { 440 | if (!options[i].getAttribute('aria-disabled')) { 441 | currentFocus = options[i]; 442 | return options[i].focus(); 443 | } 444 | } 445 | } 446 | } 447 | 448 | /** 449 | * Shortcut for addEventListener with delegation support. 450 | * @param {object} context The context to which the listener is attached. 451 | * @param {string} type Event type. 452 | * @param {(string|function)} selector Event target if delegation is used, event handler if not. 453 | * @param {function} [fn] Event handler if delegation is used. 454 | */ 455 | function addListener(context, type, selector, fn) { 456 | const matches = Element.prototype.matches || Element.prototype.msMatchesSelector; 457 | 458 | // Delegate event to the target of the selector 459 | if (typeof selector === 'string') { 460 | context.addEventListener(type, event => { 461 | if (matches.call(event.target, selector)) { 462 | fn.call(event.target, event); 463 | } 464 | }); 465 | 466 | // If the selector is not a string then it's a function 467 | // in which case we need regular event listener 468 | } else { 469 | fn = selector; 470 | context.addEventListener(type, fn); 471 | } 472 | } 473 | 474 | /** 475 | * Call a function only when the DOM is ready. 476 | * @param {function} fn The function to call. 477 | * @param {array} [args] Arguments to pass to the function. 478 | */ 479 | function DOMReady(fn, args) { 480 | args = args !== undefined ? args : []; 481 | 482 | if (document.readyState !== 'loading') { 483 | fn(...args); 484 | } else { 485 | document.addEventListener('DOMContentLoaded', () => { 486 | fn(...args); 487 | }); 488 | } 489 | } 490 | 491 | // On click on the list box button 492 | addListener(document, 'click', '.fsb-button', event => { 493 | const isClickToClose = currentElement === event.target; 494 | 495 | closeListBox(); 496 | 497 | if (!isClickToClose) { 498 | openListBox(event.target); 499 | } 500 | 501 | event.preventDefault(); 502 | event.stopImmediatePropagation(); 503 | }); 504 | 505 | // On key press on the list box button 506 | addListener(document, 'keydown', '.fsb-button', event => { 507 | const button = event.target; 508 | const list = button.nextElementSibling; 509 | let preventDefault = true; 510 | 511 | switch (event.key) { 512 | case 'ArrowUp': 513 | case 'ArrowDown': 514 | case 'Enter': 515 | case ' ': 516 | openListBox(button); 517 | break; 518 | default: 519 | if (isTyping(event)) { 520 | openListBox(button); 521 | focusMatchingItem(list); 522 | } else { 523 | preventDefault = false; 524 | } 525 | } 526 | 527 | if (preventDefault) { 528 | event.preventDefault(); 529 | } 530 | }); 531 | 532 | // When the mouse moves on an item, focus it. 533 | // Use mousemove instead of mouseover to prevent accidental focus on the wrong item, 534 | // namely when the list box is opened with a keyboard shortcut, and the mouse arrow 535 | // just happens to be on an item. 536 | addListener(document.documentElement, 'mousemove', '.fsb-option:not([aria-disabled="true"])', event => { 537 | event.target.focus(); 538 | currentFocus = event.target; 539 | }); 540 | 541 | // On click on an item 542 | addListener(document, 'click', '.fsb-option', event => { 543 | const item = event.target; 544 | 545 | if (!item.getAttribute('aria-disabled')) { 546 | selectItem(item); 547 | closeListBox(true); 548 | } else { 549 | event.stopImmediatePropagation(); 550 | currentFocus.focus(); 551 | } 552 | }); 553 | 554 | // On key press on an item 555 | addListener(document, 'keydown', '.fsb-option', event => { 556 | const item = event.target; 557 | const list = item.parentNode; 558 | let preventDefault = true; 559 | 560 | switch (event.key) { 561 | case 'ArrowUp': 562 | case 'ArrowLeft': 563 | focusClosestActiveOption(item.previousElementSibling, 'prev'); 564 | break; 565 | case 'ArrowDown': 566 | case 'ArrowRight': 567 | focusClosestActiveOption(item.nextElementSibling, 'next'); 568 | break; 569 | case 'Home': 570 | focusClosestActiveOption(list.firstElementChild, 'next'); 571 | break; 572 | case 'End': 573 | focusClosestActiveOption(list.lastElementChild, 'prev'); 574 | break; 575 | case 'PageUp': 576 | case 'PageDown': 577 | // Disable Page Up and Page Down keys 578 | break; 579 | case 'Tab': 580 | selectItem(item); 581 | closeListBox(); 582 | preventDefault = false; 583 | break; 584 | case 'Enter': 585 | case ' ': 586 | selectItem(item); 587 | case 'Escape': 588 | closeListBox(true); 589 | break; 590 | default: 591 | if (isTyping(event)) { 592 | focusMatchingItem(list); 593 | } else { 594 | preventDefault = false; 595 | } 596 | } 597 | 598 | if (preventDefault) { 599 | event.preventDefault(); 600 | } 601 | }); 602 | 603 | // On click outside the custom select box, close it 604 | addListener(document, 'click', event => { 605 | closeListBox(); 606 | }); 607 | 608 | // Expose the constructor to the global scope 609 | window.FancySelect = (() => { 610 | function FancySelect() { 611 | DOMReady(init); 612 | } 613 | 614 | // Available methodes 615 | FancySelect.init = init; 616 | FancySelect.replace = replaceNativeSelect; 617 | FancySelect.update = updateFromNativeSelect; 618 | 619 | return FancySelect; 620 | })(); 621 | 622 | // Initialize the custom select boxes when the DOM is ready 623 | if (autoInitialize) { 624 | DOMReady(init); 625 | } 626 | 627 | })(window, document, typeof FancySelectAutoInitialize !== 'undefined' ? FancySelectAutoInitialize : true ); --------------------------------------------------------------------------------