DesignX
Core@paxeer/core/numeric-stepperimplemented

Numeric stepper

Increment/decrement field composed from Button + Input.

Install

$pnpm exec designx add @paxeer/core/numeric-stepper

Preview

Loading preview...

Source code

components/designx/core/numeric-stepper.tsxtsx
"use client";

import { type ComponentProps, useId } from "react";
import { cn } from "../lib/cn";
import { Button } from "./button";
import { Input } from "./input";

export interface NumericStepperProps
  extends Omit<ComponentProps<typeof Input>, "onChange" | "type" | "value"> {
  value: number;
  onValueChange: (value: number) => void;
  step?: number;
  min?: number;
  max?: number;
  label?: string;
}

export function NumericStepper({
  value,
  onValueChange,
  step = 1,
  min,
  max,
  label,
  className,
  id,
  disabled,
  ...props
}: NumericStepperProps) {
  const generatedId = useId();
  const inputId = id ?? generatedId;

  function clamp(next: number) {
    let result = next;
    if (typeof min === "number") result = Math.max(min, result);
    if (typeof max === "number") result = Math.min(max, result);
    return Number(Number(result).toPrecision(12));
  }

  function bump(direction: 1 | -1) {
    onValueChange(clamp(value + direction * step));
  }

  return (
    <div className={cn("inline-flex flex-col gap-1", className)}>
      {label ? (
        <label
          htmlFor={inputId}
          className="text-[length:var(--md-sys-typescale-label-medium-size)] text-on-surface-variant"
        >
          {label}
        </label>
      ) : null}
      <div className="inline-flex overflow-hidden rounded-[var(--md-sys-shape-corner-small)] border border-outline-variant">
        <Button
          variant="ghost"
          size="dense"
          aria-label="Decrease"
          disabled={disabled || (typeof min === "number" && value <= min)}
          className="rounded-none"
          onClick={() => bump(-1)}
        >
          −
        </Button>
        <Input
          {...props}
          id={inputId}
          type="number"
          inputMode="decimal"
          disabled={disabled}
          step={step}
          min={min}
          max={max}
          value={Number.isFinite(value) ? value : ""}
          onChange={(event) => {
            const next = Number(event.target.value);
            if (Number.isNaN(next)) return;
            onValueChange(clamp(next));
          }}
          className="h-10 w-20 rounded-none border-y-0 border-x-outline-variant text-center font-[family-name:var(--md-ref-typeface-mono)]"
        />
        <Button
          variant="ghost"
          size="dense"
          aria-label="Increase"
          disabled={disabled || (typeof max === "number" && value >= max)}
          className="rounded-none"
          onClick={() => bump(1)}
        >
          +
        </Button>
      </div>
    </div>
  );
}

Dependencies