├── .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 |
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 | My select
49 |
50 | Neptunium
51 | Plutonium
52 | Americium
53 |
54 |
55 |
56 | My fancy select
57 |
58 | Neptunium
59 | Plutonium
60 | Americium
61 |
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 | My fancy select
109 |
110 | Neptunium
111 | Plutonium
112 | Americium
113 |
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 | Options with icons
148 |
149 | Neptunium
150 | Plutonium
151 | Americium
152 |
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 | Options with icons
170 |
171 | Home
172 | Delete
173 | Bookmark
174 |
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 |
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 | Default style
16 |
17 | Neptunium
18 | Plutonium
19 | Americium
20 | Curium
21 | Berkelium
22 | Californium
23 | Einsteinium
24 | Fermium
25 | Mendelevium
26 | Nobelium
27 | Lawrencium
28 | Rutherfordium
29 | Dubnium
30 | Seaborgium
31 | Bohrium
32 | Hassium
33 | Meitnerium
34 | Darmstadtium
35 | Roentgenium
36 | Copernicium
37 | Nihonium
38 | Flerovium
39 | Moscovium
40 | Livermorium
41 | Tennessine
42 | Oganesson
43 |
44 |
45 |
46 | Full width of parent
47 |
48 | Neptunium
49 | Plutonium
50 | Americium
51 | Curium
52 | Berkelium
53 | Californium
54 | Einsteinium
55 | Fermium
56 | Mendelevium
57 | Nobelium
58 | Lawrencium
59 | Rutherfordium
60 | Dubnium
61 | Seaborgium
62 | Bohrium
63 | Hassium
64 | Meitnerium
65 | Darmstadtium
66 | Roentgenium
67 | Copernicium
68 | Nihonium
69 | Flerovium
70 | Moscovium
71 | Livermorium
72 | Tennessine
73 | Oganesson
74 |
75 |
76 |
77 | Custom style
78 |
79 | Neptunium
80 | Plutonium
81 | Americium
82 | Curium
83 | Berkelium
84 | Californium
85 | Einsteinium
86 | Fermium
87 | Mendelevium
88 | Nobelium
89 | Lawrencium
90 | Rutherfordium
91 | Dubnium
92 | Seaborgium
93 | Bohrium
94 | Hassium
95 | Meitnerium
96 | Darmstadtium
97 | Roentgenium
98 | Copernicium
99 | Nihonium
100 | Flerovium
101 | Moscovium
102 | Livermorium
103 | Tennessine
104 | Oganesson
105 |
106 |
107 |
108 | Custom style and icon support
109 |
110 | Neptunium
111 | Plutonium
112 | Americium
113 | Curium
114 | Berkelium
115 | Californium
116 | Einsteinium
117 | Fermium
118 | Mendelevium
119 | Nobelium
120 | Lawrencium
121 | Rutherfordium
122 | Dubnium
123 | Seaborgium
124 | Bohrium
125 | Hassium
126 | Meitnerium
127 | Darmstadtium
128 | Roentgenium
129 | Copernicium
130 | Nihonium
131 | Flerovium
132 | Moscovium
133 | Livermorium
134 | Tennessine
135 | Oganesson
136 |
137 |
138 |
139 | Auto-position example (Menu opens on top unless you scroll down)
140 |
141 | Neptunium
142 | Plutonium
143 | Americium
144 | Curium
145 | Berkelium
146 | Californium
147 | Einsteinium
148 | Fermium
149 | Mendelevium
150 | Nobelium
151 | Lawrencium
152 | Rutherfordium
153 | Dubnium
154 | Seaborgium
155 | Bohrium
156 | Hassium
157 | Meitnerium
158 | Darmstadtium
159 | Roentgenium
160 | Copernicium
161 | Nihonium
162 | Flerovium
163 | Moscovium
164 | Livermorium
165 | Tennessine
166 | Oganesson
167 |
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 );
--------------------------------------------------------------------------------