UNPKG

@jinntec/fore

Version:

Fore - declarative user interfaces in plain HTML

667 lines (595 loc) 21.4 kB
import XfAbstractControl from './abstract-control.js'; import { evaluateXPath, evaluateXPathToString, evaluateXPathToFirstNode, } from '../xpath-evaluation.js'; import getInScopeContext from '../getInScopeContext.js'; import { Fore } from '../fore.js'; import { ModelItem } from '../modelitem.js'; import { debounce } from '../events.js'; import { FxModel } from '../fx-model.js'; const WIDGETCLASS = 'widget'; /** * `fx-control` * a generic wrapper for controls * * * * @customElement * @demo demo/index.html */ /* function debounce( func, timeout = 300) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => { func.apply(this, args); }, timeout); }; } */ export default class FxControl extends XfAbstractControl { constructor() { super(); this.inited = false; this.nodeset = null; this.attachShadow({ mode: 'open' }); } static get properties() { return { ...XfAbstractControl.properties, credentials: { type: String, }, initial: { type: Boolean, }, src: { type: String, }, }; } _getValueFromHtmlDom() { if (this.valueProp === 'selectedOptions') { // We have multiple! Just return that as space-separated for now return [...this.widget.selectedOptions].map(option => option.value).join(' '); } return this.widget[this.valueProp]; } connectedCallback() { this.initial = this.hasAttribute('initial') ? this.getAttribute('initial') : null; this.src = this.hasAttribute('src') ? this.getAttribute('src') : null; this.loaded = false; this.initialNode = null; this.debounceDelay = this.hasAttribute('debounce') ? this.getAttribute('debounce') : null; this.updateEvent = this.hasAttribute('update-event') ? this.getAttribute('update-event') : 'blur'; this.label = this.hasAttribute('label') ? this.getAttribute('label') : null; const style = ` :host{ display:inline-block; } `; this.credentials = this.hasAttribute('credentials') ? this.getAttribute('credentials') : 'same-origin'; if (!['same-origin', 'include', 'omit'].includes(this.credentials)) { console.error( `fx-submission: the value of credentials is not valid. Expected 'same-origin', 'include' or 'omit' but got '${this.credentials}'`, this, ); } this.shadowRoot.innerHTML = ` <style> ${style} </style> ${this.renderHTML(this.ref)} `; this.widget = this.getWidget(); this.addEventListener('mousedown', e => { // ### prevent mousedown events on all control content that is not the widget or within the widget if (!Fore.isWidget(e.target) && !e.target?.classList.contains('fx-hint')) { e.preventDefault(); // e.stopImmediatePropagation(); } this.widget.focus(); }); const defaultValueProp = this.widget.hasAttribute('multiple') ? 'selectedOptions' : 'value'; this.valueProp = this.hasAttribute('value-prop') ? this.getAttribute('value-prop') : defaultValueProp; // console.log('widget ', this.widget); let listenOn = this.widget; // default: usually listening on widget if (this.hasAttribute('listen-on')) { const q = this.getAttribute('listen-on'); const target = this.querySelector(q); if (target) { listenOn = target; } } this.addEventListener('keyup', () => { FxModel.dataChanged = true; }); // ### convenience marker event if (this.updateEvent === 'enter') { this.widget.addEventListener('keyup', event => { if (event.keyCode === 13) { // console.info('handling Event:', event.type, listenOn); // Cancel the default action, if needed event.preventDefault(); this.setValue(this._getValueFromHtmlDom()); } }); this.updateEvent = 'blur'; // needs to be registered too } if (this.debounceDelay) { listenOn.addEventListener( this.updateEvent, debounce( this, () => { // console.log('eventlistener ', this.updateEvent); // console.info('handling Event:', event.type, listenOn); this.setValue(this._getValueFromHtmlDom()); }, this.debounceDelay, ), ); } else { listenOn.addEventListener(this.updateEvent, event => { this.setValue(this._getValueFromHtmlDom()); }); listenOn.addEventListener( 'blur', event => { this.setValue(this._getValueFromHtmlDom()); }, { once: true }, ); } this.addEventListener('return', e => { const newNodes = e.detail.nodeset; e.stopPropagation(); this._replaceNode(newNodes); }); this.widget.addEventListener('focus', () => { /* if (!this.classList.contains('visited')) { this.classList.add('visited'); } */ }); this.template = this.querySelector('template'); this.boundInitialized = false; this.static = !!this.widget.hasAttribute('static'); // console.log('template',this.template); } _debounce(func, timeout = 300) { let timer; return (...args) => { const context = this; clearTimeout(timer); timer = setTimeout(() => { func.apply(context, args); }, timeout); }; } /** * updates the model with a new value by executing it's `<fx-setvalue>` action. * * In case the `as='node'` is given the bound node is replaced with the widgets' value with is * expected to be a node again. * * @param val the new value to be set */ setValue(val) { const modelitem = this.getModelItem(); if (this.getAttribute('class')) { this.classList.add('visited'); } else { this.setAttribute('class', 'visited'); } if (modelitem?.readonly) { console.warn('attempt to change readonly node', modelitem); return; // do nothing when modelItem is readonly } if (this.getAttribute('as') === 'node') { const replace = this.shadowRoot.getElementById('replace'); const widgetValue = this.getWidget()[this.valueProp]; replace.replace(this.nodeset, widgetValue); if (modelitem && widgetValue && widgetValue !== modelitem.value) { modelitem.value = widgetValue; FxModel.dataChanged = true; replace.actionPerformed(); } return; } const setval = this.shadowRoot.getElementById('setvalue'); setval.setValue(modelitem, val); if (this.modelItem instanceof ModelItem && !this.modelItem?.boundControls.includes(this)) { this.modelItem.boundControls.push(this); } setval.actionPerformed(); // this.visited = true; } _replaceNode(node) { // Note: clone the node while replacing to prevent the instances to leak through if (node.nodeType === Node.ATTRIBUTE_NODE) { this.modelItem.node.nodeValue = node.nodeValue; } else if (node.nodeType === Node.ELEMENT_NODE) { this.modelItem.node.replaceWith(node.cloneNode(true)); } else if (node.nodeType === Node.TEXT_NODE) { this.modelItem.node.nodeValue = node.textContent; } else { Fore.dispatch(this, 'warn', { message: 'trying to replace a node that is neither an Attribute, Elemment or Text node', }); } // this.getOwnerForm().refresh(); } renderHTML(ref) { return ` ${this.label ? `${this.label}` : ''} <slot></slot> ${ this.hasAttribute('as') && this.getAttribute('as') === 'node' ? '<fx-replace id="replace" ref=".">' : `<fx-setvalue id="setvalue" ref="${ref}"></fx-setvalue>` } `; } /** * The widget is the actual control being used in the UI e.g. a native input control or any * other component that presents a control that can be interacted with. * * This function returns the widget by querying the children of this control for an element * with `class="widget"`. If that cannot be found it searches for an native `input` of any type. * If either cannot be found a `<input type="text">` is created. * * @returns {HTMLElement|*} */ getWidget() { if (this.widget) return this.widget; let widget = this.querySelector(`.${WIDGETCLASS}`); if (!widget) { widget = this.querySelector('input'); if (widget && !widget.classList.contains('widget')) { widget.classList.add('widget'); } } if (!widget) { const input = document.createElement('input'); input.classList.add(WIDGETCLASS); input.setAttribute('type', 'text'); this.appendChild(input); return input; } return widget; } /** * updates the widget from the modelItem value. During refresh the a control * evaluates it's binding expression to determine the bound node. The bound node corresponds * to a modelItem which acts a the state object of a node. The modelItem determines the value * and the state of the node and set the `value` property of this class. * * @returns {Promise<void>} */ async updateWidgetValue() { // this._getValueFromHtmlDom() = this.value; let { widget } = this; if (!widget) { widget = this; } // ### value is bound to checkbox if (this.valueProp === 'checked') { if (this.value === 'true') { widget.checked = true; } else { widget.checked = false; } return; } // ### value is bound to radio if (this.widget.type === 'radio') { const matches = this.querySelector(`input[value=${this.value}]`); if (matches) { matches.checked = true; } return; } if (this.valueProp === 'selectedOptions') { const valueSet = new Set(this.value.split(' ')); for (const option of [...this.widget.querySelectorAll('option')]) { if (valueSet.has(option.value)) { option.selected = true; } else { option.selected = false; } } return; } if (this.hasAttribute('as')) { const as = this.getAttribute('as'); // ### when there's an `as=text` attribute serialize nodeset to prettified string if (as === 'text') { const serializer = new XMLSerializer(); const pretty = Fore.prettifyXml(serializer.serializeToString(this.nodeset)); widget.value = pretty; } if (as === 'node' && this.nodeset !== widget.value) { // const oldVal = this.nodeset.innerHTML; const oldVal = this.nodeset; if (widget.value) { if (oldVal !== this.widget.value) { // console.log('changed'); widget.value = this.nodeset.cloneNode(true); return; } } widget.value = this.nodeset.cloneNode(true); // todo: should be more like below but that can cause infinite loop when controll trigger update event due to calling a setter for property // widget[this.valueProp] = this.nodeset.cloneNode(true); // console.log('passed value to widget', widget.value); } return; } // ### when there's a src Fore is used as widget and will be loaded from external file if (this.src && !this.loaded && this.modelItem.relevant) { // ### evaluate initial data if necessary if (this.initial) { this.initialNode = evaluateXPathToFirstNode(this.initial, this.nodeset, this); // console.log('initialNodes', this.initialNode); } // ### load the markup from src await this._loadForeFromSrc(); this.loaded = true; // ### replace default instance of embedded Fore with initial nodes // const innerInstance = this.querySelector('fx-instance'); // console.log('innerInstance',innerInstance); return; } if (widget.value !== this.value) { widget.value = this.value; } } /** * loads an external Fore from an HTML file given by `src` attribute and embed it as child of this control. * * Will look for the `<fx-fore>` element within the returned HTML file and return that element. * * If that cannot be found an error is dispatched. * * todo: dispatch link error * @private */ async _loadForeFromSrc() { console.info( `%cControl ref="${this.ref}" is loading ${this.src}`, 'background:#64b5f6; color:white; padding:0.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); try { const response = await fetch(this.src, { method: 'GET', credentials: this.credentials, mode: 'cors', headers: { 'Content-Type': 'text/html', }, }); const responseContentType = response.headers.get('content-type').toLowerCase(); // console.log('********** responseContentType *********', responseContentType); let data; if (responseContentType.startsWith('text/html')) { data = await response.text().then(result => // console.log('xml ********', result); new DOMParser().parseFromString(result, 'text/html'), ); } else { data = 'done'; } // const theFore = fxEvaluateXPathToFirstNode('//fx-fore', data.firstElementChild); const theFore = data.querySelector('fx-fore'); const imported = document.importNode(theFore, true); imported.classList.add('widget'); // is the new widget imported.addEventListener( 'model-construct-done', e => { const defaultInst = imported.querySelector('fx-instance'); if (this.initial) { const doc = new DOMParser().parseFromString('<data></data>', 'application/xml'); // Note: Clone the input to prevent the inner fore from editing the outer node // Also update the `initialNode` to make sure we have an up-to-date version this.initialNode = evaluateXPathToFirstNode(this.initial, this.nodeset, this); doc.firstElementChild.appendChild(this.initialNode.cloneNode(true)); defaultInst.instanceData = doc; } imported.model = imported.querySelector('fx-model'); imported.model.updateModel(); imported.refresh(true); }, { once: true }, ); const dummy = this.querySelector('input'); /* todo: the mechanism to import constructed stylesheets as in fore-component is still missing here. There no way yet to specify CSS for a embedded fx-fore in shadowDOM. */ if (this.hasAttribute('shadow')) { dummy.parentNode.removeChild(dummy); this.shadowRoot.appendChild(imported); } else if (!this.loaded) { dummy.replaceWith(imported); } if (!theFore) { Fore.dispatch('error', { detail: { message: `Fore element not found in '${this.src}'. Maybe wrapped within 'template' element?`, }, }); } Fore.dispatch('loaded', { detail: { fore: theFore } }); } catch (error) { // console.log('error', error); Fore.dispatch(this, 'error', { origin: this, message: `control couldn't be loaded from src '${this.src}'`, level: 'Error', }); } } getTemplate() { return this.querySelector('template'); } async refresh(force) { // console.log('fx-control refresh', this); super.refresh(force); // console.log('refresh template', this.template); // const {widget} = this; // ### if we find a ref on control we have a 'select' control of some kind const widget = this.getWidget(); this._handleBoundWidget(widget); this._handleDataAttributeBinding(); Fore.refreshChildren(this, force); } /** * handle non-Fore elements like 'select' and 'datalist' which have a 'data-ref' attribute * @private */ _handleDataAttributeBinding() { const dataRefd = this.querySelector('[data-ref]'); // Handle nested fx-controls if (dataRefd && dataRefd.closest('fx-control') === this) { this.boundList = dataRefd; const ref = dataRefd.getAttribute('data-ref'); this._handleBoundWidget(dataRefd); } } /** * If the widget itself has a `ref` it binds to another nodeset to provide some * dynamic items to be created from a template usually. Examples are dynamic select option lists * or a set of checkboxes. * * @param widget the widget to handle * @private */ _handleBoundWidget(widget) { if (this.boundInitialized && this.static) return; const ref = widget.hasAttribute('ref') ? widget.getAttribute('ref') : widget.getAttribute('data-ref'); // if (widget && widget.hasAttribute('ref')) { if (widget && ref) { // ### eval nodeset for list control const inscope = getInScopeContext(this, ref); // const nodeset = evaluateXPathToNodes(ref, inscope, this); const nodeset = evaluateXPath(ref, inscope, this); // ### clear items const { children } = widget; Array.from(children).forEach(child => { if (child.nodeName.toLowerCase() !== 'template') { child.parentNode.removeChild(child); } }); // ### bail out when nodeset is array and empty if (Array.isArray(nodeset) && nodeset.length === 0) return; // ### build the items const { template } = this; if (template) { // ### handle 'selection' open and insert an empty option in that case if ( this.widget.nodeName === 'SELECT' && this.widget.hasAttribute('selection') && this.widget.getAttribute('selection') === 'open' ) { const firstTemplateChild = this.template.firstElementChild; // todo: create the element which is used in the template instead of 'option' const option = document.createElement('option'); this.widget.insertBefore(option, firstTemplateChild); } if (nodeset.length !== 0) { // console.log('nodeset', nodeset); const fragment = document.createDocumentFragment(); // console.time('offscreen'); Array.from(nodeset).forEach(node => { // console.log('#### node', node); // ### initialize new entry const newEntry = this.createEntry(); fragment.appendChild(newEntry); // ### set value this.updateEntry(newEntry, node); }); this.template.parentNode.appendChild(fragment); // console.timeEnd('offscreen'); } else { const newEntry = this.createEntry(); this.template.parentNode.appendChild(newEntry); this.updateEntry(newEntry, nodeset); } this.boundInitialized = true; } if (this.valueProp === 'selectedOptions') { const valueSet = new Set(this.value.split(' ')); const options = this.getWidget().querySelectorAll('option'); for (const option of [...options]) { if (valueSet.has(option.value)) { option.selected = true; } else { option.selected = false; } } } } } updateEntry(newEntry, node) { // ### >>> todo: needs rework this code is heavily assuming a select control with 'value' attribute - not generic at all yet. // if (this.widget.nodeName !== 'SELECT') return; const valueAttribute = this._getValueAttribute(newEntry); if (!valueAttribute) { // Fore.dispatch(this,'warn',{message:'no value attribute specified for template entry.'}); return; } const valueExpr = valueAttribute.value; const cutted = valueExpr.substring(1, valueExpr.length - 1); const evaluated = evaluateXPathToString(cutted, node, newEntry); valueAttribute.value = evaluated; if (this.value === evaluated) { newEntry.setAttribute('selected', 'selected'); } // ### set label const optionLabel = newEntry.textContent; this.evalLabel(optionLabel, node, newEntry); // ### <<< needs rework } evalLabel(optionLabel, node, newEntry) { const labelExpr = optionLabel.substring(1, optionLabel.length - 1); if (!labelExpr) return; const label = evaluateXPathToString(labelExpr, node, this); newEntry.textContent = label; } createEntry() { return this.template.content.firstElementChild.cloneNode(true); // const content = this.template.content.firstElementChild.cloneNode(true); // return content; // const newEntry = document.importNode(content, true); // this.template.parentNode.appendChild(newEntry); // return newEntry; } // eslint-disable-next-line class-methods-use-this _getValueAttribute(element) { let result; Array.from(element.attributes).forEach(attribute => { const attrVal = attribute.value; if (attrVal.indexOf('{') !== -1) { // console.log('avt found ', attribute); result = attribute; } }); return result; } } if (!customElements.get('fx-control')) { window.customElements.define('fx-control', FxControl); }