• HTML

    input-field.html html

    <input-field>
    	<label for="name-input">Name</label>
    	<div class="row">
    		<div class="group auto">
    			<input type="text" id="name-input" name="name" autocomplete="name" required>
    		</div>
    	</div>
    	<p class="error" aria-live="assertive" id="name-error"></p>
    </input-field>
    
    <input-field integer>
    	<label for="bday-year-input">Birthday Year</label>
    	<div class="row">
    		<div class="group short">
    			<input type="number" id="bday-year-input" name="bday-year" autocomplete="bday-year" required minlength="4" maxlength="4" min="1900" max="2024" step="1">
    		</div>
    		<div class="spinbutton" data-step="1">
    			<button type="button" class="decrement" aria-label="Decrement">−</button>
    			<button type="button" class="increment" aria-label="Increment">+</button>
    		</div>
    	</div>
    	<p class="error" aria-live="assertive" id="bday-year-error"></p>
    	<p class="description" aria-live="polite" id="bday-year-description"></p>
    </input-field>
    
    <input-field validate="./examples/snippets/validate.html">
    	<label for="username-input">Username</label>
    	<div class="row">
    		<div class="group auto">
    			<input type="text" id="username-input" name="username" autocomplete="username" aria-describedby="username-description" required minlength="4" maxlength="20">
    		</div>
    	</div>
    	<p class="error" aria-live="assertive" id="username-error"></p>
    	<p class="description" aria-live="polite" id="username-description" data-remaining="${x} characters remaining">Max. 20 characters</p>
    </input-field>
    CSS

    input-field.css css

    input-field {
    	width: 100%;
    
    	&[value="0"] input {
    		color: color-mix(in srgb, var(--color-text) 50%, transparent);
    	}
    
    	&:hover button {
    		opacity: var(--opacity-translucent);
    
    		&:not(:disabled) {
    			opacity: var(--opacity-solid);
    			cursor: pointer;
    		}
    	}
    
    	&:focus-within {
    
    		& label,
    		& p,
    		& span {
    			opacity: var(--opacity-solid);
    		}
    
    		& button {
    			opacity: var(--opacity-translucent);
    
    			&:not(:disabled) {
    				opacity: var(--opacity-solid);
    				cursor: pointer;
    			}
    		}
    
    		& input {
    			color: var(--color-text);
    		}
    	}
    
    	& label,
    	& p,
    	& span {
    		opacity: var(--opacity-dimmed);
    		transition: opacity var(--transition-short) var(--easing-inout);
    	}
    
    	& label {
    		display: block;
    		font-size: var(--font-size-s);
    		color: var(--color-text);
    		margin-bottom: var(--space-xxs);
    	}
    
    	.row {
    		display: flex;
    		gap: var(--space-s);
    	}
    
    	.group {
    		display: flex;
    		align-items: baseline;
    		background: var(--color-input);
    		border-bottom: 1px solid var(--color-border);
    		width: 100%;
    
    		&.short {
    			width: 6rem;
    		}
    
    		.clear {
    			border: 0;
    			border-radius: 50%;
    			color: var(--color-input);
    			width: var(--space-m);
    			height: var(--space-m);
    			line-height: 1.1;
    			align-self: center;
    			text-align: center;
    			padding: 0;
    			margin: 0 var(--space-xxs);
    		}
    
    		.hidden {
    			display: none;
    		}
    
    		& span:first-child {
    			padding-left: var(--space-xs);
    		}
    
    		& span:last-child {
    			padding-right: var(--space-xs);
    		}
    	}
    
    	& input {
    		flex-grow: 1;
    		display: inline-block;
    		box-sizing: border-box;
    		background: var(--color-input);
    		color: var(--color-text);
    		border: 0;
    		padding: var(--space-xs) var(--space-xxs);
    		font-size: var(--font-size-m);
    		height: 2rem;
    		width: 100%;
    		transition: color var(--transition-short) var(--easing-inout);
    
    		&::placeholder {
    			color: var(--color-text);
    			opacity: var(--opacity-translucent);
    		}
    	}
    
    	& input[type="number"] {
    		text-align: right;
    	}
    
    	& input[aria-invalid="true"] {
    		box-shadow: 0 0 var(--space-xxs) 2px var(--color-error);
    	}
    
    	& span {
    		flex-grow: 0;
    	}
    
    	::-webkit-textfield-decoration-container {
    		height: 100%;
    	}
    
    	::-webkit-inner-spin-button {
    		appearance: none;
    	}
    
    	.spinbutton {
    		display: flex;
    	}
    
    	& button {
    		border: 1px solid var(--color-border);
    		background-color: var(--color-secondary);
    		color: var(--color-text);
    		padding: var(--space-xs) var(--space-s);
    		font-size: var(--font-size-s);
    		line-height: var(--line-height-s);
    		width: 2rem;
    		height: 2rem;
    		opacity: var(--opacity-transparent);
    		transition: opacity var(--transition-short) var(--easing-inout);
    		user-select: none;
    
    		&:disabled {
    			cursor: revert;
    			background-color: var(--color-background);
    		}
    
    		&:not(:disabled) {
    
    			&:hover {
    				background-color: var(--color-secondary-hover);
    			}
    
    			&:active {
    				background-color: var(--color-secondary-active);
    			}
    		}
    	}
    
    	.decrement {
    		border-radius: var(--space-xs) 0 0 var(--space-xs);
    	}
    
    	.increment {
    		border-radius: 0 var(--space-xs) var(--space-xs) 0;
    		border-left: 0;
    	}
    
    	.error,
    	.description {
    		margin: var(--space-xs) 0 0;
    		font-size: var(--font-size-xs);
    		line-height: var(--line-height-s);
    
    		&:empty {
    			display: none;
    		}
    	}
    
    	.error {
    		color: color-mix(in srgb, var(--color-text) 50%, var(--color-error));
    	}
    
    	.description {
    		color: var(--color-text-soft);
    	}
    }
    
    TS

    input-field.ts ts

    import { effect, setAttribute, setProperty, setText, UIElement, UNSET } from '../../../'
    
    /* === Type === */
    
    type InputFieldSignals = {
    	value: string | number,
    	length: number,
    	empty: boolean,
    	error: string,
    	ariaInvalid: "true" | "false",
    	'aria-errormessage': string,
        /* description?: string,
        isInteger?: boolean,
        min?: number,
        max?: number,
    	'aria-describedby'?: string, */
    }
    
    /* === Pure functions === */
    
    // Check if value is a number
    const isNumber = (num: any) => typeof num === 'number'
    
    // Convert any value to a boolean string
    const toBooleanString = (value: any): "true" | "false" =>
    	!value || value === "false" ? "false" : "true"
    
    // Parse a value as a number with optional integer flag and fallback value
    const parseNumber = (v: any, int = false, fallback = 0): number => {
    	const temp = int ? parseInt(v, 10) : parseFloat(v)
    	return Number.isFinite(temp) ? temp : fallback
    }
    
    /* === Class definition === */
    
    export class InputField extends UIElement<InputFieldSignals> {
    	static observedAttributes = ['value', 'description']
    
    	init = {
    		value: (v: string | null, el: UIElement<InputFieldSignals>): string | number =>
    			(el as InputField).isNumber
    				? parseNumber(v, (el as InputField).isInteger, 0)
    				: (v ?? ''),
    		length: 0,
    		empty: () => this.get('length') === 0,
    		error: '',
    		ariaInvalid: () => toBooleanString(this.get('error')),
    		'aria-errormessage': () =>
    			this.get('error') ? this.querySelector('.error')?.id : UNSET,
    	}
    
    	isNumber = false
    	isInteger = false
    	input: HTMLInputElement | null = null
    	step = 1
    	min = 0
    	max = 100
    
    	connectedCallback() {
    		super.connectedCallback()
    
    		// Set properties
    		this.input = this.querySelector('input')
    		this.isNumber = (this.input && this.input.type === 'number') ?? false
    		this.isInteger = this.hasAttribute('integer')
    		if (this.input && this.isNumber) {
    			this.step = parseNumber(this.input.step, this.isInteger, 1)
    			this.min = parseNumber(this.input.min, this.isInteger, 0)
    			this.max = parseNumber(this.input.max, this.isInteger, 100)
    		}
    		this.set('length', this.input?.value.length ?? 0)
    
    		// Handle input changes
    		this.first('input')
    			.on('change', () => {
    				this.#triggerChange(
    					this.isNumber
    						? (this.input?.valueAsNumber ?? 0)
    						: (this.input?.value ?? '')
    				)
    			})
    			.on('input', () => {
    				this.set('length', this.input?.value.length ?? 0)
    			})
    
    		// Handle arrow key events to increment / decrement value
    		if (this.isNumber) {
    			this.first('input').on('keydown', (e: Event) => {
    				const evt = e as KeyboardEvent
    				if (['ArrowUp', 'ArrowDown'].includes(evt.key)) {
    					e.stopPropagation()
    					e.preventDefault()
    					if (evt.key === 'ArrowDown')
    						this.stepDown(evt.shiftKey ? this.step * 10 : this.step)
    					if (evt.key === 'ArrowUp')
    						this.stepUp(evt.shiftKey ? this.step * 10 : this.step)
    				}
    			})
    		}
    
    		// Setup error message
    		this.first('.error').sync(setText('error'))
    		this.first('input').sync(
    			setProperty('ariaInvalid'),
    			setAttribute('aria-errormessage')
    		)
    
    		// Setup description
    		const description = this.querySelector<HTMLElement>('.description')
    		if (description) {
    			
    			// Derived states
    			const maxLength = this.input?.maxLength
    			const remainingMessage = maxLength && description.dataset.remaining
    			const defaultDescription = description.textContent ?? ''
    			this.set(
    				'description',
    				remainingMessage
    					? () => {
    						const length = this.get('length')
    						return length
    							? remainingMessage.replace('${x}', String(maxLength - length))
    							: defaultDescription
    					}
    					: defaultDescription
    			)
    			this.set(
    				'aria-describedby',
    				() => this.get('description') ? description.id : UNSET
    			)
    
    			// Effects
    			this.first('.description').sync(setText('description'))
    			this.first('input').sync(setAttribute('aria-describedby'))
    		}
    
    		// Handle spin button clicks and update their disabled state
    		const spinButton = this.querySelector('.spinbutton') as HTMLElement | null
    		if (this.isNumber && spinButton) {
    			this.first<HTMLButtonElement>('.decrement')
    				.on('click', (e: Event) => {
    					this.stepDown((e as MouseEvent).shiftKey ? this.step * 10 : this.step)
    				}).sync(setProperty(
    					'disabled',
    					() => isNumber(this.min) && (this.get('value') as number ?? 0) - this.step < this.min
    				))
    			this.first<HTMLButtonElement>('.increment')
    				.on('click', (e: Event) => {
    					this.stepUp((e as MouseEvent).shiftKey ? this.step * 10 : this.step)
    				})
    				.sync(setProperty(
    					'disabled',
    					() => isNumber(this.max) && (this.get('value') as number ?? 0) + this.step > this.max
    				))
    		}
    
    		// Setup clear button
    		this.first('.clear')
    			.on('click', () => {
    				this.clear()
    				this.input?.focus()
    			})
    			.sync(setProperty('hidden', 'empty'))
    
    		// Validate and update value
    		effect(() => {
    			const value = this.get('value')
    			const validate = this.getAttribute('validate')
    			if (value && validate) {
    
    				// Validate input value against a server-side endpoint
    				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))
    			}
    
    			// Ensure value is a number if it is not already a number
    			if (this.isNumber && !isNumber(value))
    				// Effect will be called again with numeric value
    				return this.set('value', parseNumber(value, this.isInteger, 0))
    
    			// Change value only if it is a valid number
    			if (this.input && this.isNumber && Number.isFinite(value))
    				this.input.value = String(value)
    		})
     	}
    
    	// Clear the input field
    	clear() {
    		this.set('value', '')
    		this.set('length', 0)
    		if (this.input) {
    			this.input.value = ''
    			this.input.focus()
    		}
    	}
    
    	stepUp(stepIncrement: number = this.step) {
    		if (this.isNumber)
    			this.#triggerChange(v => this.#nearestStep(v + stepIncrement))
    	}
    
    	stepDown(stepDecrement: number = this.step) {
    		if (this.isNumber)
    			this.#triggerChange(v => this.#nearestStep(v - stepDecrement))
        }
    
    	// Trigger value-change event to commit the value change
    	#triggerChange = (value: string | number | ((v: any) => string | number))  => {
    		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.self.emit('value-change', value)
    	}
    
    	// Round a value to the nearest step
    	#nearestStep = (v: number) => {
    		const steps = Math.round((this.max - this.min) / this.step)
    		// Bring to 0-1 range
    		let zerone = Math.round((v - this.min) * steps / (this.max - this.min)) / steps
    		// Keep in range in case value is off limits
    		zerone = Math.min(Math.max(zerone, 0), 1)
    		const value = zerone * (this.max - this.min) + this.min
    		return this.isInteger ? Math.round(value) : value
    	}
    
    }
    InputField.define('input-field')