Improve count logging with direct editable totals
This commit is contained in:
103
src/components/design-system/Counter.test.tsx
Normal file
103
src/components/design-system/Counter.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useId } from "react";
|
import { useId, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
ButtonHTMLAttributes,
|
ButtonHTMLAttributes,
|
||||||
AnchorHTMLAttributes,
|
AnchorHTMLAttributes,
|
||||||
@@ -89,28 +89,60 @@ export function Counter({
|
|||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
disabled?: boolean;
|
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 (
|
return (
|
||||||
<div className="ds-counter" role="group" aria-label={label}>
|
<div className="ds-counter" role="group" aria-label={label}>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
aria-label={`Decrease ${label}`}
|
aria-label={`Decrease ${label}`}
|
||||||
disabled={disabled || value <= 0}
|
disabled={disabled || value <= 0}
|
||||||
onClick={() => onChange(Math.max(0, value - 1))}
|
onClick={() => { updateDraft(String(value)); onChange(Math.max(0, value - 1)); }}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</Button>
|
</Button>
|
||||||
<output aria-live="polite">
|
<input
|
||||||
<span>{value}</span>
|
className="ds-counter-input"
|
||||||
<span className="ds-muted"> / {target}</span>
|
type="text"
|
||||||
</output>
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
aria-label={`Increase ${label}`}
|
aria-label={`Increase ${label}`}
|
||||||
disabled={disabled || value >= target}
|
disabled={disabled || value >= maximum}
|
||||||
onClick={() => onChange(Math.min(target, value + 1))}
|
onClick={() => { updateDraft(String(value)); onChange(Math.min(maximum, value + 1)); }}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user