Improve count logging with direct editable totals

This commit is contained in:
syntaxbullet
2026-09-05 07:51:25 +02:00
parent 9f8154e412
commit 419bdce36b
2 changed files with 143 additions and 8 deletions

View File

@@ -0,0 +1,103 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { Counter } from "./primitives";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
const input = () => container.querySelector<HTMLInputElement>("input")!;
const control = (name: string) => container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`)!;
async function enter(text: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input(), text);
input().dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
}
async function key(key: string) {
await act(async () => input().dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent));
}
test("totals save explicitly, support above-target values and retain step controls", async () => {
const updates: number[] = [];
function Demo() {
const [value, setValue] = useState(8);
return <Counter label="pages" value={value} target={8} onChange={next => { updates.push(next); setValue(next); }} />;
}
await act(async () => root.render(<Demo />));
await enter("30");
expect(updates).toEqual([]);
await key("Enter");
expect(updates).toEqual([30]);
expect(input().value).toBe("30");
expect(control("Save total pages")).toBeNull();
await act(async () => control("Increase pages").click());
expect(input().value).toBe("31");
await enter("40");
await act(async () => control("Save total pages").click());
expect(updates).toEqual([30, 31, 40]);
});
test("invalid totals never submit and Escape restores the saved value", async () => {
const updates: number[] = [];
await act(async () => root.render(<Counter label="pages" value={3} target={8} onChange={next => updates.push(next)} />));
for (const invalid of ["", "-1", "1.5", "abc", "1000000001"]) {
await enter(invalid);
expect(input().getAttribute("aria-invalid")).toBe("true");
expect(control("Save total pages").disabled).toBe(true);
await key("Enter");
}
expect(updates).toEqual([]);
await key("Escape");
expect(input().value).toBe("3");
expect(container.querySelector('[role="alert"]')).toBeNull();
});
test("server refresh replaces a draft and API bounds disable increment and decrement", async () => {
const render = (value: number, disabled = false) => root.render(<Counter label="pages" value={value} target={8} disabled={disabled} onChange={() => {}} />);
await act(async () => render(0));
expect(control("Decrease pages").disabled).toBe(true);
await enter("12");
await act(async () => render(1_000_000_000));
expect(input().value).toBe("1000000000");
expect(control("Increase pages").disabled).toBe(true);
await act(async () => render(2, true));
expect(input().disabled).toBe(true);
expect(control("Decrease pages").disabled).toBe(true);
});

View File

@@ -1,4 +1,4 @@
import { useId } from "react";
import { useId, useState } from "react";
import type {
ButtonHTMLAttributes,
AnchorHTMLAttributes,
@@ -89,28 +89,60 @@ export function Counter({
onChange: (value: number) => void;
disabled?: boolean;
}) {
const hintId = useId();
const [edit, setEdit] = useState({ source: value, label, text: String(value) });
// A changed day or refreshed server total replaces an unsubmitted draft.
const refreshed = edit.source !== value || edit.label !== label;
if (refreshed) setEdit({ source: value, label, text: String(value) });
const draft = refreshed ? String(value) : edit.text;
const changed = draft !== String(value);
const maximum = 1_000_000_000;
const valid = /^\d+$/.test(draft) && Number(draft) <= maximum;
const updateDraft = (text: string) => setEdit({ source: value, label, text });
const save = () => {
if (!disabled && changed && valid) {
updateDraft(String(Number(draft)));
onChange(Number(draft));
}
};
return (
<div className="ds-counter" role="group" aria-label={label}>
<Button
variant="text"
aria-label={`Decrease ${label}`}
disabled={disabled || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
onClick={() => { updateDraft(String(value)); onChange(Math.max(0, value - 1)); }}
>
</Button>
<output aria-live="polite">
<span>{value}</span>
<input
className="ds-counter-input"
type="text"
inputMode="numeric"
aria-label={`Total ${label}`}
aria-describedby={changed && !valid ? hintId : undefined}
aria-invalid={changed && !valid ? true : undefined}
title="Enter a total and press Enter to save. Escape cancels."
value={draft}
disabled={disabled}
onChange={event => updateDraft(event.target.value)}
onKeyDown={event => {
if (event.key === "Enter") { event.preventDefault(); save(); }
if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); }
}}
/>
<span className="ds-muted"> / {target}</span>
</output>
<Button
variant="text"
aria-label={`Increase ${label}`}
disabled={disabled || value >= target}
onClick={() => onChange(Math.min(target, value + 1))}
disabled={disabled || value >= maximum}
onClick={() => { updateDraft(String(value)); onChange(Math.min(maximum, value + 1)); }}
>
+
</Button>
{changed && <Button className="ds-counter-save" variant="secondary" disabled={disabled || !valid} onClick={save} aria-label={`Save total ${label}`}>Save</Button>}
{changed && !valid && <span className="ds-counter-error" id={hintId} role="alert">Enter a whole number from 0 to 1,000,000,000.</span>}
</div>
);
}