COMPONENTS/INPUTS

Number field.

Precision, one step at a time.

In practice

Set min, max and step on NumberField. Label the input and increment/decrement buttons.

Installation

Install Coal once. Components and TypeScript declarations are included. React 19 and React DOM 19 are peer dependencies.

Terminal · local package
npm install http://localhost:3100/downloads/chlohal-coal-ui-0.5.0.tgz

This installs the local package. It is not yet published on npm. See installation for stylesheet setup and integration.

Usage

example.tsx
"use client";
import * as React from "react";
import "@chlohal/coal-ui/styles.css";
import { Label, NumberField, NumberFieldGroup, NumberFieldInput, NumberFieldIncrement, NumberFieldDecrement } from "@chlohal/coal-ui";
export default function Example() {
    const id = React.useId();
    return (<NumberField defaultValue={3} min={1} max={12}>
          <Label htmlFor={id}>Team members</Label>
          <NumberFieldGroup>
            <NumberFieldDecrement aria-label="Remove member">
              −
            </NumberFieldDecrement>
            <NumberFieldInput id={id}/>
            <NumberFieldIncrement aria-label="Add member">
              +
            </NumberFieldIncrement>
          </NumberFieldGroup>
        </NumberField>);
}

Source

The original Coal implementation. Use the package export to integrate it.

packages/react/src/number-field.tsx
"use client";
import * as React from "react";
import {
  cn,
  useValue,
  useRequired,
  Action,
  type ActionProps,
} from "./internal.js";
const Context = React.createContext<
  | {
      value: number;
      set: (v: number) => void;
      min?: number;
      max?: number;
      step: number;
      disabled?: boolean;
      name?: string;
    }
  | undefined
>(undefined);
export type NumberFieldProps = Omit<
  React.ComponentPropsWithRef<"div">,
  "defaultValue"
> & {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  disabled?: boolean;
  name?: string;
};
export function NumberField({
  value,
  defaultValue = 0,
  onValueChange,
  min,
  max,
  step = 1,
  disabled,
  name,
  children,
  className,
  ...props
}: NumberFieldProps) {
  const [v, set] = useValue(value, defaultValue, onValueChange);
  return (
    <Context.Provider
      value={{
        value: v,
        set: (n) =>
          set(Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n))),
        min,
        max,
        step,
        disabled,
        name,
      }}
    >
      <div className={cn("coal-field", className)} {...props}>
        {children}
      </div>
    </Context.Provider>
  );
}
export function NumberFieldGroup({
  className,
  ...props
}: React.ComponentPropsWithRef<"div">) {
  return <div className={cn("coal-number-group", className)} {...props} />;
}
export function NumberFieldInput({
  onChange,
  onBlur,
  className,
  ...props
}: React.ComponentPropsWithRef<"input">) {
  const c = useRequired(Context, "NumberFieldInput");
  const [draft, setDraft] = React.useState(String(c.value));
  React.useEffect(() => setDraft(String(c.value)), [c.value]);
  return (
    <input
      type="number"
      value={draft}
      min={c.min}
      max={c.max}
      step={c.step}
      name={c.name}
      disabled={c.disabled}
      className={cn("coal-number-input", className)}
      {...props}
      onBlur={(e) => {
        onBlur?.(e);
        setDraft(String(c.value));
      }}
      onChange={(e) => {
        onChange?.(e);
        if (!e.defaultPrevented) setDraft(e.target.value);
        if (!e.defaultPrevented && Number.isFinite(e.target.valueAsNumber))
          c.set(e.target.valueAsNumber);
      }}
    />
  );
}
function StepButton({
  direction,
  onClick,
  className,
  ...props
}: ActionProps & { direction: number }) {
  const c = useRequired(Context, "NumberField step");
  return (
    <Action
      className={cn("coal-number-step", className)}
      disabled={
        c.disabled ||
        (direction > 0
          ? c.value >= (c.max ?? Infinity)
          : c.value <= (c.min ?? -Infinity))
      }
      {...props}
      onClick={(e) => {
        onClick?.(e);
        if (!e.defaultPrevented)
          c.set(Number((c.value + direction * c.step).toFixed(12)));
      }}
    />
  );
}
export function NumberFieldIncrement(props: ActionProps) {
  return <StepButton direction={1} {...props} />;
}
export function NumberFieldDecrement(props: ActionProps) {
  return <StepButton direction={-1} {...props} />;
}

Accessibility checklist

  • Provide an accessible name for controls, including icon-only buttons.
  • Check keyboard navigation, visible focus and disabled states in your application.
  • Do not rely on color alone to communicate status.