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

PropTypeDefaultWhat it does
labelReactNodenoneVisible label, tied to the input.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
errorReactNodenoneError text under the control. Marks the input invalid and is announced with it.
valuenumber | nullnoneControlled value. null is an empty field.
defaultValuenumber | nullnullUncontrolled starting value.
onValueChange(value: number | null) => voidnoneCalled after a step, or when typed text is committed on Enter or blur.
minnumbernoneLowest value. Also enables Home.
maxnumbernoneHighest value. Also enables End.
stepnumber1Change per arrow key or button press. Its decimals set the rounding.
...restComponentProps<"input">noneEvery native input attribute, including name, placeholder, disabled and ref. className and style go to the wrapper.

Anatomy

root.eq-number-fieldWrapper that carries className and style.
label.eq-number-field-label
control.eq-number-field-controlThe bordered box holding the buttons and the input.
input.eq-number-field-inputA text input with role spinbutton.
button.eq-number-field-buttonDecrease and increase; out of the tab order.
hint.eq-number-field-hint
error.eq-number-field-error

Keyboard

ArrowUpIncreases the value by one step.
ArrowDownDecreases the value by one step.
Shift+ArrowUpShift+ArrowDownPageUpPageDownChanges the value by ten steps.
HomeSets the minimum, when there is one.
EndSets the maximum, when there is one.
EnterCommits 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index