diff --git a/closest.js b/closest.js new file mode 100644 index 0000000..fa46434 --- /dev/null +++ b/closest.js @@ -0,0 +1,16 @@ +export const closest = (node, selector, pierce) => { + const matches = node.matches || node.msMatchesSelector || node.oMatchesSelector; + while (node) { + if (matches.call(node, selector)) { + return node; + } + if (pierce && !node.parentElement) { + node = node.getRootNode(); + if (node) { + node = node.host; + } + } else { + node = node.parentElement; + } + } +}; \ No newline at end of file diff --git a/control-state.js b/control-state.js new file mode 100644 index 0000000..1f37aee --- /dev/null +++ b/control-state.js @@ -0,0 +1,52 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +export const ControlState = function(superClass) { + return class extends superClass { + static get properties() { + return { + focused: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + this._boundFocus = this._focusHandler.bind(this); + } + + firstUpdated() { + super.firstUpdated(); + this.addEventListener('focus', this._boundFocus, true); + this.addEventListener('blur', this._boundFocus, true); + } + + _focusHandler(e) { + this.focused = e.type === 'focus'; + } + + _disabledChanged(disabled) { + this.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + this.style.pointerEvents = disabled ? 'none' : ''; + if (disabled) { + // Read the `tabindex` attribute instead of the `tabIndex` property. + // The property returns `-1` if there is no `tabindex` attribute. + // This distinction is important when restoring the value because + // leaving `-1` hides shadow root children from the tab order. + this._prevTabIndex = this.getAttribute('tabindex'); + this.focused = false; + this.tabIndex = -1; + this.blur(); + } else if (this._prevTabIndex !== undefined) { + if (this._prevTabIndex === null) { + this.removeAttribute('tabindex'); + } else { + this.setAttribute('tabindex', this._prevTabIndex); + } + } + } + }; +} \ No newline at end of file diff --git a/dom-query.js b/dom-query.js new file mode 100644 index 0000000..bc4f5c6 --- /dev/null +++ b/dom-query.js @@ -0,0 +1,29 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +/** + * Helps to automatically query elements in the shadow dom of the extended element. + */ +export const DomQuery = function(superClass) { + return class extends superClass { + + constructor() { + super(); + + const handler = { + get: (o, selector) => { + const root = this.shadowRoot || this; + const el = selector[0] === '?' ? root.querySelector(selector.substring(1)) : root.querySelector(`#${selector}`); + if (el !== null) { + return el; + } + } + }; + + this.$ = new Proxy({}, handler); + } + }; +} diff --git a/event-helpers.js b/event-helpers.js new file mode 100644 index 0000000..4ddccf3 --- /dev/null +++ b/event-helpers.js @@ -0,0 +1,57 @@ +/** + * Add an event listener bound to the context of the superClass. + * + * @param {HTMLElement} node Element to attach the event listener to. + * @param {String} eventName Name of the event. + * @param {String} cbName Name of the handler. + */ +export const EventHelpers = function(superClass) { + return class extends superClass { + listen(node, eventName, cbName) { + this.__boundEventListeners = this.__boundEventListeners || new WeakMap(); + const boundListener = this[cbName].bind(this); + const eventKey = `${eventName}_${cbName}`; + let listeners = this.__boundEventListeners.get(node); + + // If there is already a handler for the event assigned we stop here. + if (listeners && typeof listeners[eventKey] === 'function') return; + + if (!listeners) { + listeners = {}; + } + + listeners[eventKey] = boundListener; + this.__boundEventListeners.set(node, listeners); + node.addEventListener(eventName, boundListener); + } + + /** + * Remove an event listener bound to the context of the superClass. + * + * @param {HTMLElement} node Element to attach the event listener to. + * @param {String} eventName Name of the event. + * @param {String} cbName Name of the handler. + */ + unlisten(node, eventName, cbName) { + this.__boundEventListeners = this.__boundEventListeners || new WeakMap(); + const listeners = this.__boundEventListeners.get(node); + const eventKey = `${eventName}_${cbName}`; + + if (listeners && typeof listeners[eventKey]) { + node.removeEventListener(eventName, listeners[eventKey]); + listeners[eventKey] = null; + } + } + + once(node, eventName, cbName) { + const wrappedCbName = `__onceCb__${cbName}`; + + this[wrappedCbName] = (...args) => { + this[cbName](...args); + this.unlisten(node, eventName, wrappedCbName); + }; + + this.listen(node, eventName, wrappedCbName); + } + } +} \ No newline at end of file diff --git a/fetch-mixin.js b/fetch-mixin.js new file mode 100644 index 0000000..886b2c1 --- /dev/null +++ b/fetch-mixin.js @@ -0,0 +1,71 @@ +export const fetchMixin = function(superClass) { + return class extends superClass { + constructor() { + super(); + this.__abortControllers = new Map(); + } + + get(url) { + return fetch(url).then(response => response.json()); + } + + head(url) { + return fetch(url, { method: 'HEAD' }); + } + + async post(url, data, overwrite = true) { + this.__cancelRunningRequest(url); + + if (overwrite === true) { + const ac = new AbortController(); + this.__abortControllers.set(url, ac); + } + + try { + const reqOptions = { + method: 'POST', + signal: this.__abortControllers.get(url).signal, + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + referrer: 'no-referrer', + body: JSON.stringify(data) + }; + + document.dispatchEvent(new CustomEvent('before-request', { detail: reqOptions, bubbles: true, composed: true })); + + const result = await fetch(url, reqOptions).then(response => response.json()); + + this.__abortControllers.delete(url); + + if (result.statusCode === 500) { + console.error(result); + } + + return result; + } catch (err) { + if (err.name === 'AbortError') { + return { statusCode: -1, error: err }; + } else { + this.__abortControllers.delete(url); + return { statusCode: null, error: err }; + } + } + } + + isInFlight(url) { + return Boolean(this.__abortControllers.get(url)); + } + + __cancelRunningRequest(url) { + if (this.__abortControllers.has(url)) { + try { + this.__abortControllers.get(url).abort(); + } catch (err) { } + this.__abortControllers.delete(url); + } + } + }; +}; diff --git a/form-element.js b/form-element.js new file mode 100644 index 0000000..87c4f91 --- /dev/null +++ b/form-element.js @@ -0,0 +1,51 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +export const FormElement = function(superClass) { + return class extends superClass { + static get properties() { + return { + // The name of this element. + name: { type: String }, + + // The value for this element. + value: { type: String }, + + // Set to true to mark the input as required. Element needs to provide a "validate()" function tha returns a boolean. + required: { type: Boolean }, + + // The form that the element is registered to. Set by the form that got the registration. + parentForm: { type: Object } + }; + } + + connectedCallback() { + super.connectedCallback(); + + // Prevent that child elements register. + this.addEventListener('form-element-register', this._onChildRegister.bind(this)); + } + + firstUpdated() { + super.firstUpdated(); + this.dispatchEvent(new CustomEvent('form-element-register', { bubbles: true, composed: true })); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._parentForm) { + this._parentForm.dispatchEvent(new CustomEvent('form-element-unregister', { detail: { target: this }, bubbles: true, composed: true })); + } + } + + // Prevent that child elements register themselves to the form element. + _onChildRegister(e) { + if (e.composedPath()[0].tagName !== this.tagName) { + e.stopPropagation(); + } + } + }; +} \ No newline at end of file diff --git a/inert.js b/inert.js new file mode 100644 index 0000000..af4007e --- /dev/null +++ b/inert.js @@ -0,0 +1,44 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +/** + * This helper provides sort of a inert polyfill. + * The `inert` property and a helper method is added that can be used + * to update tabindex for example. The implementing element also is styled with + * pointer-events: none if the inert property is set true. + */ +export const Inert = function(superClass) { + return class extends superClass { + static get properties() { + return { + inert: { + type: Boolean, + value: false, + observer: '_inertChanged', + reflectToAttribute: true + } + }; + } + + updated(changes) { + if (changes.has('inert')) { + this._inertChanged(this.inert); + } + } + + _inertChanged(state) { + if (state) { + this.style.pointerEvents = 'none'; + this._inertTapIndex = this.getAttribute('tabindex'); + } else { + this.style.pointerEvents = ''; + if (this._inertTapIndex) { + this.setAttribute('tabindex', this._inertTapIndex); + } + } + } + }; +} diff --git a/isDefined.js b/isDefined.js new file mode 100644 index 0000000..fcaa5e3 --- /dev/null +++ b/isDefined.js @@ -0,0 +1,3 @@ +export const isDefined = val => { + return val !== null && val !== undefined; +}; \ No newline at end of file diff --git a/lazy-imports.js b/lazy-imports.js new file mode 100644 index 0000000..ca7f1fd --- /dev/null +++ b/lazy-imports.js @@ -0,0 +1,75 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +/** + * Used to import other scripts dynamically based on a route change from the-router for example. + * Do this by defining lazyMap for the importer and then calling `import` with the new path segments. + * + * ## Example lazyMp + * + * ```js + * [ + * { + * match: /^user$/, // Would match /user/ + * imports: [ 'user-element-1.html', 'user-element-2.html' ], + * map: [ + * { + * match: /^settings$/, // Would match /user/settings + * imports: [ 'settings.html' ] + * } + * ] + * } + * ] + * ``` + * + * Use the _lazyImport method to start processing the map. + * _lazyImport needs an array of strings that describe the path segments it should + * match the map and sub maps against. + * + * So, if for example your route is /user/settings you need to split + * the path to [ 'user', 'settings' ] and feed it to the _lazyImport function. +*/ +export default class { + constructor(lazyMap) { + this.lazyMap = lazyMap; + } + + import(segments) { + if (typeof segments === 'object' && !Array.isArray(segments)) { + segments = Object.values(segments).filter(v => v !== undefined); + } + + if (!this.lazyMap || !Array.isArray(segments)) return; + + const imports = []; + this.__processLazyMap(this.lazyMap, segments, 0, imports); + + const promises = imports.map(url => { + return import(url); + }); + + return promises.length === 1 ? promises[0] : Promise.all(promises); + } + + __processLazyMap(map, segments, level, list) { + const segment = segments[level]; + if (segment === undefined) return []; + + map.forEach(entry => { + if (!entry.match.test(segment)) return; + + if (Array.isArray(entry.imports)) { + if (entry.match.test(segment)) { + list.push(...entry.imports); + } + } + + if (Array.isArray(entry.map)) { + this.__processLazyMap(entry.map, segments, level + 1, list); + } + }); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f074585 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "helpers", + "version": "1.0.0", + "description": "", + "main": "closest.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://gitea.codeblob.work/tp-elements/helpers.git" + }, + "author": "trading_peter", + "license": "Apache-2.0" +} diff --git a/position.js b/position.js new file mode 100644 index 0000000..5ef5444 --- /dev/null +++ b/position.js @@ -0,0 +1,87 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +/** + * Helps to position elements relative to each other. + */ +export const Position = function(superClass) { + return class extends superClass { + + _posFixed(anchor, el, options) { + options = Object.assign({ + valign: 'top', + halign: 'middle', + spacing: 0 + }, options); + + let top, left, fixLeft = 0, fixTop = 0, compLeft = 0, compTop = 0; + el.style.position = 'fixed'; + el.style.zIndex = 1001; + + // Test if the target is in a different stacking context. + el.style.left = '0px'; + el.style.top = '0px'; + const elRect = el.getBoundingClientRect(); + + if (elRect.left > 0 || elRect.top > 0) { + fixLeft = elRect.left; + fixTop = elRect.top; + } + + const anchorRect = anchor.getBoundingClientRect(); + if (options.valign === 'top') { + top = anchorRect.top - elRect.height - options.spacing; + + // Move popup down a little bit if there issn't enough room over the anchor. + if (top < 0) { + compTop = Math.abs(top); + top = 0; + } + } + + if (options.valign === 'bottom') { + top = anchorRect.top + anchorRect.height + options.spacing; + + // Move popup up a little bit if there issn't enough room under the anchor. + if (top + elRect.height > window.innerHeight) { + compTop = top + elRect.height - window.innerHeight; + top -= compTop; + } + } + + if (options.halign === 'left') { + left = anchorRect.left; + } + + if (options.halign === 'middle') { + left = anchorRect.left - elRect.width / 2 + anchorRect.width / 2; + } + + if (options.halign === 'right') { + left = anchorRect.left + anchorRect.width - elRect.width; + } + + if (left + elRect.width > window.innerWidth) { + compLeft = left + elRect.width - window.innerWidth; + left -= compLeft; + } + + if (left < 0) { + compLeft = Math.abs(left); + left = 0; + } + + el.style.top = (top - fixTop) + 'px'; + el.style.left = (left - fixLeft) + 'px'; + + // Return info object about how much the position had to compensate to fit on the page. + return { + compLeft: compLeft, + compTop: compTop + }; + } + }; +}