UNPKG

baseframe-js

Version:

A suite of useful Javascript plugins and functions to help with Front-end Development on websites

561 lines (560 loc) 21.8 kB
import $ from 'cash-dom'; import Store from "./core/Store"; import { KEYS } from "./core/constants"; import { isMobileOS, getDataOptions, noop } from "./util/helpers"; const VERSION = "3.0.0"; const EVENT_NAME = 'selectEnhance'; const DATA_NAME = 'SelectEnhance'; const DEFAULTS = { cssPrefix: 'select-enhance', cssListModifer: null, mobileNative: false, emptyValAsPlaceholder: true, focusIn: noop, focusOut: noop, beforeChange: noop, afterChange: noop, blurDuration: 250, typeAheadDuration: 500, observeSelectbox: true }; // TOOD: // - Search with Keyboard // wrap the select first const mobileOS = isMobileOS(); // helper const getSelectedOptNode = ($el) => $el.find('option').filter(function () { return this.selected; })[0]; let currSelectEnhance = null; export default class SelectEnhance { $select; select; params; index; id; selectId; isReadOnly; $label; $selectEnhance; selectEnhance; $textInput; textInput; $selectList; selectList; optionSet; listPosTop; optionsShown; selectboxObserver; selectListBoxInFullView; keyedInput; posTimeout; bodyCloseEvt; static defaults = DEFAULTS; static version = VERSION; static pluginName = DATA_NAME; constructor(element, options, index) { const s = this; s.index = index; s.$select = $(element); s.select = element; s.id = s.$select.attr('id') || s.$select.attr('name') || 'select_enhance_' + index; s.selectId = s.id + '_enhance'; s.$label = $(`label[for="${s.id}"]`); s.optionSet = new WeakMap(); s.optionsShown = false; s.listPosTop = false; s.selectboxObserver; s.selectListBoxInFullView = true; s.keyedInput = ""; s.posTimeout = null; s.bodyCloseEvt = 'click.close_' + s.selectId + EVENT_NAME; s.isReadOnly = typeof s.$select.attr('readonly') === "string"; if (s.select.multiple) { console.warn(`The SelectEnhance plugin doesn't support multiple selections.`); } if (s.$label.length) { s.$label.attr({ id: s.selectId + '_lbl' }); } const dataOptions = getDataOptions(element, EVENT_NAME); s.params = $.extend({}, SelectEnhance.defaults, options, dataOptions); s.setUpSelectHtml(); if (mobileOS && s.params.mobileNative || s.isReadOnly) { s.mobileOnlyIfNavite(); } else { // // attach events // if (s.params.observeSelectbox) { s.selectInputMutationObserver(); } s.eventLabelClick(); s.eventKeyboardSearch(); s.eventShowOptions(); s.eventOptionClick(); s.eventArrowKeys(); s.eventResizeClose(); s.observeSelectListBoxInFullView(); } return s; } mobileOnlyIfNavite() { const s = this; let prevElSelectedVal = getSelectedOptNode(s.$select); s.$select.on('mouseup.' + EVENT_NAME, 'option', function (e) { if (!this.isSameNode(prevElSelectedVal)) { s.params.beforeChange(s.$select); } }).on('change.' + EVENT_NAME, function (e) { s.params.afterChange(s.$select); prevElSelectedVal = getSelectedOptNode(s.$select); }); } showOptions() { const s = this; if (s.select.disabled || s.optionsShown) { return; } s.optionsShown = true; currSelectEnhance = s.selectEnhance; const { cssPrefix } = s.params; const $selectedBtn = s.$selectList.find('[aria-selected="true"]'); document.body.append(s.selectList); s.$selectEnhance.toggleClass(cssPrefix + '--focused'); s.$textInput.attr({ 'aria-expanded': 'true' }); s.getListPosition(); s.eventScrollGetListPosition(true); s.eventOutsideClickClose(); if ($selectedBtn.length) { $selectedBtn[0].focus(); } s.params.focusIn(s.$select); } closeOptions(focusBack = true) { const s = this; const { cssPrefix } = s.params; s.optionsShown = false; s.$selectEnhance .addClass(cssPrefix + '--blurring') .removeClass(cssPrefix + '--focused'); s.$textInput.attr({ 'aria-expanded': 'false' }); s.$selectList .addClass(cssPrefix + '__list--blurring') .removeClass(cssPrefix + '__list--focused'); // remove the close event on the body $(document.body).off(s.bodyCloseEvt); setTimeout(() => { s.$selectEnhance.removeClass(cssPrefix + '--blurring ' + cssPrefix + '--pos-above'); s.$selectList.detach() .removeClass(cssPrefix + '__list--blurring ' + cssPrefix + '__list--focused' + cssPrefix + '__list--pos-above'); s.params.focusOut(s.$select); if (focusBack && s.selectEnhance.isSameNode(currSelectEnhance)) { s.textInput.focus(); } s.eventScrollGetListPosition(true); }, s.params.blurDuration); } setSelectionState(optionBtn, doBlur = true) { const s = this; const { cssPrefix } = s.params; const selectedOpt = s.optionSet.get(optionBtn); if (!selectedOpt) return; const newSelectedState = !selectedOpt.selected; s.params.beforeChange(s.$select); selectedOpt.selected = true; s.params.afterChange(s.$select); s.$selectList.find('.' + cssPrefix + '__list-btn[aria-selected]').attr({ 'aria-selected': 'false' }); $(optionBtn).attr({ 'aria-selected': newSelectedState + '' }); s.$textInput.attr({ 'aria-activedescendant': optionBtn.id }); // add a class whether there is an input value or not s.$selectEnhance.toggleClass(cssPrefix + '--empty-val', !selectedOpt.value.trim()); s.select.dispatchEvent(new Event('change')); if (s.params.emptyValAsPlaceholder && selectedOpt.value.trim() === '') { s.$textInput.val('').attr({ placeholder: selectedOpt.text }); } else { s.$textInput.val(selectedOpt.text); } if (doBlur) { s.closeOptions(); } else { optionBtn.focus(); } } // Events eventLabelClick() { const s = this; s.$label.on('click.' + EVENT_NAME, function (e) { e.preventDefault(); if (!s.select.disabled) { s.textInput.focus(); } }); } eventResizeClose() { const s = this; $(window).on('resize.' + EVENT_NAME, () => { if (s.optionsShown) s.closeOptions(); }); } eventShowOptions() { const s = this; const { cssPrefix } = s.params; s.$selectEnhance.on('click.' + EVENT_NAME, '.' + cssPrefix + '__enable-text', (e) => { if (s.select.disabled) { return; } s.showOptions(); }); // Only works on keydown event s.$textInput.on('keydown.' + EVENT_NAME, function (e) { if (e.key === KEYS.down || e.key === KEYS.up) { s.showOptions(); e.preventDefault(); } }); s.$textInput.on('keypress.' + EVENT_NAME, function (e) { if (e.key !== KEYS.tab || e.shiftKey && e.key !== KEYS.tab) { e.preventDefault(); } if (e.key === KEYS.enter || //enter key e.code === KEYS.space && s.keyedInput.trim() === '' || // space key e.ctrlKey && e.altKey && e.shiftKey && KEYS.space === e.code) { s.showOptions(); console.log('whha'); } }); } eventOptionClick() { const s = this; const { cssPrefix } = s.params; const newSelectedOption = (elem) => { s.setSelectionState(document.activeElement); s.$selectEnhance.removeClass(cssPrefix + '--focused'); setTimeout(() => s.closeOptions(), 200); }; // mouse click s.$selectList.on('click.' + EVENT_NAME, '.' + cssPrefix + '__list-btn', function (e) { newSelectedOption(this); e.preventDefault(); }); // Kyeboard click s.$selectList.on('keypress.' + EVENT_NAME, '.' + cssPrefix + '__list-btn', function (e) { if (e.key === KEYS.enter || e.code === KEYS.space && s.keyedInput.trim() === '') { newSelectedOption(document.activeElement); e.preventDefault(); } }); } eventOutsideClickClose() { const s = this; setTimeout(() => { $(document.body).off(s.bodyCloseEvt).on(s.bodyCloseEvt, function (e) { const ae = document.activeElement; if (ae && !s.$selectList.has(ae).length) { s.closeOptions(); } }); }, 100); } eventKeyboardSearch() { const s = this; let keyInputTo = null; let changedTo = null; let keyedFound; function keyboardSearch(e) { if (s.select.disabled) { return; } const keyCurr = (e.key.length === 1 ? e.key : ''); s.keyedInput += keyCurr; clearTimeout(keyInputTo); keyInputTo = setTimeout(() => { s.keyedInput = ""; }, s.params.typeAheadDuration); if (s.keyedInput.trim()) { const rgx = RegExp(s.keyedInput.trim(), 'i'); keyedFound = Array.from(s.select.options).find((el) => rgx.test(el.text)); if (keyedFound) { clearTimeout(changedTo); changedTo = setTimeout(() => { s.setSelectionState(s.optionSet.get(keyedFound), false); }, 100); } } } s.$selectEnhance.on('keypress.' + EVENT_NAME, keyboardSearch); s.$selectList.on('keypress.' + EVENT_NAME, keyboardSearch); } eventArrowKeys() { const s = this; s.$selectEnhance.on('keydown.navigate_' + EVENT_NAME, function (e) { if (s.select.disabled || !s.optionsShown) { return; } if (e.key === KEYS.down) { if (!s.textInput.isSameNode(document.activeElement)) { e.preventDefault(); } s.nextOptionButton('next'); } if (e.key === KEYS.esc) { s.closeOptions(); } if (e.key === KEYS.tab) { s.closeOptions(false); } }); s.$selectList.on('keydown.navigate_' + EVENT_NAME, function (e) { if (s.select.disabled || !s.optionsShown) { return; } if (e.key === KEYS.down) { if (!s.textInput.isSameNode(document.activeElement)) { e.preventDefault(); } s.nextOptionButton('next'); } if (e.key === KEYS.up) { e.preventDefault(); s.nextOptionButton('prev'); } if (e.key === KEYS.esc) { s.closeOptions(); } }); } // build the HTML for it setUpSelectHtml() { const s = this; const $selectEnhance = $('<div />').attr({ class: s.params.cssPrefix, id: s.selectId + '_wrap' }); const $textInput = $('<input>').attr({ type: 'text', class: s.params.cssPrefix + '__enable-text', role: "combobox", 'aria-controls': s.selectId + '_listbox', 'aria-labelledby': s.selectId + '_lbl', 'aria-autocomplete': 'list', 'aria-expanded': 'false', id: s.selectId + '_input' }); s.$select.wrap($selectEnhance); // jQuery, elements need to be bound to the DOM before they // can have events attached to them. So this is the solution s.$selectEnhance = s.$select.parent(); s.selectEnhance = s.$selectEnhance[0]; if (mobileOS && s.params.mobileNative || s.isReadOnly) { // exit if its a mobile device after wrapping for styling return; } $textInput.insertAfter(s.$select); s.$select.attr({ tabindex: '-1', 'aria-hidden': 'true' }); // jQuery, elements need to be bound to the DOM before they // can have events attached to them. So this is the solution s.$textInput = s.$select.parent().find('#' + s.selectId + '_input'); s.textInput = s.$textInput[0]; SelectEnhance.buildOptionsList(s); } static buildOptionsList(s, $selectList) { const { cssListModifer, cssPrefix } = s.params; const optGroup = s.select.getElementsByTagName('optgroup'); const hasOptGroup = !!optGroup.length; const $optGroupWm = new WeakMap(); const modifierCss = " " + (cssListModifer ? cssPrefix + '__list--' + cssListModifer : ''); if (hasOptGroup) { for (let i = 0, l = optGroup.length; i < l; i++) { const group = optGroup[i]; $optGroupWm.set(group, $('<div/>').attr({ class: cssPrefix + '__optgroup', role: 'group', label: group.label || "" }).append($('<span>').attr({ class: cssPrefix + '__optgroup-label', 'aria-hidden': 'true' }).text(group.label || ""))); } } const options = s.select.options; s.$selectList = $selectList ? $selectList : $('<div>').attr({ class: cssPrefix + '__list' + modifierCss, role: 'listbox', id: s.selectId + '_listbox', 'aria-label': s.$label.text() || '' }); s.selectList = s.$selectList[0]; const optId = s.selectId || 'select_' + s.index; for (let i = 0, l = options.length; i < l; i++) { const option = options[i]; const id = optId + i; if (option.hidden) continue; const valCssStatus = option.value === '' ? ' ' + cssPrefix + '__list-btn--empty' : ''; const attrs = { // type: 'button', tabIndex: '0', role: 'option', id, 'data-value': option.value, 'aria-selected': option.selected + '', disabled: option.disabled ? 'disabled' : null, class: cssPrefix + '__list-btn' + valCssStatus }; const optionBtn = $('<div/>').attr(attrs).text(option.textContent)[0]; s.optionSet.set(optionBtn, option); s.optionSet.set(option, optionBtn); // append to list or optgroup hasOptGroup && option.parentElement.nodeName.toUpperCase() === 'OPTGROUP' ? $optGroupWm.get(option.parentElement).append(optionBtn) : s.$selectList.append(optionBtn); if (option.selected) { s.$textInput.attr({ 'aria-activedescendant': id }); s.$selectEnhance.toggleClass(cssPrefix + '--empty-val', !option.value.trim()); if (s.params.emptyValAsPlaceholder && option.value.trim() === '') { s.$textInput.val(''); s.$textInput.attr({ placeholder: option.text }); } else { s.$textInput.val(option.text); } } } if (hasOptGroup) { for (let i = 0, l = optGroup.length; i < l; i++) { const group = optGroup[i]; s.$selectList.append($optGroupWm.get(group)); } } } nextOptionButton(dir) { const s = this; const ae = document.activeElement; const $btnList = s.$selectList.find('.' + s.params.cssPrefix + '__list-btn'); const btnLength = $btnList.length; if (btnLength && s.textInput.isSameNode(ae)) { $btnList.eq(dir === 'next' ? 0 : btnLength - 1)[0].focus(); return; } for (let i = 0; i < btnLength; i++) { const el = $btnList[i]; let prevNextIndex = 0; if (ae.isSameNode(el)) { if (dir === 'next') { const isLast = i === btnLength - 1; prevNextIndex = isLast ? i : i + 1; } else { const isFirst = i === 0; prevNextIndex = isFirst ? i : i - 1; } $btnList.eq(prevNextIndex)[0].focus(); break; } } } selectInputMutationObserver() { const s = this; const selectNode = s.select; const config = { attributes: true, childList: true, subtree: true }; // Callback function to execute when mutations are observed const callback = function (mutationsList, observer) { // Use traditional 'for loops' for IE 11 for (let i = 0, l = mutationsList.length; i < l; i++) { const mutation = mutationsList[i]; if (mutation.type === 'childList') { SelectEnhance.refreshOptions(s.select); } else if (mutation.type === 'attributes') { s.$selectEnhance.toggleClass(s.params.cssPrefix + '--disabled', s.select.disabled); } } }; // Create an observer instance linked to the callback function s.selectboxObserver = new MutationObserver(callback); // Start observing the target node for configured mutations s.selectboxObserver.observe(selectNode, config); } eventScrollGetListPosition(init = true) { const s = this; const evt = 'scroll.' + EVENT_NAME; if (init) { $(window).off(evt).on(evt, () => { s.posTimeout && clearTimeout(s.posTimeout); s.posTimeout = setTimeout(() => s.getListPosition(), 100); }); } else { $(window).off(evt); } } observeSelectListBoxInFullView() { const s = this; if (window.IntersectionObserver) { const selectIntersectionObserver = new IntersectionObserver(function (selects) { s.selectListBoxInFullView = selects[0].intersectionRatio === 1; }, { threshold: [0, 1] }); s.$selectList[0] && selectIntersectionObserver.observe(s.$selectList[0]); } } getListPosition() { const s = this; if (s.optionsShown) { const { cssPrefix } = s.params, selWrapOffset = s.$selectEnhance.offset(), selWrapHeight = s.selectEnhance.scrollHeight, selWrapPosTop = selWrapOffset.top, selWrapPosBot = selWrapPosTop + selWrapHeight, selWrapWidth = s.selectEnhance.offsetWidth, selListHeight = s.selectList.offsetHeight, { innerHeight, scrollY } = window, scrollYBot = scrollY + innerHeight, isSpaceAbove = selWrapPosTop - scrollY > selListHeight, isSpaceBelow = scrollYBot - selWrapPosBot > selListHeight, // If space below use that, else if no space at all prefer space below canPlaceBelow = isSpaceBelow ? true : isSpaceAbove ? false : true; s.$selectEnhance.toggleClass(cssPrefix + '--pos-above', !canPlaceBelow); s.$selectList .css({ left: selWrapOffset.left, top: canPlaceBelow ? selWrapPosBot : selWrapPosTop - selListHeight, width: selWrapWidth }) .toggleClass(cssPrefix + '__list--focused', s.optionsShown) .toggleClass(cssPrefix + '__list--pos-above', !canPlaceBelow); } } static refreshOptions(element) { $(element).each(function () { const s = Store(this, DATA_NAME); if (s) { s.$selectList.empty(); s.optionSet = new WeakMap(); SelectEnhance.buildOptionsList(s, s.$selectList); } else { console.warn(`No instance of a selectbox`, element); } }); } static remove(element, plugin) { $(element).each(function () { const s = plugin || Store(this, DATA_NAME); s.$selectEnhance .off('keydown.' + EVENT_NAME) .off('keydown.navigate_' + EVENT_NAME) .off('click.' + EVENT_NAME) .off('focusout.' + EVENT_NAME) .off('blur.' + EVENT_NAME); s.$label.off('click.' + EVENT_NAME); $(document.body).off(s.bodyCloseEvt); $(window).off('resize.' + EVENT_NAME); // the window event will just stay s.$select.insertAfter(s.$selectEnhance); s.$select .attr({ tabindex: null, 'aria-hidden': null }) .off('mouseup.' + EVENT_NAME) .off('change.' + EVENT_NAME); if (s.selectboxObserver) { s.selectboxObserver.disconnect(); } s.$selectEnhance.remove(); Store(this, DATA_NAME, null); }); } }