From 419bdce36b1bf136c1674bc1a21857ecce1e3d66 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 5 Sep 2026 07:51:25 +0200 Subject: [PATCH] Improve count logging with direct editable totals --- src/components/design-system/Counter.test.tsx | 103 ++++++++++++++++++ src/components/design-system/primitives.tsx | 48 ++++++-- 2 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 src/components/design-system/Counter.test.tsx diff --git a/src/components/design-system/Counter.test.tsx b/src/components/design-system/Counter.test.tsx new file mode 100644 index 0000000..3ce56e3 --- /dev/null +++ b/src/components/design-system/Counter.test.tsx @@ -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(); +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)[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("input")!; +const control = (name: string) => container.querySelector(`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 { updates.push(next); setValue(next); }} />; + } + await act(async () => root.render()); + 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( 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( {}} />); + 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); +}); diff --git a/src/components/design-system/primitives.tsx b/src/components/design-system/primitives.tsx index 65cbfcc..f026eaf 100644 --- a/src/components/design-system/primitives.tsx +++ b/src/components/design-system/primitives.tsx @@ -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 (
- - {value} - / {target} - + updateDraft(event.target.value)} + onKeyDown={event => { + if (event.key === "Enter") { event.preventDefault(); save(); } + if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); } + }} + /> + / {target} + {changed && } + {changed && !valid && Enter a whole number from 0 to 1,000,000,000.}
); }