/** * @classdesc The main EICUI libray. Available as a global static object ui * @author Michael Fallise * @version 2.1 * @hideconstructor * @category EICUI/Core */ class ui { /** @ignore */ static initiated = false; static _components = {}; static mutations = null; /** * Default options * @property {boolean} ariaEnabled Aria support */ static options = { ariaEnabled: true } /** * Format helper functions * @type FormatHelper */ static format = null; /** * Growler helper * @type Growler */ static growl = null; /** * Sets up the required layer elements used by the library * @private * @param options options to use when EICUI is instanciated * @param options.ariaEnabled Wheter components should automatically generate Aria attributes */ static init(options) { if(!ui.initiated) { this.layers = {}; this.growl = new Growler(this.create('
')); this.layers['growler'] = this.growl.el; document.body.append(this.growl.el); this.layers['blocker'] = this.create('
'); document.body.appendChild(this.layers['blocker']); document.body.setAttribute('eicapp', ''); this.mutations = new MutationObserver(ui.onDOMMutation) //no bind for static this.mutations.observe(document.body, { childList: true, subtree: true }) ui.initiated = true; } ui.format = new FormatHelper(); //backward compatibility. please consider using ui.format instead of ui.dates ui.dates = ui.format } /** * Locks the window prohibiting any user interaction */ static lock() { this.layers['blocker'].classList.add('active'); } /** * Unlocks the window */ static unlock() { this.layers['blocker'].classList.remove('active'); } /** * Automatically scans a DOM structure and initiates known components * * @param {Element} container a DOM node structure * @returns {array} */ static eicfy(container) { let elements = []; let badges = container.querySelectorAll('[eicbadge]'); for(let i = 0; i < badges.length; i++) { elements.push(new Badge(badges[i])); } let chips = container.querySelectorAll('[eicchip]'); for(let i = 0; i < chips.length; i++) { elements.push(new Chip(chips[i])); } let buttons = container.querySelectorAll('[eicbutton]'); for(let i = 0; i < buttons.length; i++) { elements.push(new Button(buttons[i])); } let inputs = container.querySelectorAll('[eicinput],[eiccheckbox]'); for(let i = 0; i < inputs.length; i++) { let type = inputs[i].getAttribute('type'); switch(type) { case 'checkbox': elements.push(new Checkbox(inputs[i])); break; case 'hidden': elements.push(new InputHidden(inputs[i])); break; case 'search': elements.push(new InputSearch(inputs[i])); break; case 'date': elements.push(new InputDate(inputs[i])); break; case 'number': elements.push(new InputNumber(inputs[i])); break; case 'currency': elements.push(new InputCurrency(inputs[i])); break; case 'toggler': elements.push(new InputToggler(inputs[i])); break; default: elements.push(new Input(inputs[i])); } } let textareas = container.querySelectorAll('[eictextarea]'); for(let i = 0; i < textareas.length; i++) { elements.push(new Textarea(textareas[i])); } let selects = container.querySelectorAll('[eicselect]'); for(let i = 0; i < selects.length; i++) { elements.push(new Select(selects[i])); } let cards = container.querySelectorAll('article[eiccard]'); for(let i = 0; i < cards.length; i++) { elements.push(new Card(cards[i])); } let dropdowns = container.querySelectorAll('[eicdropdown]'); for(let i = 0; i < dropdowns.length; i++) { elements.push(new DropDown(dropdowns[i])); } let alerts = container.querySelectorAll('[eicalert][closable]'); for(let i = 0; i < alerts.length; i++) { elements.push(new Alert(alerts[i])); } return elements; } /** * Converts a markup string into corresponding DOM structure * @param {string} markup * @returns {DOMElement} The first root element (and its childs if any) generated by the string. */ static create(markup, options) { markup = markup.trim(); let el = null; if(markup.indexOf('<') != 0) { el = document.createElement(markup); for(let prop in options) { switch(prop) { case 'html': case 'text': el.innerHTML = options[prop]; break; case 'class': el.className = options[prop]; break; default: if(el.hasOwnProperty(prop)) { el[prop] = options[prop]; } else { el.setAttribute(prop, options[prop]); } } } } else { let doc = el = document.createElement('div'); doc.innerHTML = markup; el = doc.firstElementChild; } return el; } /** * * @param {string} uid * @returns {EicComponent|null} */ static getComponent(uid) { return ui._components.hasOwnProperty(uid) ? ui._components[uid] : null; } /** * Adds a component to the registered list * @param {EicComponent} component * @returns {string} Component's new uid */ static registerComponent(component) { let uid = crypto.randomUUID(); ui._components[uid] = component; return uid; } /** * Removes a component from registered list * @param {string} uid */ static unregisterComponent(uid) { if(ui._components.hasOwnProperty(uid)) delete(ui._components[uid]); } /** * Handler for DOM MutationObserver. * Scans removed nodes and unregisters embedded EICUI components * @param {*} mutationList */ static onDOMMutation(mutationList) { for(let mutation of mutationList) { for(let i = 0; i < mutation.removedNodes.length; i++) { let node = mutation.removedNodes[i] // skip text nodes if(node.nodeName == '#text') break; if(node.hasAttribute('data-eicui-id')) ui.unregisterComponent(node.getAttribute('data-eicui-id')); let components = node.querySelectorAll('[data-eicui-id]'); for(let component of components) ui.unregisterComponent(component.getAttribute('data-eicui-id')); } } } /** * Hides an Element * @param {Element} element */ static hide(element) { element.style.display = 'none'; } /** * Shows (unhides) an Element * @param {Element} element * @param {boolean} [display] Forces the display property value. Default is 'block'. */ static show(element, mode) { element.style.display = mode ? mode: 'block'; } /** * Hides an Element in the DOM with a fade out effect. * @param {Element} element * @param {boolean} [detach] If true, removes the element from the DOM once fade animation is completed */ static fadeOut(el, detach) { let intId = setInterval(function () { if(!el.style.opacity) { el.style.opacity = 1; } if(el.style.opacity > 0) { el.style.opacity -= 0.1; } else { ui.hide(el); el.style.opacity = 1; clearInterval(intId); if(detach) { el.remove(); } } }, 20); } /** * Returns an element's nearest parent matching selector rule * @param {Element} element The source element * @param {string} selector The selector rule matching the targeted parent * @returns {Element|null} The first (bottom/up) matching parent if any */ static queryParent(child, selector) { if (selector === undefined) { selector = document; } var parents = []; var p = child.parentNode; while (p !== parentSelector) { var o = p; parents.push(o); p = o.parentNode; } parents.push(selector); return parents[parents.length-1]; } /** * Returns the actual index of an Element within its parent * @param {Element} element The target Element * @returns {number} The element child index */ static childIndex(el) { let childs = el.parentNode.childNodes; let index = 0; for(let i = 0; i < childs.length; i++) { if (childs[i] == el) return index; if (childs[i].nodeType == 1) index++; } return -1; } /** * Copies the styling properties of an Element to another * @param {Element} source The source Element * @param {Element} target The target Element */ static copyEicuiStyling(fromEl, toEl) { let m for(let eicuiAttr of ['[danger]', '[primary]', '[secondary]', '[success]', '[warning]', '[accent]', '[info]', '[disabled]', '[xxsmall]','[xsmall]','[small]','[medium]','[large]','[xlarge]','xxl[arge','[xxxlarge]','[xxxxlarge]', '.required'] ) { m = eicuiAttr.match(/\[(\w+)\]/) if(m && fromEl.hasAttribute(m[1])) toEl.setAttribute(m[1],fromEl.getAttribute(m[1])) m = eicuiAttr.match(/\.(\w+)/) if(m && fromEl.classList.contains(m[1])) toEl.classList.add(m[1]) } } } /** * Special types formatting helper class * @hideconstructor * @category EICUI/Helpers */ class FormatHelper { defaultCurrency = '€'; /** * Formats a number * @param {string|number} str The number to be formatted * @returns {string} The formatted number */ number(str) { str += ''; let s = parseFloat(0 + str).toString(); let x = s.split('.'); let x1 = x[0]; let x2 = (x.length > 1) ? '.' + x[1]: ''; let r = /(\d+)(\d{3})/; while (r.test(x1)) { x1 = x1.replace(r, '$1' + ' ' + '$2'); } return x1 + x2; } /** * Formats a currency. Uses the number() format method. * @param {string|number} str The amount to be formatted * @param {string} [currency] The currency symbol to apply. Default is '€'. * @returns {string} The formatted amount */ currency(str, currency) { return this.number(str) + ' ' + (currency ? currency: this.defaultCurrency) ; } /** * Formats a date string * @param {string} str The date string to format * @returns {string} The formatted date */ date(str) { let d = new Date(str); return d.toLocaleDateString('en-DE', { year: 'numeric', month: 'short', day: 'numeric' }); } /** * Formats a date and time string * @param {string} str The date and time string to format * @returns {string} The formatted date */ dateTime(str) { let d = new Date(str); return d.toLocaleDateString('en-DE', { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString('en-DE'); } /** * Formats a time string * @param {string} str The time string to format * @returns {string} The formatted time */ time(str) { let d = new Date(str); return d.toLocaleTimeString('en-DE'); } url(str) { if(str && !/^(?:f|ht)tps?\:\/\//.test(str)) { str = "https://" + str; } return str; } // backward compatibility currency2String = this.currency date2String = this.date dateTime2String = this.dateTime time2String = this.time } /** * @classdesc Component base (ancestor) class * @category EICUI/Core * */ class EicComponent { /** * @type {object} * @property {boolean} aria-enabled Aligns to global ui setting by default. see {@link ui#options} */ options = { 'aria-enabled': false }; _el = null; /** * @param {DOMElement|null} [element] a dom element attached to the component * @param {Object} [options] see [options]{@link EicComponent#options} */ constructor(el, options) { if(el && (el.getAttribute('data-eicui-id') != null) && (Object.keys(ui._components).includes(el.getAttribute('data-eicui-id')))) { return(ui._components[el.getAttribute('data-eicui-id')]) } this._uid = ui.registerComponent(this); this.el = el; this.setOptions(options || {}); this.attributes({ 'aria-enabled': ui.options.ariaEnabled }); } get el() { return this._el; } set el(markup) { if(typeof markup == 'string') { const placeholder = document.createElement("div"); placeholder.innerHTML = markup; const node = placeholder.firstElementChild ; this._el = node; } else { this._el = markup; } if(this._el) { this._el.setAttribute('data-eicui-id', this._uid); } } /** * Merges passed options to component's options */ setOptions(options) { this.mergeOptionsNode(this.options, options); } /** * @ignore */ mergeOptionsNode(target, source) { for(let option in source) { if(typeof source[option] === 'object' && !Array.isArray(source[option]) && (option in target) && !(target[option] instanceof(Element))) { this.mergeOptionsNode(target[option], source[option]); } else { target[option] = source[option]; } } } /** @type {boolean} */ set disabled(state) { state ? this.el.setAttribute('disabled',''): this.el.removeAttribute('disabled'); } get disabled() { return this.el.hasAttribute('disabled'); } /** * Enables a component. same as obj.disabled = false */ enable() { this.disabled = false; } /** * @typedef {string} UISize * Semantic UI size values. based on EUI semantic. * * Available values: *
	 * 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'xxxlarge' | 'xxxxlarge'
	 * 
*/ /** * The component size attribute. See EUI semantic size values. * @type {UISize} */ set size(value) { if(!this.el) return; let sizes = [ 'xxsmall','xsmall','small','medium','large','xlarge','xxlarge','xxxlarge','xxxxlarge' ]; sizes.forEach(val => this.el.removeAttribute(val)); this.el.setAttribute(value, ''); } get size() { if(!this.el) return(''); for(let size of [ 'xxsmall','xsmall','small','medium','large','xlarge','xxlarge','xxxlarge','xxxxlarge' ]) { if(this.el.hasAttribute(size)) return(size) } return('') } /** * @typedef {string} UISeverity * UI severity color code. Based EUI severity semantic. Available values: *
	 * 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'accent' | 'info'
	 * 
*/ /** * The severity attribute for the component * @type {UISeverity} */ set severity(value) { if(!this.el) return; let values = [ 'primary','secondary','success','warning','danger','accent','info' ]; values.forEach(val => this.el.removeAttribute(val)); this.el.setAttribute(value, ''); } get severity() { if(!this.el) return(''); for(let severity of [ 'primary','secondary','success','warning','danger','accent','info' ]) { if(this.el.hasAttribute(severity)) return(severity) } return('') } /** * Sets rounded angles for the component. * @type {boolean} */ set rounded(value) { if (value) { this.el.setAttribute('rounded', ''); } else { this.el.removeAttribute('rounded'); } } /** @ignore */ get position() { return this.el.getBoundingClientRect(); } /** * Pastes attributes * @param {*} properties * @example * let attr = { * 'data-id': 1, * 'data-name': 'test' * } * obj.attributes(attr) * */ attributes(properties) { if(this.el) for(let property in properties) { this.el.setAttribute(property, properties[property]); } } /** * Hides the element */ hide() { this._baseDisplay = this.el.style.display; this.el.style.display = 'none'; } /** * Shows the element. Restores the display property value catched when using hide(), uses 'block' otherwise. * @todo allow forcing display value */ show() { this.el.style.display = this._baseDisplay ? this._baseDisplay: 'block'; } /** * Adds an event listener * @param {string} type Type of event * @param {function} callback callback function */ addEventListener(type, callback) { this.el.addEventListener(type, callback); } } /** * Alert boxes * @augments EicComponent * @category EICUI/Components * * @todo check issue with options collision */ class Alert extends EicComponent { /** @inheritdoc */ /** * * @param {Element|null} element * @param {*} options */ constructor(el, options) { /** * @type {object} * @property {string} [severity] * @property {boolean} [closable] * @property {string} [message] */ let defaultOptions = { severity: '', closable: false, message: "" } if(el && (el.getAttribute('data-eicui-id') != null) && (Object.keys(ui._components).includes(el.getAttribute('data-eicui-id')))) { return(ui._components[el.getAttribute('data-eicui-id')]) } super(el, {...defaultOptions, ...(options || {})}); this.setOptions(options); if(!this.el) { this.el = ui.create(`
${options.message || ''}
`); } if(this.el.hasAttribute('closable')) this.options.closable = true; if(this.options.severity) this.severity = this.options.severity; if(this.options.closable) { this.el.setAttribute('closable',''); this.el.addEventListener('click', this.close.bind(this)); } this.attributes({ 'aria-enabled': ui.options.ariaEnabled, 'role': 'status', 'aria-label': 'Alert' }); } /** @ignore */ close(event) { event.preventDefault(); event.stopPropagation(); this.el.remove(); this.el.dispatchEvent(new Event('closed')); } } /** * A badge component * @augments EicComponent * @category EICUI/Components */ class Badge extends EicComponent { constructor(el, options) { if(el && (el.getAttribute('data-eicui-id') != null) && (Object.keys(ui._components).includes(el.getAttribute('data-eicui-id')))) { return(ui._components[el.getAttribute('data-eicui-id')]) } super(el, options); if(!this.el) this.el = ui.create(``); this.value = parseInt(this.el.innerHTML) || 0; if(this.options.size) this.size = this.options.size; this.attributes({ 'aria-enabled': ui.options.ariaEnabled, 'role': 'status', 'aria-label': 'Badge' }); } /** * Value of the badge */ set value(val) { this._value = val; this.el.innerHTML = this._value; if(val == 0) ui.hide(this.el); else ui.show(this.el,'inline-flex'); } get value() { return this._value; } /** * Increase badge value by 1 */ increment() { if(this._value) this.value = this._value++; } /** * Decrease badge value by 1 */ decrement() { if(this._value) this.value = this._value--; } } /** * Main UI message growler. Automatically instanciated by ui and available through ui.growl * @see ui#growl * @static * @augments EicComponent * @category EICUI/Core * @hideconstructor */ class Growler extends EicComponent { defaultLifeTime = 6000; /** * Growls a message * @param {string} message * @param {string} severity * @param {number} lifetime Value in ms. Default 10s. */ append(message, severity, lifetime) { let alert = new Alert(null, { message: message, severity: severity, closable: true }); alert.attributes({ 'aria-enabled': ui.options.ariaEnabled, 'role': 'status', 'aria-label': message }) this.el.appendChild(alert.el); if(lifetime !== 0) setTimeout(this.remove.bind(this,alert), lifetime || this.defaultLifeTime); } /** * @ignore * @param {*} alert * @param {*} event */ remove(alert, event) { ui.fadeOut(alert.el, true); } } /** * A card container. Cards contain a title header, a content section and optionally a footer. * @augments EicComponent * @category EICUI/Components * */ class Card extends EicComponent { /** * * @param {Element|null} element * @param {object} options see [options]{@link Card#options} */ constructor(el, options) { /** * * @type {object} * @property {boolean} options.collapsable * @property {boolean} options.collapsed * @property {boolean} options.busy */ let defaultOptions = { collapsable: false, collapsed: false, busy: false } if(el && (el.getAttribute('data-eicui-id') != null) && (Object.keys(ui._components).includes(el.getAttribute('data-eicui-id')))) { return(ui._components[el.getAttribute('data-eicui-id')]) } super(el, {...defaultOptions, ...(options || {})}); this.setOptions(options); this.header = this.el.querySelector('header'); this.content = this.el.querySelector('section'); this.footer = this.el.querySelector('footer'); if(this.el.hasAttribute('collapsable')) this.options.collapsable = true; if(this.el.hasAttribute('collapsed')) this.options.collapsed = true; if(this.options.collapsable) { this.expander = ui.create(``); this.expander.addEventListener('click', this.toggle.bind(this)); this.header.appendChild(this.expander); if(this.options.collapsed) this.collapse(); else this.expand(); } } /** @type {string} */ set title(str) { if(this.header) this.header.querySelector('h1').innerHTML = str; } /** @type {string} */ set subtitle(str) { if(this.header) this.header.querySelector('h2').innerHTML = str; } /** @type {boolean} */ get collapsed() { return this.options.collapsed; } /** @type {boolean} */ set loading(value) { if(value) { this.el.classList.add('loading') } else { this.el.classList.remove('loading') } } /** * */ clear() {} /** * * @param {*} event */ toggle(event) { event.stopPropagation(); event.preventDefault(); if(this.options.collapsed){ this.expand(); } else { this.collapse(); } } /** * */ collapse() { this.expander.classList.remove('arrow-expand', 'arrow-collapse'); void this.expander.offsetWidth; this.expander.classList.add('arrow-collapse'); this.el.setAttribute('collapsed',''); this.content.classList.add('collapsed'); this.options.collapsed = true; } /** * */ expand() { this.expander.classList.remove('arrow-expand', 'arrow-collapse'); void this.expander.offsetWidth; this.expander.classList.add('arrow-expand'); this.el.removeAttribute('collapsed'); this.content.classList.remove('collapsed'); this.options.collapsed = false; } } /** * @augments EicComponent * @category EICUI/Components */ class Chip extends EicComponent { constructor(el, options) { let defaultOptions = { label: null, severity: '', size: '', icon: null, badge: null, destroyable: false, data: {} } if(el && (el.getAttribute('data-eicui-id') != null) && (Object.keys(ui._components).includes(el.getAttribute('data-eicui-id')))) { return(ui._components[el.getAttribute('data-eicui-id')]) } super(el, {...defaultOptions, ...(options || {})}); if(!this.el) this.el = ui.create(``); for(let parm in this.options.data) { this.el.setAttribute('data-' + parm, this.options.data[parm]); } if(this.el.hasAttribute('destroyable')) this.options.destroyable = true; if(this.el.hasAttribute('severity')) this.options.severity = this.el.getAttribute('severity'); if(this.el.hasAttribute('size')) this.options.severity = this.el.getAttribute('size'); if(this.el.hasAttribute('destroyable')) this.options.destroyable = true; if(this.el.hasAttribute('icon')) this.options.icon = this.el.getAttribute('icon'); if(this.el.hasAttribute('badge')) this.options.badge = this.el.getAttribute('badge'); if(this.options.destroyable) { let button = ui.create(``); button.addEventListener('click', this.onDestroy.bind(this)); this.el.appendChild(button); } if(this.options.icon) { this.el.appendChild(ui.create(``)); } if(this.options.badge) { this.badge = new Badge(null, {size: 'xxsmall'}); this.el.appendChild(this.badge.el); this.badge.value = this.options.badge; } if(this.options.severity) { this.severity = this.options.severity; } if(this.options.size) { this.size = this.options.size; } if(this.options.icon) { this.el.appendChild(ui.create(``)); } this.attributes({ 'aria-enabled': ui.options.ariaEnabled, 'role': 'status', 'aria-label': 'Chip' }); } onDestroy(event) { event.stopPropagation(); event.preventDefault(); this.el.parentElement.removeChild(this.el); this.el.dispatchEvent(new CustomEvent('destroy', {detail: this.options.data})); this.onDestroyed(this) } onDestroyed(chip) { } } /** * Tab Controlled Content component * @category EICUI/Components * @author Michael Fallise * @version 1.0 */ class Tab { /** * @constructor */ constructor() { this.triggers = []; this.contents = []; } /** * Links a new set of tab control and content to the collection * @param {Object} trigger a DOM element used to requet the content * @param {Object} content a DOM element to be displayed by the trigger */ addTab(trigger, content) { this.triggers.push(trigger); this.contents.push(content); ui.hide(content); trigger.setAttribute('tab-id', this.triggers.length); trigger.addEventListener('click',this.onTriggerClick.bind(this)); } /** * Links an array of tab controls to an array of contents to the collection * @param {Array} trigger a DOM element used to requet the content * @param {Array} content a DOM element to be displayed by the trigger */ addTabs(triggers, contents) { if(triggers.length > 0) { for(let i = 0; i < triggers.length; i++) { this.addTab(triggers[i], contents[i]) } this.selectByIndex(0); } } /** * click event handler * @param {Object} event a mouse event object fired by the trigger element */ onTriggerClick(event) { var button = event.currentTarget; for(var i = 0; i < this.triggers.length; i++) { if(this.triggers[i].getAttribute('tab-id') == button.getAttribute('tab-id')) { this.selectByIndex(i); break; } } } /** * Activates toggler corresponding to the provided index * @param {number} index the index of the trigger to activate */ selectByIndex(index) { for(var i = 0; i < this.triggers.length; i++) { this.triggers[i].classList.remove('tab-selected'); ui.hide(this.contents[i]); } this.triggers[index].classList.add('tab-selected'); ui.show(this.contents[index]); this.triggers[index].dispatchEvent(new Event('selected')); } /** * Activates toggler containing provided class * @param {string} classname the css class name of the trigger to activate */ selectByClass(classname) { for(var i = 0; i < this.triggers.length; i++) { if(this.triggers[i].classList.has(classname)) { this.triggers[i].classList.add('tab-selected'); ui.show(this.contents[i]); } else { this.triggers[i].classList.remove('tab-selected'); ui.hide(this.contents[i]); } } } /** * Retrieves the index of the active trigger * @return {number} the current index */ getSelectedIndex() { for(var i = 0; i < this.triggers.length; i++) { if(this.triggers[i].classList.contains('tab-selected')) return i; } return -1; } /** * Retrieves the active trigger * @return {Element} the current trigger DOM Element */ getSelected() { return this.triggers.find(el => el.classList.contains('tab-selected')); } /* * Disables the toggler corresponding to the provided index * @param {integer} index the toggler index to activate * */ disableByIndex(index) { let trigger = this.triggers[index]; trigger.setAttribute('disabled', ''); } } /** * Button component * @augments EicComponent * @category EICUI/Components */ class Button extends EicComponent { constructor(el, options) { let defaultOptions = { icon: null, severity: '', disabled: false, badge: false, rounded: false, size: null, hint: null, label: '', onclick: null } if(el && (el.getAttribute('data-eicui-id') != null) && (Object.keys(ui._components).includes(el.getAttribute('data-eicui-id')))) { return(ui._components[el.getAttribute('data-eicui-id')]) } super(el, {...defaultOptions, ...(options || {})}); if(!this.el) { this.el = ui.create(''); } if(this.el.hasAttribute('icon')) this.options.icon = this.el.getAttribute('icon'); if(this.el.hasAttribute('disabled')) this.options.disabled = true; if(this.el.hasAttribute('badge')) this.options.badge = this.el.getAttribute('badge') || true; if(options && options.label) this.label = options.label; if(options && options.severity) this.severity = options.severity; if(this.options.icon) { let icon = this.el.querySelector('i'); if(icon) icon.remove(); this.el.appendChild(ui.create(``)); } if(this.options.rounded) { this.el.setAttribute('rounded', ''); } if(this.options.size) { this.el.setAttribute(this.options.size, ''); } if(this.options.hint) { this.el.setAttribute('title', this.options.hint); } if(this.options.badge) { this.badge = this.el.querySelector('[eicbage]'); if(this.badge) this.badge.remove(); this.badge = new Badge(ui.create(`${this.options.badge}`)); this.el.appendChild(this.badge.el); } if(options && options.onclick) this.click = options.onclick; this.disabled = this.options.disabled; this.attributes({ 'aria-enabled': ui.options.ariaEnabled, 'role': 'button', 'aria-label': this.el.getAttribute('title') || 'Button' }); if(this.el.hasAttribute('loading')) this.loading = this.el.getAttribute('loading'); this.el.addEventListener('click', this.onClick.bind(this)); } set label(label) { this.options.label = label this.el.innerHTML = `${label}`; } set hint(hint) { this.options.hint = hint this.el.setAttribute('title', hint) } set loading(value) { let icon = this.el.querySelector('i'); if(icon) { icon.className = value ? 'icon-spinner spin': this.options.icon; } else { this.el.setAttribute('loading', value); } this.disabled = value; } show() { this.el.style.display = 'inline-flex'; } onClick(event) { event.stopPropagation(); event.preventDefault(); this.click(event); } // By Nike: be silent by default in places with old-style addEventListener shuts-up :-) // To trace those places : change it to { console.log(event); console.trace() } click(event) { } } /** * @augments EicComponent * @category EICUI/Components */ class Select extends EicComponent { /** * @param {element|string} el A SELECT element or the selector string for the SELECT which will be binded to the component. * This passed SELECT element will be hidden (display: none) by the wrapper but will still accessible by the DOM. * The component will use the OPTION and OPTGROUP nodes contained in the parent SELECT to feed its list. * @param {object} [options] Component options: * @param {boolean} [options.multi] Set to true to enable mutiple value selection. Can also be enabled by adding a "mutiple" class to the original SELECT element * @param {boolean} [options.lookup] Set to true to enable value lookup (search engine). Can also be enabled by adding a "lookup" class to the original SELECT element * */ listenVanilla = true; activeEditor = SelectEditor; constructor(el, options) { let defaultOptions = { multi: false, editable: false, lookup: false, maxItems: 0, placeholder: '', hint: '', editor: null } if(el && (el.getAttribute('data-eicui-id') != null) && (Object.keys(ui._components).includes(el.getAttribute('data-eicui-id')))) { return(ui._components[el.getAttribute('data-eicui-id')]) } super(el, {...defaultOptions, ...(options || {})}); let self = this; if(el.hasAttribute('placeholder')) this.options.placeholder = el.getAttribute('placeholder'); if(el.hasAttribute('hint')) this.options.hint = el.getAttribute('hint'); if(el.hasAttribute('multiple')) { this.options.multi = true; } if(el.hasAttribute('lookup')) { this.options.lookup = true; } if(el.hasAttribute('editable')) { this.options.editable = true; this.options.multi = true; el.setAttribute('multiple', '') } this._value = []; this.selectedItems = []; this.coat(el); this.disabled = el.hasAttribute('disabled'); // sync selected options el.querySelectorAll(':checked').forEach( (el) => el.value ? this.selectedItems.push({value: el.value || el.text, label: el.text}): el=el ); this.catalog = !this.options.editable ? this.getCatalog(): null; //ui.hide(this.catalog); // tracks changes if vanilla select is modified el.addEventListener('change', this.onVanillaChange.bind(this)) //el.addEventListener('DOMNodeInserted', this.onVanillaChange.bind(this)) this.mutationList = { attributes: true, childList: true, subtree: false }; this.observer = new MutationObserver(this.onVanillaChange.bind(this)); this.observer.observe(el, this.mutationList); this.selection.addEventListener('remove', function() { ui.hide(self.catalog); }); this.update(); if(this.options.editable) { this.selection.addEventListener('click', this.onOpenEditor.bind(this)); } this.visibilityObserver = new IntersectionObserver( this.onVisibilityChanged.bind(this), {root: null, rootMargin: "0px", threshold:0}) } coat(el) { if(this.el.classList.contains('required')) { this.options.hint = 'This field is required. ' + this.options.hint; } this.container = ui.create(`
`) if(el.parentNode) el.parentNode.insertBefore(this.container, el); this.container.append(this.el); this.selection = ui.create(`
`); //this.selection.classList.remove('eicui-select'); this.container.append(this.selection); if(this.options.hint != '') { this.hint = ui.create(`
${this.options.hint}
`); this.container.append(this.hint); } this.selection.setAttribute('placeholder', this.options.placeholder) ui.hide(el); } /** * Vanilla Select value change handler (value assignment goes upstream) * @param {*} event */ onVanillaChange(event) { if(this.listenVanilla) { let options = this.el.querySelectorAll('option[selected="selected"], option[selected]') //if(options.length > 0) this.value = Array.prototype.slice.call(options).map(el => el.value); } } getCatalog() { let catalog = document.querySelector('#eicui-select-catalog'); // Build catalog window if it has not been set up already (catalog window is shared between all select components) if(!catalog) { catalog = ui.create(`
`); document.body.appendChild(catalog); catalog.appendChild(ui.create(`
`)); // close catalog when loosing focus window.addEventListener('click', this.closeCatalog.bind(this)); catalog.addEventListener('click', function(event) { event.stopPropagation(); }); } this.selection.addEventListener('click', this.onOpenCatalog.bind(this)); return catalog } onOpenCatalog(event) { event.stopPropagation(); this.openCatalog(this.options.lookup); } /** * Opens the items selection dialog */ openCatalog(withLookup) { // resetting lookup input let search = this.catalog.querySelector('.search'); if(search) { search.remove(); } this.searchInput = ui.create(``); this.searchInput.addEventListener('keyup',this.lookup.bind(this)); this.catalog.prepend( this.searchInput); if(withLookup) { this.catalog.classList.add('lookup'); } else { this.catalog.classList.remove('lookup'); } search = this.catalog.querySelector('.search'); search.value = ''; let content = this.catalog.querySelector('.content'); content.innerHTML = ''; this.loadItems(this.el, content); this.catalog.style.left = this.offset.left + 'px'; this.catalog.style.top = this.offset.top + 'px'; this.catalog.width = this.selection.getBoundingClientRect().width + 'px'; ui.show(this.catalog); this.catalog.setAttribute('opened', ''); this.visibilityObserver.observe(this.container) } /** * Event handler for selection list closing. Can be used as a standard method when no event is provided. * @private */ closeCatalog(event) { if(this.catalog.hasAttribute('opened')) { if(event) { event.stopPropagation(); event.preventDefault(); } ui.fadeOut(this.catalog); this.catalog.removeAttribute('opened'); } if(this._keyPressed) this.catalog.removeEventListener('keypressed', this._keyPressed) this.visibilityObserver.disconnect() } /** * Event handler called when the (coated) selector visibility changes. * Used to make sure to close the catalog when the selector dissapears for any reason. * @private */ onVisibilityChanged(intersections, obs){ if(intersections[0].isIntersecting) return // This closes the catalog also when scrolling-out the selector. (not bad as catalog is floating) // if you still want to avoid this, add some condition on the computed visibility of this.container if(this.catalog.hasAttribute('opened')) this.closeCatalog() } /** * * @returns {Object} */ getEditor() { if(!this.editor) { this.editor = new this.activeEditor(); this.editor.initUI(); this.editor.initEvents(); this.selection.after(this.editor.el); window.addEventListener('click', this.editor.onClose.bind(this.editor)); this.editor.addEventListener('click', function(event) { event.stopPropagation(); }); } return this.editor; } /** * * @param {*} event */ async onOpenEditor(event) { event.stopPropagation(); let response = await this.openEditor(); if(response) { this.addItem(response); } } onEditorOpened() {} /** * */ openEditor() { let self = this; let promise = new Promise(function(resolve, reject) { self.listenVanilla = false; self.catalog = self.getEditor(); self.catalog.open(); self.onEditorOpened(self.catalog); self.catalog.el.querySelector('input').value = ""; self.catalog.el.querySelector('input').focus(); function commit(result) { self.catalog.close(); resolve(result); } function abort(result) { self.catalog.close(); reject(result); } self.catalog.commit = commit; self.catalog.abort = abort; }); return promise; } /** * */ closeEditor() { ui.hide(this.catalog.el); this.catalog.el.removeAttribute('opened', ''); //this.listenVanilla = true; } /** * * @param {*} event */ onKeyPressed(event) { event.stopPropagation(); if(event.keyCode == 13) { this.addItem(this.catalog.el.querySelector('input').value); this.closeEditor(); } } /** * */ set disabled(value) { if(value) this.selection.setAttribute('disabled',''); else this.selection.removeAttribute('disabled'); } /** * * @returns NodeList */ get items() { return this.el.querySelectorAll('options'); } /** * Finds Y axis position of element in the page * @returns number */ get offset() { if (!this.selection.getClientRects().length) { return { top: 0, left: 0 }; } let rect = this.selection.getBoundingClientRect(); let win = this.selection.ownerDocument.defaultView; return ( { top: rect.top + win.scrollY, left: rect.left + win.scrollX }); } /** * Returns selected value(s) * @returns {string|Array} */ get value() { return this.options.multi ? this._value: this._value.length > 0 ? this._value[0]: null ; } set value(val) { if(val === this._value) return; if(typeof val == "boolean") { val = val.toString(); } if(val === '' || val === null) { this.clear(); return; } if(this.options.multi) { if(!Array.isArray(val)) { val = val.split(','); } } else { val = val != '' ? [ val ]: []; } this.selectedItems = []; for(let i = 0; i < val.length; i++) { let option if(this.options.editable) { option = ui.create(''); this.listenVanilla = false; this.el.append(option); //this.listenVanilla = true; } else { option = this.el.querySelector('option[value="' + val[i] + '"]'); } if(val[i] != '' && option) this.selectedItems.push({value: val[i], label: option.label}); } this.update(); return; } /** * Wrapper for jQuery element.change() event handler method * Triggered when the selection is changed (through a user interaction). * @param {function} handler The call back function called when event is triggered */ change(handler) { this.el.addEventListener('change', handler); } /** * Selects an item in the collection * @param {item} The selected LI element * @param {boolean|null} forcedValue if set, selects (with true) or unselects (with false) the item. If null, toggles the selection state. */ select(item, forcedValue) { this.listenVanilla = false; // check if max selection reached if(this.options.multi && this.options.maxItems > 0 && !item.classList.contains('selected')) { if(this.selectedItems.length == this.options.maxItems) return; } if(forcedValue == null) { item.classList.toggle('selected'); } else { if(forcedValue) { item.classList.add('selected'); } else { item.classList.remove('selected'); } } forcedValue = item.classList.contains('selected'); let value = item.getAttribute('data-value'); let label = item.querySelector('span'); let childs = item.querySelectorAll('ul > li'); if(item.classList.contains('selected')) { if(!this.options.multi) { this.selectedItems = []; this.catalog.querySelector('.selected').classList.remove('selected'); item.classList.add('selected'); this.closeCatalog(); } if(this.selectedItems.findIndex(x => x.value == value) == -1) { this.selectedItems.push({value: value, label: label.innerHTML}); } } else { var index = this.selectedItems.findIndex(x => x.value == value); this.selectedItems.splice(index, 1); } for(var i = 0; i < childs.length ; i++) { this.select(childs[i], forcedValue); } this.update(); this.listenVanilla = true; } /** * Event handler for item removal from selection */ remove(event) { event.stopPropagation(); event.preventDefault(); this.listenVanilla = false; if(this.options.multi) { let id = event.detail.id; this.selectedItems.splice(this.selectedItems.findIndex(x => x.value == id), 1); } else { this.clear(); } this.update(); this.listenVanilla = true; } /** * Remove all options */ empty() { this.el.innerHTML = ''; this.clear(); } /** * Clears the selection */ clear(noevent) { this._value = ''; this.selectedItems = []; this.update(noevent); } addItem(newItem) { this.listenVanilla = false; if(this.options.maxItems > 0 && this.selectedItems.length == this.options.maxItems) return; if(!this.selectedItems.find(item => item.value == newItem.value)) { this.selectedItems.push(newItem); let option = this.el.querySelector(`option[value="${newItem.value}"]`) if(!option) { option = ui.create(``) this.el.append(option); } option.setAttribute('selected','selected') this.update(); } //this.catalog.el.querySelector('input').value = ""; } /* * Fills catalog window with the related SELECT.OPTION fields content (recurses on optgroup subsets) * @private */ loadItems(el, container, filter) { let self = this; let tags = el.children; let list = []; let ul = ui.create('