diff --git a/DynamicSelect.css b/DynamicSelect.css index 552ff2c..f4de0ce 100644 --- a/DynamicSelect.css +++ b/DynamicSelect.css @@ -1,3 +1,13 @@ +dynamic-select selection, dynamic-select selected, dynamic-select dropdown, dynamic-select item { + all: unset; + display: block; +} +dynamic-select[disabled], .dynamic-select .dynamic-select-options .dynamic-select-option[disabled] { + opacity: 0.5; + pointer-events: none; + background-color: transparent; + pointer-events: none; +} .dynamic-select { display: flex; box-sizing: border-box; @@ -11,7 +21,7 @@ padding: 7px 30px 7px 12px; } .dynamic-select .dynamic-select-header::after { - content: ""; + content: ''; display: block; position: absolute; top: 50%; @@ -66,7 +76,7 @@ .dynamic-select .dynamic-select-options .dynamic-select-option { padding: 7px 12px; } -.dynamic-select .dynamic-select-options .dynamic-select-option:hover, .dynamic-select .dynamic-select-options .dynamic-select-option:active { +.dynamic-select .dynamic-select-options .dynamic-select-option:hover, .dynamic-select .dynamic-select-options .dynamic-select-option:active, .dynamic-select .dynamic-select-options .dynamic-select-option.focus { background-color: #f3f4f7; } .dynamic-select .dynamic-select-header, .dynamic-select .dynamic-select-option { @@ -92,14 +102,14 @@ max-height: none; max-width: none; } -.dynamic-select .dynamic-select-header img, .dynamic-select .dynamic-select-header svg, .dynamic-select .dynamic-select-header i, .dynamic-select .dynamic-select-header span, .dynamic-select .dynamic-select-option img, .dynamic-select .dynamic-select-option svg, .dynamic-select .dynamic-select-option i, .dynamic-select .dynamic-select-option span { +.dynamic-select .dynamic-select-header img, .dynamic-select .dynamic-select-option img, .dynamic-select .dynamic-select-header svg, .dynamic-select .dynamic-select-option svg, .dynamic-select .dynamic-select-header i, .dynamic-select .dynamic-select-option i, .dynamic-select .dynamic-select-header span, .dynamic-select .dynamic-select-option span { box-sizing: border-box; margin-right: 10px; } .dynamic-select .dynamic-select-header.dynamic-select-no-text, .dynamic-select .dynamic-select-option.dynamic-select-no-text { justify-content: center; } -.dynamic-select .dynamic-select-header.dynamic-select-no-text img, .dynamic-select .dynamic-select-header.dynamic-select-no-text svg, .dynamic-select .dynamic-select-header.dynamic-select-no-text i, .dynamic-select .dynamic-select-header.dynamic-select-no-text span, .dynamic-select .dynamic-select-option.dynamic-select-no-text img, .dynamic-select .dynamic-select-option.dynamic-select-no-text svg, .dynamic-select .dynamic-select-option.dynamic-select-no-text i, .dynamic-select .dynamic-select-option.dynamic-select-no-text span { +.dynamic-select .dynamic-select-header.dynamic-select-no-text img, .dynamic-select .dynamic-select-option.dynamic-select-no-text img, .dynamic-select .dynamic-select-header.dynamic-select-no-text svg, .dynamic-select .dynamic-select-option.dynamic-select-no-text svg, .dynamic-select .dynamic-select-header.dynamic-select-no-text i, .dynamic-select .dynamic-select-option.dynamic-select-no-text i, .dynamic-select .dynamic-select-header.dynamic-select-no-text span, .dynamic-select .dynamic-select-option.dynamic-select-no-text span { margin-right: 0; } .dynamic-select .dynamic-select-header .dynamic-select-option-text, .dynamic-select .dynamic-select-option .dynamic-select-option-text { @@ -110,4 +120,4 @@ white-space: nowrap; color: inherit; font-size: inherit; -} \ No newline at end of file +} diff --git a/DynamicSelect.js b/DynamicSelect.js index 601ba9e..34d7a08 100644 --- a/DynamicSelect.js +++ b/DynamicSelect.js @@ -1,28 +1,68 @@ /* - * Created by David Adams + * Created by "David Adams" * https://codeshack.io/dynamic-select-images-html-javascript/ * * Released under the MIT license + * + * Modified by 'Gabriel Książek' (McRaZick) | https://github.com/gubrus50 + * Contribution date: 'From 02/April/2025 To 08/April/2025 (Timezone: UTC-0)' + * Implementation: + * + added keyboard navigability/accessibility for DynamicSelect. + * + added support for "disabled" attribute. + * -/+ replaced primary elements with custom semantic elements: + * this.element = + * .dynamic-select = + * .dynamic-select-selected = + * .dynamic-select-options = + * .dynamic-select-option = + * + * -/+ moved ID attribute FROM TO + * + added support for extending classes and styles for most semantic elements. + * + 'new DynamicSelect()' object now inherits options from target -
${this.placeholder}
-
${optionsHTML}
- + + + ${this.placeholder} + ${optionsHTML} + `; - let element = document.createElement('div'); + + let element = document.createElement('dynamic-select'); + element.setAttribute('id', this.selectElement.id && this.selectElement.id); + element.setAttribute('class', this.options.class); + element.setAttribute('style', this.options.style); + element.setAttribute('tabindex', this.tabindex); + (this.disabled == true) && + element.setAttribute('disabled', ''); element.innerHTML = template; return element; } _eventHandlers() { - this.element.querySelectorAll('.dynamic-select-option').forEach(option => { - option.onclick = () => { - this.element.querySelectorAll('.dynamic-select-selected').forEach(selected => selected.classList.remove('dynamic-select-selected')); - option.classList.add('dynamic-select-selected'); - this.element.querySelector('.dynamic-select-header').innerHTML = option.innerHTML; - this.element.querySelector('input').value = option.getAttribute('data-value'); - this.data.forEach(data => data.selected = false); - this.data.filter(data => data.value == option.getAttribute('data-value'))[0].selected = true; - this.element.querySelector('.dynamic-select-header').classList.remove('dynamic-select-header-active'); - this.options.onChange(option.getAttribute('data-value'), option.querySelector('.dynamic-select-option-text') ? option.querySelector('.dynamic-select-option-text').innerHTML : '', option); - }; - }); - this.element.querySelector('.dynamic-select-header').onclick = () => { - this.element.querySelector('.dynamic-select-header').classList.toggle('dynamic-select-header-active'); - }; - if (this.selectElement.id && document.querySelector('label[for="' + this.selectElement.id + '"]')) { - document.querySelector('label[for="' + this.selectElement.id + '"]').onclick = () => { - this.element.querySelector('.dynamic-select-header').classList.toggle('dynamic-select-header-active'); - }; + + const getContextOfClickedTargetDynamicOption = (eventTarget) => { + const parentOptions = eventTarget.closest('.dynamic-select-options'); + const clickedOption = eventTarget.closest('.dynamic-select-option'); + return !parentOptions + && !clickedOption + ? { clicked: false } + : { clicked: !!parentOptions, disabled: !clickedOption } } + + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'disabled'); + this.disabled = this.element.hasAttribute('disabled'); + })}).observe(this.element, { attributes: true }); + + const header = this.element.querySelector('.dynamic-select-header'); + this.open = () => {(!this.disabled) && header.classList.add('dynamic-select-header-active'); this.scrollToSelectedOption()} + this.close = () => (!this.disabled) && header.classList.remove('dynamic-select-header-active'); + this.toggle = () => {(!this.disabled) && header.classList.toggle('dynamic-select-header-active'); this.scrollToSelectedOption()} + + this.element.querySelectorAll('.dynamic-select-option').forEach(option => + option.addEventListener('click', () => this.optionElement = option)); + + this.element.addEventListener('click', (event) => { + if (document.activeElement !== this.element) return; + if (!header.classList.contains('dynamic-select-header-active')) return this.open(); + if (event.target.closest('dynamic-select')) + { + const option = getContextOfClickedTargetDynamicOption(event.target); + if (!option.clicked) return this.toggle(); + else (option.clicked && !option.disabled) && this.close(); + } + }); + + this.element.addEventListener('blur', () => setTimeout(() => + (!this.element.contains(document.activeElement)) && this.close(), 50)); + + if (this.selectElement.id && document.querySelector(`label[for="${this.selectElement.id}"]`)) + document.querySelector(`label[for="${this.selectElement.id}"]`).onclick = () => this.toggle(); + document.addEventListener('click', event => { - if (!event.target.closest('.' + this.name) && !event.target.closest('label[for="' + this.selectElement.id + '"]')) { - this.element.querySelector('.dynamic-select-header').classList.remove('dynamic-select-header-active'); + if (!event.target.closest('.' + this.name) + && !event.target.closest('label[for="' + this.selectElement.id + '"]') + && !event.target.closest('div:has(.' + this.name + ')')?.children[0].classList.contains(this.name) + && event.target.closest('dynamic-select') !== this.element) + { + + const option = getContextOfClickedTargetDynamicOption(event.target); + if (option.clicked && !option.disabled) this.close(); + } + }); + + const reservedKeys = ['Escape', 'Enter', ' ', 'Shift', 'Tab', 'ArrowUp', 'ArrowDown']; + + document.addEventListener('keydown', event => { + if (document.activeElement !== this.element + || !event.target.querySelector('.dynamic-select-header.dynamic-select-header-active') && event.key == 'Tab') return; + if (reservedKeys.includes(event.key) || event.code == 'Space') event.preventDefault(); + + if (event.key == 'Enter') this.toggle(); + else if (event.key == ' ' + || event.code == 'Space') this.open(); + + let newOption, direction, + value = this.element.querySelector(`input[name="${this.name}"]`).value, + index = this.data.findIndex(data => data.value == value), + options = [...this.element.querySelectorAll('.dynamic-select-option')]; + if (!options) throw new Error('No valid DynamicSelect options available'); + + + const nextIndex = () => { index++; + if (index >= this.data.length && event.key == 'ArrowDown') index = 0; + else if (index >= this.data.length && event.key == 'Tab') this.close(); + }; + + const prevIndex = () => { index--; + if (index < 0 && event.key == 'ArrowUp') index = this.data.length - 1; + else if (index < 0 && (event.key == 'Tab' && event.shiftKey == true)) this.close(); + }; + + const findOptionByLetter = (letter, direction) => { + letter = letter.toLowerCase().trim(); + + const currentIndex = options.findIndex(opt => opt === this.optionElement); + const step = direction === 'next' ? 1 : -1; + + // Start from current position (or beginning/end if not found) + let start = currentIndex !== -1 ? currentIndex : (step === 1 ? -1 : options.length); + + for (let i = 1; i <= options.length; i++) { + const index = (start + step * i + options.length) % options.length; + const option = options[index]; + const text = option.querySelector('.dynamic-select-option-text')?.textContent.toLowerCase().trim(); + + if (!text) { + console.warn('Missing .dynamic-select-option-text in option', option); + continue; + } + + if (text.startsWith(letter) && !option.hasAttribute('disabled')) return option; + } + return null; + }; + + + if (event.key == 'Escape') { + return this.close(); + } + else if (event.key == 'ArrowUp' || (event.key == 'Tab' && event.shiftKey == true)) { + direction = 'prev'; prevIndex(); + } + else if (event.key == 'ArrowDown' || event.key == 'Tab') { + direction = 'next'; nextIndex(); + } + else if (event.key.length === 1 && event.key.match(/^\p{Letter}/u)) { + direction = event.shiftKey ? 'prev' : 'next'; + newOption = findOptionByLetter(event.key, direction); + if (newOption + && newOption !== this.optionElement + && !newOption.hasAttribute('disabled')) this.optionElement = newOption; + return; + } + else return; + + + for (let i = 0; i < options.length; i++) { + newOption = options[index]; + if (newOption + && !newOption.hasAttribute('disabled')) return this.optionElement = newOption; + + else direction === 'next' ? nextIndex() : prevIndex(); } }); } @@ -109,6 +269,18 @@ class DynamicSelect { } } + scrollToSelectedOption() { + if (!this.options.optionElement) return; + const containerRect = this.element.querySelector('.dynamic-select-options').getBoundingClientRect(); + const optionRect = this.options.optionElement.getBoundingClientRect(); + + if (optionRect.top < containerRect.top + || optionRect.bottom > containerRect.bottom + || optionRect.left < containerRect.left + || optionRect.right > containerRect.right) + this.options.optionElement.scrollIntoView({ block: 'nearest' }); + } + get selectedValue() { let selected = this.data.filter(option => option.selected); selected = selected.length ? selected[0].value : ''; @@ -131,6 +303,36 @@ class DynamicSelect { return this.options.selectElement; } + set optionElement(value) { + if (!value?.classList.contains('dynamic-select-option') + || value.hasAttribute('disabled') + || this.element.hasAttribute('disabled')) return; + + this.options.optionElement = value; + + this.element.querySelectorAll('.dynamic-select-selected').forEach(selected => selected.classList.remove('dynamic-select-selected')); + this.options.optionElement.classList.add('dynamic-select-selected'); + this.element.querySelector('.dynamic-select-header').innerHTML = this.options.optionElement.innerHTML; + this.element.querySelector(`input[name="${this.name}"]`).value = this.options.optionElement.getAttribute('data-value'); + this.data.forEach(data => data.selected = false); + this.data.filter(data => data.value == this.options.optionElement.getAttribute('data-value'))[0].selected = true; + + this.options.onChange( + this.options.optionElement.getAttribute('data-value'), + this.options.optionElement.querySelector('.dynamic-select-option-text') ? + this.options.optionElement.querySelector('.dynamic-select-option-text').innerHTML : '', + this.options.optionElement + ); + + this.scrollToSelectedOption(); + this.element.querySelectorAll('.dynamic-select-option').forEach(option => option.classList.remove('focus')); + this.optionElement.classList.toggle('focus'); + } + + get optionElement() { + return this.options.optionElement; + } + set element(value) { this.options.element = value; } @@ -147,6 +349,14 @@ class DynamicSelect { return this.options.placeholder; } + set tabindex(value) { + this.options.tabindex = value; + } + + get tabindex() { + return this.options.tabindex; + } + set columns(value) { this.options.columns = value; } @@ -179,5 +389,16 @@ class DynamicSelect { return this.options.height; } + set disabled(value) { + this.options.disabled = value; + this.disabled == true + ? this.element.querySelector(`input[name="${this.name}"]`).setAttribute('disabled','') + : this.element.querySelector(`input[name="${this.name}"]`).removeAttribute('disabled'); + } + + get disabled() { + return this.options.disabled; + } } + document.querySelectorAll('[data-dynamic-select]').forEach(select => new DynamicSelect(select)); \ No newline at end of file diff --git a/DynamicSelect.scss b/DynamicSelect.scss index 97cdfd4..b3d2373 100644 --- a/DynamicSelect.scss +++ b/DynamicSelect.scss @@ -1,3 +1,16 @@ +dynamic-select { + selection, selected, dropdown, item { + all: unset; + display: block; + } +} +dynamic-select[disabled], +.dynamic-select .dynamic-select-options .dynamic-select-option[disabled] { + opacity: 0.5; + pointer-events: none; + background-color: transparent; + pointer-events: none; +} .dynamic-select { display: flex; box-sizing: border-box; @@ -63,7 +76,7 @@ } .dynamic-select-option { padding: 7px 12px; - &:hover, &:active { + &:hover, &:active, &.focus { background-color: #f3f4f7; } } @@ -110,4 +123,4 @@ font-size: inherit; } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index f308316..1766c2c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ The complete guide and reference is available here: [https://codeshack.io/multi- - Customizable placeholder text - Multiple columns for dropdown options - Custom HTML content for options +- Semantic naming convension - Easy integration with existing forms +- Navigability with tabulation and UP/DOWN arrow keys +- Toggle, Open & Close with keyboard buttons: **Enter**, **Spacebar** & **Esc** +- Search by first letter using any keyboard buttons (a-z, 0-9 + supports UNICODE) +- Reverse search by holding **shift** key +- Disable functionality of dynamic select element and any option from the dropdown in realtime - Lightweight and fast ## Screenshot @@ -30,9 +36,9 @@ The complete guide and reference is available here: [https://codeshack.io/multi- 3. Initialize the dynamic select element in your HTML file: ```html ``` @@ -49,6 +55,12 @@ The complete guide and reference is available here: [https://codeshack.io/multi- }); ``` +`new DynamicSelect()` object inherits attribute data from the target element. Hence property: **placeholder**, overwrites dataset attribute: **data-placeholder**. + +Additionally, **class** and **style** attributes are reserved for the `` element. +- `disabled`: Disabled attribute for the `` element. +
+ +**NOTE** - Added `disabled` **attribute** to `` also disables ``'s ``.
Additionally, `disabled` **property** requires a boolean value for it to be initialized and updated. +
+ +- `style`: Style attribute for the `` element. +- `class`: Class attribute for the `` element. +- `width`: Width attribute for the `` element. +- `height`: Height attribute for the `` element. +- `selectionStyle`: Style attribute for the `` element. +- `selectionClass`: Class attribute for the `` element. +- `selectedStyle`: Style attribute for the `` element. +- `selectedClass`: Class attribute for the `` element. +
+ +- `dropdownWidth`: Width attribute for the `` element. +- `dropdownHeight`: Height attribute for the `` element. +- `dropdownStyle`: Style attribute for the `` element. +- `dropdownClass`: Class attribute for the `` element. +- `itemsStyle`: Style attribute for all instances of the `` element. +- `itemsClass`: Class attribute for all instances of the `` element. +
+ +**NOTE** - Any **style** or **class** option, for example: **dropdownClass**, overwrites its defined properties: width, height, + and any styles from the *"DynamicSelect.css"* stylesheet. +
+ +- `data`: Array of objects representing the select options: `` element. + +
+ +**NOTE** - Present `