Skip to content
All components

Dose Input

Stable

DosagePrimitive · Clinical

One of the highest-consequence inputs in healthcare software, and a free-text number field is not an acceptable control for it. Three defences in order of harm prevented: ISMP formatting rules, because “1.0 mg” read past the decimal point is 10 mg; plausibility warnings kept separate from hard maximums, because a dose can be unusual and correct and conflating the two teaches prescribers to click through both; and weight-based calculation that keeps its inputs on screen, because a calculator returning a bare number invites use with a stale weight.

pnpm dlx shadcn@latest add @oxygenui/dose-input

First install? Add "@oxygenui": "https://oxygenui.design/r/{name}.json" to the registries block of your components.json first — or skip the config and pass https://oxygenui.design/r/dose-input.json directly.

Preview

Every state, switchable.

Live · synthetic data

Trailing zero — write 1 mg, not 1.0 mg. A missed decimal point reads as a tenfold overdose.

Type 1.0 or .5 — both are flagged with the corrected form offered, never applied silently.

Well-formed doseTrailing zeroMissing leading zeroAbove plausible rangeAbove absolute maximumWeight-based calculationNo weight on file

Usage

Props are the FHIR resource.

import { DoseInput } from "@/components/oxygen/dose-input";

<DoseInput
  value={dose}
  onChange={setDose}
  units={["mg", "mL"]}
  plausibleMax={40}
  absoluteMax={80}
  dosePerKg={0.5}
  weightKg={patientWeightKg}
/>

Dependencies

  • @oxygenui-design/fhir@^0.1.0
  • lucide-react
  • clsx
  • tailwind-merge
Dose Input props
PropTypeDefault
onChange

(value: string) => void
units

Units permitted for this drug and route. Never a free list.

string[]
value

string
absoluteMax

Documented hard maximum. Crossing this blocks, and the interface says what the maximum is rather than only refusing.

number | undefined
className

string | undefined
disabled

boolean | undefinedfalse
dosePerKg

Weight-based dosing. Omit the weight and no calculation is offered.

number | undefined
id

string | undefined"ox-dose"
label

string | undefined"Dose"
onUnitChange

((unit: string) => void) | undefined
plausibleMax

number | undefined
plausibleMin

Soft floor and ceiling. Crossing these warns with a stated reason and does NOT block — a dose can be unusual and correct.

number | undefined
unit

string | undefined
weightKg

number | undefined

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • Any dose, weight, rate, or volume entry.
  • With absoluteMax set from the drug reference, not from a guess.
  • With weightKg wired to a recorded weight and left undefined when there is none.

Guardrails

Don't

03
  • Silently rewriting a typed dose. The corrected form is offered, never applied.
  • Using plausibleMax as a hard stop \u2014 unusual doses are sometimes correct.
  • Substituting an average weight when none is recorded.

Quality

What was tested, and what is still missing.

Messages bound to the field
Every warning is associated through aria-describedby and announced on the input, not discovered at submit.
Blocking states are alerts
Crossing the absolute maximum uses role=alert and sets aria-invalid.
No spinner
A numeric keypad without increment controls, so a stray scroll cannot change a prescription.

Open gaps

Known limitations

03
  • Plausibility bounds are supplied by the caller \u2014 there is no built-in drug reference.
  • Unit lists are caller-supplied; the component does not know which units suit a formulation.
  • Override workflow for exceeding the maximum is yours to build; this blocks and explains.

Source

Exactly what lands in your repository.

Read directly from the published registry, so this can never drift from what the CLI installs.

Show source253 lines
"use client";

/**
 * DoseInput — numeric entry for doses, weights, rates, and volumes.
 *
 * This is one of the highest-consequence inputs in healthcare software, and a
 * free-text number field is not an acceptable control for it.
 *
 * Three defences, in order of how much harm they prevent:
 *
 *   1. ISMP formatting rules. "1.0 mg" read past the decimal point is 10 mg;
 *      ".5 mg" is 5 mg. Both patterns are flagged as you type, with the
 *      corrected form offered — not silently rewritten, because a silent
 *      rewrite of a dose is its own hazard.
 *   2. Plausibility, separated from abnormality. A dose can be unusual and
 *      correct. A soft warning states its reason and does not block; a hard
 *      maximum blocks and says what it is. Conflating the two is how
 *      prescribers learn to click through both.
 *   3. Weight-based calculation with its inputs shown. A calculator that
 *      returns a bare number invites use with a stale weight, so the arithmetic
 *      stays on screen and the result is never editable independently of it.
 *
 * Units come from the drug and route, not from a free list — "5 units" and
 * "5 mL" of insulin are different events.
 */

import * as React from "react";
import { Calculator, CircleAlert, TriangleAlert } from "lucide-react";
import {
  DOSE_ISSUE_LABEL,
  doseFormatIssues,
  safeDoseText,
  weightBasedDose,
  type DoseFormatIssue,
} from "@oxygenui-design/fhir";
import { cn } from "@/lib/utils";

export interface DoseInputProps {
  id?: string;
  label?: string;
  value: string;
  onChange: (value: string) => void;
  /** Units permitted for this drug and route. Never a free list. */
  units: string[];
  unit?: string;
  onUnitChange?: (unit: string) => void;
  /**
   * Soft floor and ceiling. Crossing these warns with a stated reason and
   * does NOT block — a dose can be unusual and correct.
   */
  plausibleMin?: number;
  plausibleMax?: number;
  /**
   * Documented hard maximum. Crossing this blocks, and the interface says
   * what the maximum is rather than only refusing.
   */
  absoluteMax?: number;
  /** Weight-based dosing. Omit the weight and no calculation is offered. */
  dosePerKg?: number;
  weightKg?: number;
  disabled?: boolean;
  className?: string;
}

/** Written out in full — Tailwind cannot see a class built from a variable. */
const FIELD_CLASS = {
  ok: "border-[var(--ox-border-strong)]",
  warn: "border-[var(--ox-status-high)] bg-[var(--ox-status-high-bg)]",
  block: "border-[var(--ox-status-critical)] bg-[var(--ox-status-critical-bg)]",
} as const;

export function DoseInput({
  id = "ox-dose",
  label = "Dose",
  value,
  onChange,
  units,
  unit,
  onUnitChange,
  plausibleMin,
  plausibleMax,
  absoluteMax,
  dosePerKg,
  weightKg,
  disabled = false,
  className,
}: DoseInputProps) {
  const issues: DoseFormatIssue[] = doseFormatIssues(value);
  const corrected = safeDoseText(value);
  const numeric = value.trim() === "" ? undefined : Number(value);
  const parsed = numeric !== undefined && !Number.isNaN(numeric) ? numeric : undefined;

  const overAbsolute = parsed !== undefined && absoluteMax !== undefined && parsed > absoluteMax;
  const overPlausible =
    parsed !== undefined && plausibleMax !== undefined && parsed > plausibleMax && !overAbsolute;
  const underPlausible =
    parsed !== undefined && plausibleMin !== undefined && parsed < plausibleMin;

  const calculation = weightBasedDose(dosePerKg, weightKg, absoluteMax);

  const tone = overAbsolute
    ? "block"
    : issues.length || overPlausible || underPlausible
      ? "warn"
      : "ok";

  // Every message is associated with the input, so a screen-reader user hears
  // the problem on the field rather than discovering it at submit.
  const messageIds = [
    issues.length ? `${id}-format` : undefined,
    overAbsolute ? `${id}-block` : undefined,
    overPlausible || underPlausible ? `${id}-plausible` : undefined,
    calculation ? `${id}-calc` : undefined,
  ].filter(Boolean) as string[];

  return (
    <div className={cn("flex flex-col gap-1.5", className)}>
      <label
        htmlFor={id}
        className="text-[length:var(--ox-text-2xs)] font-semibold uppercase tracking-wider text-[var(--ox-text-muted)]"
      >
        {label}
      </label>

      <div className="flex items-stretch gap-2">
        <input
          id={id}
          type="text"
          // A numeric keypad without the spinner: spinners on a dose field
          // let a stray scroll change a prescription.
          inputMode="decimal"
          autoComplete="off"
          disabled={disabled}
          value={value}
          onChange={(event) => onChange(event.target.value)}
          aria-invalid={overAbsolute || issues.includes("not-a-number")}
          aria-describedby={messageIds.length ? messageIds.join(" ") : undefined}
          className={cn(
            "w-28 rounded-[var(--ox-radius-sm)] border bg-[var(--ox-surface)] px-2 py-1.5",
            "font-[family-name:var(--ox-font-numeric)] text-[length:var(--ox-density-font)] tabular-nums",
            FIELD_CLASS[tone],
          )}
        />

        <label htmlFor={`${id}-unit`} className="sr-only">
          Unit
        </label>
        <select
          id={`${id}-unit`}
          disabled={disabled}
          value={unit ?? units[0] ?? ""}
          onChange={(event) => onUnitChange?.(event.target.value)}
          className="rounded-[var(--ox-radius-sm)] border border-[var(--ox-border-strong)] bg-[var(--ox-surface)] px-2 py-1.5 text-[length:var(--ox-density-font)]"
        >
          {units.map((item) => (
            <option key={item} value={item}>
              {item}
            </option>
          ))}
        </select>
      </div>

      {/* ISMP formatting. Offered, never applied silently. */}
      {issues.length > 0 && (
        <p
          id={`${id}-format`}
          className="flex items-start gap-1.5 text-[length:var(--ox-text-xs)] text-[var(--ox-status-high)]"
        >
          <CircleAlert aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
          <span>
            {issues.map((issue) => DOSE_ISSUE_LABEL[issue]).join(" ")}
            {corrected && corrected !== value && (
              <>
                {" "}
                <button
                  type="button"
                  onClick={() => onChange(corrected)}
                  className="font-semibold underline underline-offset-2"
                >
                  Use {corrected}
                </button>
              </>
            )}
          </span>
        </p>
      )}

      {/* Hard stop. States the maximum rather than only refusing. */}
      {overAbsolute && (
        <p
          id={`${id}-block`}
          role="alert"
          className="flex items-start gap-1.5 text-[length:var(--ox-text-xs)] font-semibold text-[var(--ox-status-critical)]"
        >
          <TriangleAlert aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
          Above the documented maximum of {absoluteMax} {unit ?? units[0]}. This cannot be
          prescribed without an override.
        </p>
      )}

      {/* Soft warning. Reason stated; does not block. */}
      {(overPlausible || underPlausible) && (
        <p
          id={`${id}-plausible`}
          className="flex items-start gap-1.5 text-[length:var(--ox-text-xs)] text-[var(--ox-status-high)]"
        >
          <CircleAlert aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
          {overPlausible
            ? `Unusually high for this drug and route (typical up to ${plausibleMax}). Confirm if intended.`
            : `Unusually low for this drug and route (typical from ${plausibleMin}). Confirm if intended.`}
        </p>
      )}

      {/* The arithmetic, not just the answer. */}
      {calculation && (
        <div
          id={`${id}-calc`}
          className="flex items-start gap-1.5 rounded-[var(--ox-radius-sm)] bg-[var(--ox-bg-subtle)] px-2 py-1.5 text-[length:var(--ox-text-xs)] text-[var(--ox-text-muted)]"
        >
          <Calculator aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
          <span>
            <span className="font-[family-name:var(--ox-font-numeric)] tabular-nums">
              {calculation.workings}
            </span>{" "}
            ={" "}
            <strong className="font-[family-name:var(--ox-font-numeric)] font-semibold tabular-nums text-[var(--ox-text)]">
              {calculation.total} {unit ?? units[0]}
            </strong>
            {calculation.cappedAt !== undefined && " — capped at the documented maximum"}
            <button
              type="button"
              onClick={() => onChange(String(calculation.total))}
              className="ml-2 font-semibold text-[var(--ox-accent)] underline underline-offset-2"
            >
              Use this
            </button>
          </span>
        </div>
      )}

      {/* Weight-based dosing was requested but there is no weight. Say so
          rather than substituting an average, which turns a paediatric dose
          into an adult one. */}
      {dosePerKg !== undefined && !calculation && (
        <p className="text-[length:var(--ox-text-xs)] text-[var(--ox-status-high)]">
          Weight-based dosing requires a recorded weight. No weight on file — record one before
          calculating.
        </p>
      )}
    </div>
  );
}