fix(labs): let a unit typed key by key pass through "1"

The no-unit state was read from the value, so the first keystroke of
10^9/L or 1/min ticked the box and locked the field. Only the box makes
the choice now; a typed 1 stays text.
This commit is contained in:
Marc-André Bombeck committed 2026-11-02 20:52:17 +01:00
1 parent 70cc753c49
commit 6a8ac98613
2 files changed
+68 -7

No files matched your search

@@ -0,0 +1,56 @@
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { userEvent } from "vitest/browser";
import { renderWithApp } from "../../../../tests/browser/render";
import { LabUnitInput } from "../lab-unit-input";
/**
* The unit field as a form holds it, typed into key by key. A unit that
* starts with a 1 (10^9/L, 1/min) passes through "1" on the way, and that
* keystroke must not turn into the "no unit" choice.
*/
function Controlled({ initial = "" }: { initial?: string }) {
const [unit, setUnit] = useState(initial);
return (
<>
<label htmlFor="u">Unit</label>
<LabUnitInput id="u" value={unit} onChange={setUnit} />
<output data-testid="unit">{unit}</output>
</>
);
}
describe("<LabUnitInput> typed into", () => {
it("keeps a unit that starts with a 1", async () => {
const { screen } = await renderWithApp(<Controlled />);
await userEvent.type(screen.getByLabelText("Unit"), "10^9/L");
await expect
.element(screen.getByTestId("unit"))
.toHaveTextContent("10^9/L");
await expect
.element(screen.getByRole("checkbox", { name: /no unit/i }))
.not.toBeChecked();
});
it("shows a stored unity as the ticked choice and gives the field back on unticking", async () => {
const { screen } = await renderWithApp(<Controlled initial="1" />);
const box = screen.getByRole("checkbox", { name: /no unit/i });
await expect.element(box).toBeChecked();
await expect.element(screen.getByLabelText("Unit")).toBeDisabled();
await box.click();
await expect.element(screen.getByLabelText("Unit")).toBeEnabled();
await userEvent.type(screen.getByLabelText("Unit"), "mmol/L");
await expect
.element(screen.getByTestId("unit"))
.toHaveTextContent("mmol/L");
});
it("sets the unity when the box is ticked", async () => {
const { screen } = await renderWithApp(<Controlled initial="mg/dL" />);
await screen.getByRole("checkbox", { name: /no unit/i }).click();
await expect
.poll(() => screen.getByTestId("unit").element().textContent)
.toBe("1");
});
});
+12 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useId, useRef } from "react";
import { useId, useRef, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -48,8 +48,9 @@ interface LabUnitInputProps {
* The unit of a lab value, with an explicit "no unit" choice for a ratio or an
* index. Ticking it sets the unit to the UCUM unity `"1"`, which every write
* path accepts as a real unit; leaving the field empty is still refused, so a
* unit an extraction dropped is never taken for a value that has none. Typing
* `1` by hand is the same choice and ticks the box.
* unit an extraction dropped is never taken for a value that has none. Only
* the box makes the choice: a unit typed key by key passes through "1" on its
* way to `10^9/L`, and that keystroke stays text.
*/
export function LabUnitInput({
id,
@@ -64,7 +65,9 @@ export function LabUnitInput({
const { t } = useTranslations();
const noneId = useId();
const hintId = `${noneId}-hint`;
const none = allowNoUnit && isDimensionlessUnit(value);
// Set when a keystroke, not the box, left the field at "1".
const [typedOne, setTypedOne] = useState(false);
const none = allowNoUnit && isDimensionlessUnit(value) && !typedOne;
// What was typed before the box was ticked, so unticking gives it back.
const typed = useRef(none ? "" : value);
@@ -74,6 +77,7 @@ export function LabUnitInput({
value={none ? "" : value}
onChange={(e) => {
typed.current = e.target.value;
setTypedOne(isDimensionlessUnit(e.target.value));
onChange(e.target.value);
}}
placeholder={none ? t("labs.unit.none") : placeholder}
@@ -95,15 +99,16 @@ export function LabUnitInput({
<Checkbox
id={noneId}
checked={none}
onCheckedChange={(state) =>
onCheckedChange={(state) => {
setTypedOne(false);
onChange(
state === true
? DIMENSIONLESS_UNIT
: isDimensionlessUnit(typed.current)
? ""
: typed.current,
)
}
);
}}
aria-describedby={hintId}
className="mt-0.5"
/>