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