UNPKG

@efflore/ui-element

Version:

UIElement - minimal reactive framework based on Web Components

628 lines (555 loc) 19.3 kB
import { UIElement, maybe, pass, on, effect, asBoolean, asInteger, setText, setProperty, setAttribute, toggleAttribute, toggleClass } from '@efflore/ui-element' import Prism from './assets/js/prism.min.js' class MyCounter extends UIElement { static observedAttributes = ['count'] static attributeMap = { count: asInteger } connectedCallback() { this.set('parity', () => this.get('count') % 2 ? 'odd' : 'even') this.first('.increment').map(on('click', () => this.set('count', v => ++v))) this.first('.decrement').map(on('click', () => this.set('count', v => --v))) this.first('.count').map(setText('count')) this.first('.parity').map(setText('parity')) } } MyCounter.define('my-counter') class CodeBlock extends UIElement { static observedAttributes = ['collapsed'] static attributeMap = { collapsed: asBoolean } connectedCallback() { // enhance code block with Prism.js const language = this.getAttribute('language') || 'html' const content = this.querySelector('code') this.set('code', content.textContent.trim(), false) effect(enqueue => { // apply syntax highlighting while preserving Lit's marker nodes in Storybook const code = document.createElement('code') code.innerHTML = Prism.highlight(this.get('code'), Prism.languages[language], language) enqueue(content, 'h', el => () => { Array.from(el.childNodes) .filter(node => node.nodeType !== Node.COMMENT_NODE) .forEach(node => node.remove()) Array.from(code.childNodes) .forEach(node => el.appendChild(node)) }) }) // copy to clipboard this.first('.copy').map(ui => on('click', async () => { const copyButton = ui.target const label = copyButton.textContent let status = 'success' try { await navigator.clipboard.writeText(content.textContent) } catch (err) { console.error('Error when trying to use navigator.clipboard.writeText()', err) status = 'error' } copyButton.set('disabled', true) copyButton.set('label', ui.host.getAttribute(`copy-${status}`)) setTimeout(() => { copyButton.set('disabled', false) copyButton.set('label', label) }, status === 'success' ? 1000 : 3000) })(ui)) // expand this.first('.overlay').map(on('click', () => this.set('collapsed', false))) this.self.map(toggleAttribute('collapsed')) } } CodeBlock.define('code-block') class TabList extends UIElement { static observedAttributes = ['accordion'] static attributeMap = { accordion: asBoolean } static consumedContexts = ['media-viewport'] connectedCallback() { super.connectedCallback() this.set('active', 0, false) setTimeout(() => { if (this.get('media-viewport')) this.set('accordion', () => ['xs', 'sm'].includes(this.get('media-viewport'))) }, 0) this.self .map(toggleAttribute('accordion')) this.first('.tab-nav') .map(setProperty('ariaHidden', 'accordion')) this.all('.tab-nav button') .map((ui, idx) => setProperty('ariaPressed', () => this.get('active') === idx)(ui)) .map((ui, idx) => on('click', () => this.set('active', idx))(ui)) this.all('accordion-panel') .map((ui, idx) => pass({ open: () => ui.host.get('active') === idx, collapsible: 'accordion' })(ui)) } } TabList.define('tab-list') class AccordionPanel extends UIElement { connectedCallback() { this.set('open', this.hasAttribute('open')) this.set('collapsible', this.hasAttribute('collapsible')) this.self .map(toggleAttribute('open')) .map(toggleAttribute('collapsible')) .map(setProperty('ariaHidden', () => !this.get('open') && !this.get('collapsible'))) this.first('details') .map(setProperty('open')) .map(setProperty('ariaDisabled', () => !this.get('collapsible'))) } } AccordionPanel.define('accordion-panel') class InputButton extends UIElement { static observedAttributes = ['disabled'] static attributeMap = { disabled: asBoolean } connectedCallback() { this.first('button') .map(setText('label')) .map(setProperty('disabled')) } } InputButton.define('input-button') /* === Pure Functions === */ const isNumber = num => typeof num === 'number' const parseNumber = (v, int = false) => int ? parseInt(v, 10) : parseFloat(v) /* === Class Definition === */ class InputField extends UIElement { static observedAttributes = ['value', 'description'] static attributeMap = { value: value => this.isNumber ? value.map(v => parseNumber(v, this.isInteger)).filter(Number.isFinite) : value } connectedCallback() { this.input = this.querySelector('input') this.isNumber = this.input && this.input.type === 'number' this.isInteger = this.hasAttribute('integer') // set default states this.set('value', this.isNumber ? this.input.valueAsNumber : this.input.value, false) this.set('length', this.input.value.length) // derived states this.set('empty', () => !this.get('length')) // setup sub elements this.#setupErrorMessage() this.#setupDescription() this.#setupSpinButton() this.#setupClearButton() // handle input changes this.input.onchange = () => this.#triggerChange(this.isNumber ? this.input.valueAsNumber : this.input.value) this.input.oninput = () => this.set('length', this.input.value.length) // update value effect(async () => { const value = this.get('value') const validate = this.getAttribute('validate') if (value && validate) { // validate input value against a server-side endpoint await fetch(`${validate}?name=${this.input.name}value=${this.input.value}`) .then(async response => { const text = await response.text() this.input.setCustomValidity(text) this.set('error', text) }) .catch(err => this.set('error', err.message)) } if (this.isNumber && !isNumber(value)) // ensure value is a number if it is not already a number return this.set('value', parseNumber(value, this.isInteger)) // effect will be called again with numeric value if (this.isNumber && !Number.isNaN(value)) // change value only if it is a valid number this.input.value = value }); } /** * Clear the input field */ clear() { this.input.value = '' this.set('value', '') this.set('length', 0) this.input.focus() } /** * Trigger value-change event to commit the value change * * @private * @param {number|string|function} value - value to set */ #triggerChange = value => { this.set('value', value) this.set('error', this.input.validationMessage) if (typeof value === 'function') value = this.get('value') if (this.input.value !== String(value)) this.dispatchEvent(new CustomEvent('value-change', { detail: value, bubbles: true })) } /** * Setup error message * * @private */ #setupErrorMessage() { const error = this.first('.error') // derived states this.set('ariaInvalid', () => String(Boolean(this.get('error')))) this.set('aria-errormessage', () => this.get('error') ? error[0]?.target.id : undefined ) // effects error .map(setText('error')) this.first('input') .map(setProperty('ariaInvalid')) .map(setAttribute('aria-errormessage')) } /** * Setup description * * @private */ #setupDescription() { const description = this.first('.description') if (!description[0]) return // no description, so skip // derived states const input = this.first('input') const maxLength = this.input.maxLength const remainingMessage = maxLength && description[0].target.dataset.remaining const defaultDescription = description[0].target.textContent this.set('description', remainingMessage ? () => { const length = this.get('length') return length > 0 ? remainingMessage.replace('${x}', maxLength - length) : defaultDescription } : defaultDescription ) this.set('aria-describedby', () => this.get('description') ? description[0].target.id : undefined ) // effects description.forEach(setText('description')) input.forEach(setAttribute('aria-describedby')) } /** * Setup spin button * * @private */ #setupSpinButton() { const spinButton = this.querySelector('.spinbutton') if (!this.isNumber || !spinButton) return // no spin button, so skip const getNumber = attr => maybe(parseNumber(this.input[attr], this.isInteger)).filter(Number.isFinite)[0] const tempStep = parseNumber(spinButton.dataset.step, this.isInteger) const [step, min, max] = Number.isFinite(tempStep) ? [tempStep, getNumber('min'), getNumber('max')] : [] // bring value to nearest step const nearestStep = v => { const steps = Math.round((max - min) / step) let zerone = Math.round((v - min) * steps / (max - min)) / steps // bring to 0-1 range zerone = Math.min(Math.max(zerone, 0), 1) // keep in range in case value is off limits const value = zerone * (max - min) + min return this.isInteger ? Math.round(value) : value } /** * Step down * * @param {number} [stepDecrement=step] - value to increment by */ this.stepDown = (stepDecrement = step) => this.#triggerChange(v => nearestStep(v - stepDecrement)) /** * Step up * * @param {number} [stepIncrement=step] - value to increment by */ this.stepUp = (stepIncrement = step) => this.#triggerChange(v => nearestStep(v + stepIncrement)) // derived states this.set('decrement-disabled', () => isNumber(min) && (this.get('value') - step < min)) this.set('increment-disabled', () => isNumber(max) && (this.get('value') + step > max)) // handle spin button clicks and update their disabled state this.first('.decrement') .map(setProperty('disabled', 'decrement-disabled')) .forEach(on('click', e => this.stepDown(e.shiftKey ? step * 10 : step))) this.first('.increment') .map(setProperty('disabled', 'increment-disabled')) .forEach(on('click', e => this.stepUp(e.shiftKey ? step * 10 : step))) // handle arrow key events this.input.onkeydown = e => { if (['ArrowUp', 'ArrowDown'].includes(e.key)) { e.stopPropagation() e.preventDefault() if (e.key === 'ArrowDown') this.stepDown(e.shiftKey ? step * 10 : step) if (e.key === 'ArrowUp') this.stepUp(e.shiftKey ? step * 10 : step) } } } /** * Setup clear button * * @private */ #setupClearButton() { this.first('.clear') .map(toggleClass('hidden', 'empty')) .forEach(on('click', () => { this.clear() this.input.focus() })) } } InputField.define('input-field') class InputCheckbox extends UIElement { static observedAttributes = ['checked'] static attributeMap = { checked: asBoolean, } connectedCallback() { this.first('input') .map(on('change', e => this.set('checked', Boolean(e.target.checked)))) .map(setProperty('checked')) this.self .map(toggleAttribute('checked')) } } InputCheckbox.define('input-checkbox') export class InputRadiogroup extends UIElement { static observedAttributes = ['value'] connectedCallback() { this.self .map(setAttribute('value')) this.all('input') .map(on('change', e => this.set('value', e.target.value))) this.all('label') .map(ui => toggleClass( 'selected', () => ui.host.get('value') === ui.target.querySelector('input').value )(ui)) } } InputRadiogroup.define('input-radiogroup') class LazyLoad extends UIElement { connectedCallback() { this.set('error', '') this.set('content', () => { fetch(this.getAttribute('src')) // TODO ensure 'src' attribute is a valid URL from a trusted source .then(async response => response.ok ? this.set('content', await response.text()) : this.set('error', response.statusText) ) .catch(error => this.set('error', error)) return // we don't return a fallback value }) const loadingEl = this.querySelector('.loading') const errorEl = this.querySelector('.error') effect(enqueue => { const error = this.get('error') if (!error || !errorEl) return if (loadingEl) enqueue(loadingEl, 'r', el => () => el.remove()) // remove placeholder for pending state enqueue(errorEl, 't', el => () => el.textContent = error) // fill error message }) effect(enqueue => { const content = this.get('content') if (!content) return this.root = this.shadowRoot || this.attachShadow({ mode: 'open' }) // we use shadow DOM to encapsulate styles enqueue(this.root, 'h', el => () => { // UNSAFE!, use only trusted sources in 'src' attribute el.innerHTML = content el.querySelectorAll('script').forEach(script => { const newScript = document.createElement('script') newScript.appendChild(document.createTextNode(script.textContent)) el.appendChild(newScript) script.remove() }) }) if (loadingEl) enqueue(loadingEl, 'r', el => () => el.remove()) // remove placeholder for pending state if (errorEl) enqueue(errorEl, 'r', el => () => el.remove()) // won't be needed anymore as request was successful }) } } LazyLoad.define('lazy-load') const MEDIA_MOTION = 'media-motion' const MEDIA_THEME = 'media-theme' const MEDIA_VIEWPORT = 'media-viewport' const MEDIA_ORIENTATION = 'media-orientation' class MediaContext extends UIElement { static providedContexts = [MEDIA_MOTION, MEDIA_THEME, MEDIA_VIEWPORT, MEDIA_ORIENTATION] connectedCallback() { super.connectedCallback() const THEME_LIGHT = 'light' const THEME_DARK = 'dark' const VIEWPORT_XS = 'xs' const VIEWPORT_SM = 'sm' const VIEWPORT_MD = 'md' const VIEWPORT_LG = 'lg' const VIEWPORT_XL = 'xl' const ORIENTATION_LANDSCAPE = 'landscape' const ORIENTATION_PORTRAIT = 'portrait' const getBreakpoints = () => { const parseBreakpoint = breakpoint => { const attr = this.getAttribute(breakpoint)?.trim() if (!attr) return null const unit = attr.match(/em$/) ? 'em' : 'px' const value = maybe(parseFloat(attr)).filter(Number.isFinite)[0] return value ? value + unit : null } const sm = parseBreakpoint(VIEWPORT_SM) || '32em' const md = parseBreakpoint(VIEWPORT_MD) || '48em' const lg = parseBreakpoint(VIEWPORT_LG) || '72em' const xl = parseBreakpoint(VIEWPORT_XL) || '108em' return { sm, md, lg, xl } } const breakpoints = getBreakpoints() const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)') const colorScheme = matchMedia('(prefers-color-scheme: dark)') const screenSmall = matchMedia(`(min-width: ${breakpoints.sm})`) const screenMedium = matchMedia(`(min-width: ${breakpoints.md})`) const screenLarge = matchMedia(`(min-width: ${breakpoints.lg})`) const screenXLarge = matchMedia(`(min-width: ${breakpoints.xl})`) const screenOrientation = matchMedia('(orientation: landscape)') const getViewport = () => { if (screenXLarge.matches) return VIEWPORT_XL if (screenLarge.matches) return VIEWPORT_LG if (screenMedium.matches) return VIEWPORT_MD if (screenSmall.matches) return VIEWPORT_SM return VIEWPORT_XS } // set initial values this.set(MEDIA_MOTION, reducedMotion.matches) this.set(MEDIA_THEME, colorScheme.matches ? THEME_DARK : THEME_LIGHT) this.set(MEDIA_VIEWPORT, getViewport()) this.set(MEDIA_ORIENTATION, screenOrientation.matches ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT) // event listeners reducedMotion.addEventListener( 'change', e => this.set(MEDIA_MOTION, e.matches) ) colorScheme.addEventListener( 'change', e => this.set(MEDIA_THEME, e.matches ? THEME_DARK : THEME_LIGHT) ) screenSmall.addEventListener('change', () => this.set(MEDIA_VIEWPORT, getViewport())) screenMedium.addEventListener('change', () => this.set(MEDIA_VIEWPORT, getViewport())) screenLarge.addEventListener('change', () => this.set(MEDIA_VIEWPORT, getViewport())) screenXLarge.addEventListener('change', () => this.set(MEDIA_VIEWPORT, getViewport())) screenOrientation.addEventListener( 'change', e => this.set(MEDIA_THEME, e.matches ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT) ) } } MediaContext.define('media-context') class TodoForm extends UIElement { connectedCallback() { const inputField = this.querySelector('input-field') this.first('form') .map(on('submit', e => { e.preventDefault() setTimeout(() => { this.dispatchEvent(new CustomEvent('add-todo', { bubbles: true, detail: inputField.get('value') })) inputField.clear() }, 0) })) this.first('input-button') .map(pass({ disabled: () => inputField.get('empty') })) } } TodoForm.define('todo-form') class TodoApp extends UIElement { connectedCallback() { const [todoList, todoFilter] = ['todo-list', 'input-radiogroup'] .map(selector => this.querySelector(selector)) // event listener on own element this.self .map(on('add-todo', e => todoList?.addItem(e.detail))) // coordinate todo-count this.first('todo-count') .map(pass({ active: () => todoList?.get('count').active })) // coordinate todo-list this.first('todo-list') .map(pass({ filter: () => todoFilter?.get('value') })) // coordinate .clear-completed button this.first('.clear-completed') .map(on('click', () => todoList?.clearCompleted())) .map(pass({ disabled: () => !todoList?.get('count').completed })) } } TodoApp.define('todo-app') class TodoCount extends UIElement { connectedCallback() { this.set('active', 0, false) this.first('.count') .map(setText('active')) this.first('.singular') .map(setProperty('ariaHidden', () => this.get('active') > 1)) this.first('.plural') .map(setProperty('ariaHidden', () => this.get('active') === 1)) this.first('.remaining') .map(setProperty('ariaHidden', () => !this.get('active'))) this.first('.all-done') .map(setProperty('ariaHidden', () => !!this.get('active'))) } } TodoCount.define('todo-count') class TodoList extends UIElement { connectedCallback() { this.set('filter', 'all') // set initial filter this.#updateList() // event listener and attribute on own element this.self .map(on('click', e => { if (e.target.localName === 'button') this.removeItem(e.target) })) .map(setAttribute('filter')) // update count on each change this.set('count', () => { const tasks = this.get('tasks').map(el => el.signal('checked')) const completed = tasks.filter(fn => fn()).length const total = tasks.length return { active: total - completed, completed, total } }) } addItem = task => { const template = this.querySelector('template').content.cloneNode(true) template.querySelector('span').textContent = task this.querySelector('ol').appendChild(template) this.#updateList() } removeItem = element => { element.closest('li').remove() this.#updateList() } clearCompleted = () => { this.get('tasks') .filter(el => el.get('checked')) .forEach(el => el.parentElement.remove()) this.#updateList() } #updateList() { this.set('tasks', Array.from(this.querySelectorAll('input-checkbox'))) } } TodoList.define('todo-list')