UNPKG

@zeix/ui-element

Version:

UIElement - a HTML-first library for reactive Web Components

265 lines (242 loc) 6.41 kB
import { type Component, component, computed, type Effect, emitEvent, on, type Parser, setAttribute, setProperty, setText, UNSET, } from '../../../' import { clearEffects, clearMethod } from '../../functions/shared/clear-input' /* === Type === */ export type InputFieldProps = { value: string | number length: number error: string description: string clear(): void } /* === Pure Functions === */ // Check if value is a number const isNumber = (num: unknown) => typeof num === 'number' // Parse a value as a number with optional integer flag and fallback value const parseNumber = ( v: string | null | undefined, int = false, fallback = 0, ): number => { if (!v) return fallback const temp = int ? parseInt(v, 10) : parseFloat(v) return Number.isFinite(temp) ? temp : fallback } // Count decimal places in a number const countDecimals = (value: number): number => { if (Math.floor(value) === value || String(value).indexOf('.') === -1) return 0 return String(value).split('.')[1].length || 0 } /* === Attribute Parsers === */ const asNumberOrString: Parser<string | number> = (el, v) => { const input = el.querySelector('input') return input && input.type === 'number' ? parseNumber(v, el.hasAttribute('integer'), 0) : (v ?? '') } /* === Component === */ export default component<InputFieldProps>( 'input-field', { value: asNumberOrString, length: 0, error: '', description: '', clear: clearMethod(), }, (el, { first, useElement }) => { const fns: Effect<InputFieldProps, Component<InputFieldProps>>[] = [] const input = useElement('input', 'Native input field needed') const typeNumber = input.type === 'number' const integer = el.hasAttribute('integer') const validationEndpoint = el.getAttribute('validate') // Trigger value-change event to commit the value change const triggerChange = ( value: string | number | ((v: any) => string | number), ) => { const newValue = typeof value === 'function' ? value(el.value) : typeNumber && !isNumber(value) ? parseNumber(value, integer, 0) : value if (Object.is(el.value, newValue)) return // Validate input value against a server-side endpoint if (newValue !== null && validationEndpoint) { fetch( `${validationEndpoint}?name=${input.name}value=${newValue}`, ) .then(async response => { const text = await response.text() input.setCustomValidity(text) el.error = text }) .catch(err => { el.error = err.message }) } input.checkValidity() el.value = newValue el.error = input.validationMessage ?? '' } // Handle input changes fns.push( emitEvent('value-change', 'value'), first('input', [ setProperty('value', () => String(el.value)), on('change', () => { triggerChange( typeNumber ? (input.valueAsNumber ?? 0) : (input.value ?? ''), ) }), on('input', () => { el.length = input.value.length ?? 0 }), ]), ) if (typeNumber) { const spinButton = el.querySelector( '.spinbutton', ) as HTMLElement | null const step = parseNumber( spinButton?.dataset['step'] || input.step, integer, 1, ) const min = parseNumber(input.min, integer, 0) const max = parseNumber(input.max, integer, 100) // Round a value to the nearest step const nearestStep = (v: number) => { if (!Number.isFinite(v) || v < min) return min if (v > max) return max const value = min + Math.round((v - min) / step) * step return integer ? Math.round(value) : parseFloat(value.toFixed(countDecimals(step))) } // Handle arrow key events to increment / decrement value fns.push( first( 'input', on('keydown', ({ event }) => { const { key, shiftKey } = event if (['ArrowUp', 'ArrowDown'].includes(key)) { event.stopPropagation() event.preventDefault() const n = shiftKey ? step * 10 : step const newValue = nearestStep( input.valueAsNumber + (key === 'ArrowUp' ? n : -n), ) input.value = String(newValue) triggerChange(newValue) } }), ), ) // Handle spin button clicks and update their disabled state if (spinButton) { fns.push( first<HTMLButtonElement>('.decrement', [ on('click', ({ event }) => { const n = event.shiftKey ? step * 10 : step const newValue = nearestStep( input.valueAsNumber - n, ) input.value = String(newValue) triggerChange(newValue) }), setProperty( 'disabled', () => (isNumber(min) ? (el.value as number) : 0) - step < min, ), ]), first<HTMLButtonElement>('.increment', [ on('click', ({ event }) => { const n = event.shiftKey ? step * 10 : step const newValue = nearestStep( input.valueAsNumber + n, ) input.value = String(newValue) triggerChange(newValue) }), setProperty( 'disabled', () => (isNumber(max) ? (el.value as number) : 0) + step > max, ), ]), ) } } else { // Setup clear button and method fns.push(first('.clear', clearEffects(el))) } // Setup error message const errorId = el.querySelector('.error')?.id fns.push( first('.error', setText('error')), first('input', [ setProperty('ariaInvalid', () => (el.error ? 'true' : 'false')), setAttribute('aria-errormessage', () => el.error && errorId ? errorId : UNSET, ), ]), ) // Setup description const description = el.querySelector<HTMLElement>('.description') if (description) { // Derived state const maxLength = input.maxLength const remainingMessage = maxLength && description.dataset.remaining if (remainingMessage) { el.setSignal( 'description', computed(() => remainingMessage.replace( '${x}', String(maxLength - el.length), ), ), ) } // Effects fns.push( first('.description', setText('description')), first( 'input', setAttribute('aria-describedby', () => el.description && description.id ? description.id : UNSET, ), ), ) } return fns }, ) declare global { interface HTMLElementTagNameMap { 'input-field': Component<InputFieldProps> } }