<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>
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-invalid);
}
& span {
flex-grow: 0;
}
::-webkit-textfield-decoration-container {
height: 100%;
}
::-webkit-inner-form-spinbutton {
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);
}
}
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>
}
}