input-field.ts ts

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>
	}
}