Inputs
Number field
Takes an exact number, typed or stepped with buttons and arrow keys, within optional bounds.
- Built on
<input inputmode="decimal">role=spinbutton:has():user-invalid- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.4 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { NumberField } from "@/components/equip/number-field";
export default function Demo() {
return <NumberField label="Seats" defaultValue={5} min={1} max={50} name="seats" />;
}Your versionchange its options in Look, This part
Examples
Decimal step with a hint
The step's decimals set the rounding, so arrowing from 0.25 lands on 0.5, never 0.49999.
Between 0.25 and 8 cores per container.
Code
import { NumberField } from "@/components/equip/number-field";
export default function Example() {
return (
<div style={{ width: 240 }}>
<NumberField
label="CPU limit (cores)"
hint="Between 0.25 and 8 cores per container."
defaultValue={0.5}
min={0.25}
max={8}
step={0.25}
name="cpu"
/>
</div>
);
}Controlled
api-gateway runs 3 pods
Code
"use client";
import { useState } from "react";
import { NumberField } from "@/components/equip/number-field";
export default function Example() {
const [replicas, setReplicas] = useState<number | null>(3);
return (
<div style={{ display: "grid", gap: 8 }}>
<NumberField label="Replicas" value={replicas} onValueChange={setReplicas} min={0} max={12} />
<p style={{ margin: 0, fontSize: 13 }}>
{replicas === null ? "No replica count set" : `api-gateway runs ${replicas} ${replicas === 1 ? "pod" : "pods"}`}
</p>
</div>
);
}Empty, error and disabled
Without a min, stepping up from empty starts at 0. An error marks the input invalid and is read with it.
Ports below 1024 need root.
Code
import { NumberField } from "@/components/equip/number-field";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, alignItems: "start" }}>
<NumberField label="Timeout (s)" placeholder="30" />
<NumberField label="Port" defaultValue={80} min={1} max={65535} error="Ports below 1024 need root." />
<NumberField label="Workers" defaultValue={4} disabled />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label, tied to the input. |
hint | ReactNode | none | Help text under the control, linked with aria-describedby. |
error | ReactNode | none | Error text under the control. Marks the input invalid and is announced with it. |
value | number | null | none | Controlled value. null is an empty field. |
defaultValue | number | null | null | Uncontrolled starting value. |
onValueChange | (value: number | null) => void | none | Called after a step, or when typed text is committed on Enter or blur. |
min | number | none | Lowest value. Also enables Home. |
max | number | none | Highest value. Also enables End. |
step | number | 1 | Change per arrow key or button press. Its decimals set the rounding. |
...rest | ComponentProps<"input"> | none | Every native input attribute, including name, placeholder, disabled and ref. className and style go to the wrapper. |
Anatomy
| root | .eq-number-field | Wrapper that carries className and style. |
|---|---|---|
| label | .eq-number-field-label | |
| control | .eq-number-field-control | The bordered box holding the buttons and the input. |
| input | .eq-number-field-input | A text input with role spinbutton. |
| button | .eq-number-field-button | Decrease and increase; out of the tab order. |
| hint | .eq-number-field-hint | |
| error | .eq-number-field-error |
Keyboard
| ArrowUp | Increases the value by one step. |
|---|---|
| ArrowDown | Decreases the value by one step. |
| Shift+ArrowUpShift+ArrowDownPageUpPageDown | Changes the value by ten steps. |
| Home | Sets the minimum, when there is one. |
| End | Sets the maximum, when there is one. |
| Enter | Commits typed text, clamped to the bounds, then lets the form submit. |
Notes
- The input is type text with role spinbutton and inputmode, which avoids the native number input's scroll-wheel changes and loose parsing.
- Typed text is committed on Enter or blur; text that is not a number is dropped and the last value returns.
- The step buttons are out of the tab order, as the spinbutton pattern asks, and keep focus in the input when clicked.
- Shift with a click on a step button also moves ten steps.
- Values are clamped to min and max and rounded to the step's decimals, so 0.1 plus 0.2 shows 0.3.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/number-field.tsx243 lines
"use client";
import { useId, useState } from "react";
import type { ChangeEvent, ComponentProps, FocusEvent, KeyboardEvent, MouseEvent, ReactNode } from "react";
import "./number-field.css";
export type NumberFieldProps = Omit<
ComponentProps<"input">,
"type" | "value" | "defaultValue" | "min" | "max" | "step"
> & {
/** Visible label. */
label: ReactNode;
/** Help text under the control, linked with aria-describedby. */
hint?: ReactNode;
/** Error text. When set, the input is marked invalid and the text is announced with it. */
error?: ReactNode;
/** Controlled value. `null` is an empty field. */
value?: number | null;
/** Uncontrolled starting value. */
defaultValue?: number | null;
/** Called with the new value after a step or when typed text is committed. */
onValueChange?: (value: number | null) => void;
min?: number;
max?: number;
/** Amount one press of an arrow or a button changes the value by. */
step?: number;
};
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
function decimals(n: number) {
const text = String(n);
const dot = text.indexOf(".");
return dot === -1 ? 0 : text.length - dot - 1;
}
function parse(text: string): number | null | undefined {
const trimmed = text.trim().replace(/−/g, "-");
if (trimmed === "") return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : undefined;
}
export function NumberField({
label,
hint,
error,
value,
defaultValue = null,
onValueChange,
min,
max,
step = 1,
className,
style,
id,
disabled,
readOnly,
onChange,
onKeyDown,
onBlur,
"aria-describedby": describedBy,
"aria-invalid": ariaInvalid,
...rest
}: NumberFieldProps) {
const autoId = useId();
const inputId = id ?? autoId;
const hintId = `${autoId}-hint`;
const errorId = `${autoId}-error`;
const [inner, setInner] = useState<number | null>(defaultValue);
/** Text being typed; null when the field shows the committed value. */
const [draft, setDraft] = useState<string | null>(null);
const current = value !== undefined ? value : inner;
const precision = Math.max(decimals(step), min !== undefined ? decimals(min) : 0);
const locked = disabled || readOnly;
function clamp(n: number) {
let next = n;
if (min !== undefined) next = Math.max(min, next);
if (max !== undefined) next = Math.min(max, next);
return Number(next.toFixed(precision));
}
function commit(next: number | null) {
setDraft(null);
if (next === current) return;
if (value === undefined) setInner(next);
onValueChange?.(next);
}
/** The value stepping starts from: typed text if it parses, else the committed value. */
function base() {
const typed = draft === null ? undefined : parse(draft);
return typed === undefined ? current : typed;
}
function stepBy(count: number) {
const from = base();
if (from === null) {
commit(clamp(count > 0 ? (min ?? 0) : (max ?? 0)));
return;
}
commit(clamp(from + count * step));
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
onChange?.(event);
setDraft(event.currentTarget.value);
}
function handleBlur(event: FocusEvent<HTMLInputElement>) {
onBlur?.(event);
if (draft === null) return;
const typed = parse(draft);
// Text that is not a number is dropped and the last good value comes back.
commit(typed === undefined ? current : typed === null ? null : clamp(typed));
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || locked) return;
const big = event.shiftKey ? 10 : 1;
switch (event.key) {
case "ArrowUp":
stepBy(big);
break;
case "ArrowDown":
stepBy(-big);
break;
case "PageUp":
stepBy(10);
break;
case "PageDown":
stepBy(-10);
break;
case "Home":
if (min === undefined) return;
commit(min);
break;
case "End":
if (max === undefined) return;
commit(max);
break;
case "Enter": {
if (draft === null) return;
const typed = parse(draft);
commit(typed === undefined ? current : typed === null ? null : clamp(typed));
// Let Enter go on to submit a surrounding form.
return;
}
default:
return;
}
event.preventDefault();
}
function handleButton(direction: 1 | -1) {
return (event: MouseEvent<HTMLButtonElement>) => stepBy(direction * (event.shiftKey ? 10 : 1));
}
/** Keeps focus where it is, so a button that disables itself at a bound never strands it. */
function keepFocus(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
const text = draft ?? (current === null ? "" : String(current));
const atMin = current !== null && min !== undefined && current <= min;
const atMax = current !== null && max !== undefined && current >= max;
return (
<div className={["eq-number-field", className].filter(Boolean).join(" ")} style={style}>
<label className="eq-number-field-label" htmlFor={inputId}>
{label}
</label>
<div className="eq-number-field-control" data-disabled={disabled || undefined}>
<button
type="button"
className="eq-number-field-button"
tabIndex={-1}
aria-label="Decrease"
aria-controls={inputId}
disabled={locked || atMin}
onMouseDown={keepFocus}
onClick={handleButton(-1)}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M3.5 8h9" />
</svg>
</button>
<input
inputMode={precision > 0 || (min !== undefined && min < 0) ? "decimal" : "numeric"}
autoComplete="off"
{...rest}
id={inputId}
className="eq-number-field-input"
type="text"
role="spinbutton"
aria-valuenow={current ?? undefined}
aria-valuemin={min}
aria-valuemax={max}
aria-describedby={
[describedBy, present(hint) ? hintId : null, present(error) ? errorId : null].filter(Boolean).join(" ") ||
undefined
}
aria-invalid={present(error) ? true : ariaInvalid}
disabled={disabled}
readOnly={readOnly}
value={text}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
<button
type="button"
className="eq-number-field-button"
tabIndex={-1}
aria-label="Increase"
aria-controls={inputId}
disabled={locked || atMax}
onMouseDown={keepFocus}
onClick={handleButton(1)}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M3.5 8h9M8 3.5v9" />
</svg>
</button>
</div>
{present(hint) && (
<p className="eq-number-field-hint" id={hintId}>
{hint}
</p>
)}
{present(error) && (
<p className="eq-number-field-error" id={errorId}>
{error}
</p>
)}
</div>
);
}components/equip/number-field.css145 lines
@layer equip {
.eq-number-field {
display: grid;
justify-items: start;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-number-field-label {
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-number-field-hint,
.eq-number-field-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-number-field-hint {
color: var(--eq-ink-2);
}
.eq-number-field-error {
color: var(--eq-danger);
}
/* The bordered box is the control the eye sees; the input inside is bare. */
.eq-number-field-control {
box-sizing: border-box;
display: flex;
align-items: stretch;
width: calc(var(--eq-h-m) * 4);
max-width: 100%;
height: var(--eq-h-m);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
transition: border-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
/* :where keeps this below the invalid rule, so a red border stays red on hover. */
.eq-number-field-control:hover:where(:not([data-disabled])) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-number-field-control:has(.eq-number-field-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-number-field-control:has(.eq-number-field-input:user-invalid, .eq-number-field-input[aria-invalid="true"]) {
border-color: var(--eq-danger);
}
.eq-number-field-input {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 0 var(--eq-space-1);
border: 0;
background: transparent;
color: inherit;
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
text-align: center;
}
.eq-number-field-input:focus-visible {
outline: none;
}
.eq-number-field-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-number-field-button {
display: grid;
flex: none;
place-items: center;
width: calc(var(--eq-h-m) - 2px);
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease);
}
.eq-number-field-button:first-child {
border-inline-end: 1px solid var(--eq-line);
border-start-start-radius: calc(var(--eq-radius-m) - 1px);
border-end-start-radius: calc(var(--eq-radius-m) - 1px);
}
.eq-number-field-button:last-child {
border-inline-start: 1px solid var(--eq-line);
border-start-end-radius: calc(var(--eq-radius-m) - 1px);
border-end-end-radius: calc(var(--eq-radius-m) - 1px);
}
.eq-number-field-button > svg {
width: 16px;
height: 16px;
transition: scale var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-number-field-button:hover:not(:disabled) {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
/* The stepper is part of the box, so its glyph gives rather than the whole button. */
.eq-number-field-button:active:not(:disabled) {
background: var(--eq-tint-strong);
}
.eq-number-field-button:active:not(:disabled) > svg {
scale: calc(var(--eq-press) - 0.04);
}
/* A button at its bound fades on its own; a disabled field fades once, as a whole. */
.eq-number-field-control:not([data-disabled]) .eq-number-field-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-number-field-control[data-disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-number-field-control[data-disabled] > * {
cursor: not-allowed;
}
}