input-field.ts ts

import {
	type AttributeParser,
	type Component,
	type Effect,
	UNSET,
	component,
	computed,
	emitEvent,
	on,
	setAttribute,
	setProperty,
	setText,
	show,
} from '../../../'

/* === 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: AttributeParser<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(
	'input-field',
	{
		value: asNumberOrString,
		length: 0,
		error: '',
		description: '',
		clear: (host: Component<InputFieldProps>) => {
			host.clear = () => {
				host.value = ''
				host.length = 0
				const input = host.querySelector('input')
				if (input) {
					input.value = ''
					input.checkValidity()
					input.focus()
				}
			}
		},
	},
	(el: Component<InputFieldProps>, { first }) => {
		const fns: Effect<InputFieldProps, Component<InputFieldProps>>[] = []
		const input = el.querySelector('input')
		if (!input) throw new Error('No input element found')
		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', (e: Event) => {
						const { key, shiftKey } = e as KeyboardEvent
						if (['ArrowUp', 'ArrowDown'].includes(key)) {
							e.stopPropagation()
							e.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', (e: Event) => {
							const n = (e as MouseEvent).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', (e: Event) => {
							const n = (e as MouseEvent).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<HTMLButtonElement>(
					'.clear',
					on('click', () => {
						el.clear()
						triggerChange('')
					}),
					show(() => !!el.length),
				),
			)
		}

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