baseframe-js
Version:
Baseframe JS is a comprehensive suite of modular plugins and utilities designed for front-end development
566 lines (565 loc) • 22 kB
JavaScript
import $be from "base-elem-js";
import Store from "./core/Store";
import { KEYS } from "./core/constants";
import { d, isMobileOS, getDataOptions, noop, setParams } from "./util/helpers";
const VERSION = "3.0.1";
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,
listAppendToSelect: false
};
const { make, findBy } = $be;
// wrap the select first
const mobileOS = isMobileOS();
// helper
// const getSelectedOptNode = ($el) => $el.find('option').filter(elem => elem.selected).elem[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;
toggling;
static defaults = DEFAULTS;
static version = VERSION;
static pluginName = DATA_NAME;
constructor(element, options, index) {
const s = this;
const dataOptions = getDataOptions(element, EVENT_NAME);
s.index = index;
s.$select = $be(element);
s.select = element;
s.id = s.$select.attr('id') || s.$select.attr('name') || 'select_enhance_' + index;
s.selectId = s.id + '_enhance';
s.$label = $be(`label[for="${s.id}"]`);
s.optionSet = new WeakMap();
s.toggling = false;
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.hasEls) {
s.$label.attr({ id: s.selectId + '_lbl' });
}
s.params = setParams(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.#inFullView();
}
return s;
}
#mobileOnlyIfNavite() {
const s = this;
let prevElSelectedVal = s.#getSelectedOptNode(s.$select);
s.$select.on(`mouseup.${EVENT_NAME}`, (ev, elem) => {
if (!elem === prevElSelectedVal) {
s.params.beforeChange(s.select);
}
}, 'option')
.on(`change.${EVENT_NAME}`, (ev, elem) => {
s.params.afterChange(s.select);
prevElSelectedVal = s.#getSelectedOptNode(s.$select);
});
}
#getSelectedOptNode($el) {
return $el.find('option').filter(elem => elem.selected).elem[0];
}
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"]');
s.$selectList.css({ display: null });
if (s.params.listAppendToSelect) {
s.$selectEnhance.insert(s.$selectList);
}
else {
d.body.append(s.selectList);
}
s.$selectEnhance.tgClass(cssPrefix + '--focused');
s.$textInput.attr({ 'aria-expanded': 'true' });
s.listPosition();
s.eventScrollGetListPosition(true);
s.eventOutsideClickClose();
if ($selectedBtn.hasEls) {
$selectedBtn.elem[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')
.rmClass(cssPrefix + '--focused');
s.$textInput.attr({ 'aria-expanded': 'false' });
s.$selectList
.addClass(cssPrefix + '__list--blurring')
.rmClass(cssPrefix + '__list--focused');
// remove the close event on the body
$be(d.body).off(s.bodyCloseEvt);
setTimeout(() => {
s.$selectEnhance
.rmClass([`${cssPrefix}--blurring`, `${cssPrefix}--pos-above`])
.insert(s.$selectList);
s.$selectList
.css({ display: 'none' })
.rmClass([
`${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;
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' });
$be(optionBtn).attr({ 'aria-selected': 'true' });
s.$textInput.attr({ 'aria-activedescendant': optionBtn.id });
// add a class whether there is an input value or not
s.$selectEnhance.tgClass(cssPrefix + '--empty-val', !selectedOpt.value.trim());
s.select.dispatchEvent(new Event('change'));
if (s.params.emptyValAsPlaceholder && selectedOpt.value.trim() === '') {
s.$textInput.attr({ value: '', placeholder: selectedOpt.text });
}
else {
s.$textInput.attr({ value: selectedOpt.text });
}
if (doBlur)
s.closeOptions();
else
optionBtn.focus();
}
// region Events
#eventLabelClick() {
const s = this;
s.$label.on(`click.${EVENT_NAME}`, (ev) => {
ev.preventDefault();
if (!s.select.disabled)
s.textInput.focus();
});
}
#eventResizeClose() {
const s = this;
$be(window).on(`resize.${EVENT_NAME}`, () => {
if (s.optionsShown)
s.closeOptions();
});
}
#eventShowOptions() {
const s = this;
s.$textInput.on(`click.${EVENT_NAME}`, () => {
if (s.select.disabled)
return;
s.showOptions();
})
.on(`keydown.${EVENT_NAME}`, (ev) => {
// Only works on keydown event
if (ev.key === KEYS.down || ev.key === KEYS.up) {
s.showOptions();
ev.preventDefault();
}
}).on(`keypress.${EVENT_NAME}`, (ev) => {
if (ev.key !== KEYS.tab ||
ev.shiftKey &&
ev.key !== KEYS.tab) {
ev.preventDefault();
}
if (ev.code === KEYS.space && s.keyedInput.trim() === '' || // space key
ev.ctrlKey && ev.altKey && ev.shiftKey && KEYS.space === ev.code) {
s.showOptions();
}
});
}
#eventOptionClick() {
const s = this;
const { cssPrefix } = s.params;
let toggling = null;
const selectedOption = (elem) => {
// clear the close event if we are toggling twice quickly
window.clearTimeout(toggling);
s.setSelectionState(elem);
toggling = window.setTimeout(() => s.closeOptions(), 200);
};
// mouse click
s.$selectList.on(`click.${EVENT_NAME}`, (ev, elem) => {
selectedOption(elem);
ev.preventDefault();
}, `.${cssPrefix}__list-btn`)
// keyboard click
.on(`keypress.${EVENT_NAME}`, (ev, elem) => {
if (ev.key === KEYS.enter ||
ev.code === KEYS.space && s.keyedInput.trim() === '') {
selectedOption(elem);
ev.preventDefault();
}
}, `.${cssPrefix}__list-btn`);
}
eventOutsideClickClose() {
const s = this;
setTimeout(() => {
const bodyEvt = s.bodyCloseEvt;
$be(d.body).off(bodyEvt).on(bodyEvt, (ev, elem) => {
const activeElem = d.activeElement;
if (activeElem && !s.selectList.contains(activeElem)) {
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}`, (ev, elem) => {
if (s.select.disabled || !s.optionsShown) {
return;
}
if (ev.key === KEYS.down) {
if (!s.textInput.isSameNode(d.activeElement)) {
ev.preventDefault();
}
s.traverseOptions(elem, 'next');
}
if (ev.key === KEYS.esc) {
s.closeOptions();
}
if (ev.key === KEYS.tab) {
s.closeOptions(false);
}
});
s.$selectList.on(`keydown.navigate_${EVENT_NAME}`, (ev, elem) => {
if (s.select.disabled || !s.optionsShown) {
return;
}
const activeElem = d.activeElement;
if (ev.key === KEYS.down) {
if (!s.textInput.isSameNode(activeElem)) {
ev.preventDefault();
}
s.traverseOptions(activeElem, 'next');
}
if (ev.key === KEYS.up) {
ev.preventDefault();
s.traverseOptions(activeElem, 'prev');
}
if (ev.key === KEYS.esc) {
s.closeOptions();
}
});
}
// region Build Options
#setUpSelectHtml() {
const s = this, selectEnhance = make(`div.${s.params.cssPrefix}#${s.selectId}_wrap`), textInput = make(`input.${s.params.cssPrefix}__enable-text`, {
type: 'text',
role: "combobox",
ariaExpanded: 'false',
ariaLabel: s.$label.text() || '',
id: s.selectId + '_input'
}), $selectEnhance = $be(selectEnhance), $textInput = $be(textInput);
// TS throws error
const otherAttrs = [
['aria-controls', s.selectId + '_listbox'],
['aria-owns', s.selectId + '_listbox'],
['aria-labelledby', s.selectId + '_lbl'],
['aria-autocomplete', 'list'],
['aria-activedescendant', '']
];
for (const [attr, val] of otherAttrs) {
textInput.setAttribute(attr, val);
}
s.$textInput = $textInput;
s.textInput = textInput;
s.$selectEnhance = $selectEnhance;
s.selectEnhance = selectEnhance;
s.$select
.attr({ tabindex: '-1' })
.insert($selectEnhance, 'after');
$selectEnhance.insert(s.$select);
if (!(mobileOS && s.params.mobileNative || s.isReadOnly)) {
s.$select.insert($textInput, 'after');
SelectEnhance.buildOptionsList(s);
}
}
static buildOptionsList(s, $selectList) {
const { cssListModifer, cssPrefix } = s.params, optGroup = findBy('tag', 'optgroup', s.select), options = s.select.options, hasOptGroup = !!optGroup.length, $optGroupWm = new WeakMap(), modifierCss = " " + (cssListModifer ? cssPrefix + '__list--' + cssListModifer : '');
if (hasOptGroup) {
for (let i = 0, l = optGroup.length; i < l; i++) {
const group = optGroup[i];
const optGroupElem = make('div', {
className: cssPrefix + '__optgroup',
role: 'group',
ariaLabel: group.label || "",
textContent: group.label || ""
});
$optGroupWm.set(group, $be(optGroupElem));
}
}
s.$selectList = $selectList ? $selectList : $be(make('div', {
className: cssPrefix + '__list' + modifierCss,
role: 'listbox',
id: s.selectId + '_listbox',
ariaLabel: s.$label.text() || ''
})).css({ display: 'none' });
s.selectList = s.$selectList.elem[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 isInOptGrup = option.parentElement.nodeName.toUpperCase() === 'OPTGROUP';
const valCssStatus = option.value === '' ? ' ' + cssPrefix + '__list-btn--empty' : '';
const optionBtn = make(`div.${cssPrefix}__list-btn${valCssStatus}`, {
tabIndex: 0,
role: 'option', id,
ariaSelected: option.selected + '',
// disabled: option.disabled ? 'disabled' : null,
textContent: option.textContent
});
optionBtn.dataset.disabled = option.disabled ? 'disabled' : null,
optionBtn.dataset.value = option.value;
s.optionSet.set(optionBtn, option);
s.optionSet.set(option, optionBtn);
// append to list or optgroup
if (hasOptGroup && isInOptGrup)
$optGroupWm.get(option.parentElement).insert(optionBtn);
else
s.$selectList.insert(optionBtn);
if (option.selected) {
s.$textInput.attr({ 'aria-activedescendant': id });
s.$selectEnhance.tgClass(cssPrefix + '--empty-val', !option.value.trim());
if (s.params.emptyValAsPlaceholder && option.value.trim() === '') {
s.$textInput.attr({ value: '' });
s.$textInput.attr({ placeholder: option.text });
}
else
s.$textInput.attr({ value: option.text });
}
}
if (hasOptGroup) {
for (let i = 0, l = optGroup.length; i < l; i++) {
const group = optGroup[i];
s.$selectList.insert($optGroupWm.get(group));
}
}
// append to select enhance wrapper for correct relative positioning,
// will be moved to body on show if that is the option
s.$selectEnhance.insert(s.$selectList);
}
traverseOptions(elem, dir) {
const parentElem = elem.parentElement;
const nextOption = (dir === 'next' ?
elem.nextElementSibling :
elem.previousElementSibling);
if (nextOption)
nextOption.focus();
if (!nextOption && parentElem.role === 'group') {
const nextOptGroup = dir === 'next' ?
parentElem.nextElementSibling :
parentElem.previousElementSibling;
if (nextOptGroup) {
if (dir === 'next') {
nextOptGroup.firstElementChild?.focus();
}
else
nextOptGroup.lastElementChild?.focus();
}
}
}
// region Mutation Observer
selectInputMutationObserver() {
const s = this;
const selectNode = s.select;
const config = { attributes: true, childList: true, subtree: true };
// Create an observer instance linked to the callback function
s.selectboxObserver = new MutationObserver(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.tgClass(s.params.cssPrefix + '--disabled', s.select.disabled);
}
}
});
// Start observing the target node for configured mutations
s.selectboxObserver.observe(selectNode, config);
}
// region List Position
eventScrollGetListPosition(init = true) {
const s = this;
const evt = `scroll.${EVENT_NAME}`;
if (init) {
$be(window).off(evt).on(evt, () => {
s.posTimeout && clearTimeout(s.posTimeout);
s.posTimeout = setTimeout(() => s.listPosition(), 100);
});
}
else {
$be(window).off(evt);
}
}
#inFullView() {
const s = this;
if (window.IntersectionObserver) {
const selectIntersectionObserver = new IntersectionObserver(function (selects) {
s.selectListBoxInFullView = selects[0].intersectionRatio === 1;
}, { threshold: [0, 1] });
s.selectList && selectIntersectionObserver.observe(s.selectList);
}
}
listPosition() {
const s = this;
if (s.optionsShown) {
const selWrapRects = s.$selectEnhance.elemRects(), { cssPrefix, listAppendToSelect } = s.params, { innerHeight, scrollY, scrollX } = window, selWrapTop = selWrapRects.top + scrollY, selWrapPosBot = selWrapTop + selWrapRects.height, selWrapWidth = s.selectEnhance.offsetWidth, selListHeight = s.selectList.offsetHeight, scrollYBot = scrollY + innerHeight, isSpaceAbove = selWrapTop - 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, left = listAppendToSelect ? 0 : (selWrapRects.left + scrollX), top = listAppendToSelect ?
(canPlaceBelow ? selWrapRects.height : selListHeight * -1) :
(canPlaceBelow ? selWrapPosBot : selWrapTop - selListHeight);
s.$selectEnhance.tgClass(cssPrefix + '--pos-above', !canPlaceBelow);
s.$selectList
.css({
left: left + 'px',
top: top + 'px',
width: selWrapWidth + 'px'
})
.tgClass(cssPrefix + '__list--focused', s.optionsShown)
.tgClass(cssPrefix + '__list--pos-above', !canPlaceBelow);
}
}
// region Static Methods
static refreshOptions(element) {
$be(element).each((elem) => {
const s = Store(elem, 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) {
$be(element).each((elem) => {
const s = plugin || Store(elem, DATA_NAME);
s.$selectEnhance.off([
`keydown.${EVENT_NAME}`,
`keydown.navigate_${EVENT_NAME}`,
`click.${EVENT_NAME}`,
`focusout.${EVENT_NAME}`,
`blur.${EVENT_NAME}`
]);
s.$label.off(`click.${EVENT_NAME}`);
$be(d.body).off(s.bodyCloseEvt);
$be(window).off(`resize.${EVENT_NAME}`);
// the window event will just stay
s.$selectEnhance
.insert(s.$select, 'after')
.attr({ tabindex: null })
.off([`mouseup.${EVENT_NAME}`, `change.${EVENT_NAME}`]);
if (s.selectboxObserver)
s.selectboxObserver.disconnect();
s.$selectEnhance.remove();
Store(elem, DATA_NAME, null);
});
}
}