diff --git a/packages/shell/src/renderer/ui/text-field.test.tsx b/packages/shell/src/renderer/ui/text-field.test.tsx new file mode 100644 index 0000000..79e2238 --- /dev/null +++ b/packages/shell/src/renderer/ui/text-field.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +/** + * `` numeric bounds — `min`/`max`/`step` forward to the underlying + * `` so a constrained numeric field ("keep the last N days") can ride the + * shared face instead of hand-rolling `` just to get bounds. + * They're omitted entirely when unset (no `min=""` polluting the DOM). + */ + +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { TextField } from "./text-field"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("TextField numeric bounds", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + const input = () => { + const el = host.querySelector("input"); + if (!el) throw new Error("no input"); + return el; + }; + + it("forwards min/max/step to the input", () => { + act(() => + root.render( + {}} + min={1} + max={3650} + step={1} + aria-label="days" + />, + ), + ); + const el = input(); + expect(el.getAttribute("min")).toBe("1"); + expect(el.getAttribute("max")).toBe("3650"); + expect(el.getAttribute("step")).toBe("1"); + expect(el.type).toBe("number"); + }); + + it("omits the bounds attributes entirely when unset", () => { + act(() => root.render( {}} aria-label="name" />)); + const el = input(); + expect(el.hasAttribute("min")).toBe(false); + expect(el.hasAttribute("max")).toBe(false); + expect(el.hasAttribute("step")).toBe(false); + }); +}); diff --git a/packages/shell/src/renderer/ui/text-field.tsx b/packages/shell/src/renderer/ui/text-field.tsx index edead48..349f234 100644 --- a/packages/shell/src/renderer/ui/text-field.tsx +++ b/packages/shell/src/renderer/ui/text-field.tsx @@ -59,6 +59,13 @@ export type TextFieldProps = CommonProps & { readonly value: string; readonly onChange: (next: string) => void; readonly type?: "text" | "email" | "search" | "url" | "tel" | "password" | "number"; + /** Numeric bounds/step, forwarded to the underlying ``. Only + * meaningful with `type="number"` — they let a constrained numeric field + * (e.g. "keep the last N days") ride the shared face instead of dropping to + * a hand-rolled `` just to get `min`/`max`. */ + readonly min?: number; + readonly max?: number; + readonly step?: number; readonly placeholder?: string; readonly maxLength?: number; readonly autoFocus?: boolean; @@ -88,6 +95,9 @@ export const TextField = forwardRef(function T value, onChange, type = "text", + min, + max, + step, placeholder, maxLength, autoFocus, @@ -141,6 +151,9 @@ export const TextField = forwardRef(function T {...(onKeyDown ? { onKeyDown } : {})} {...(placeholder !== undefined ? { placeholder } : {})} {...(maxLength !== undefined ? { maxLength } : {})} + {...(min !== undefined ? { min } : {})} + {...(max !== undefined ? { max } : {})} + {...(step !== undefined ? { step } : {})} {...(autoComplete !== undefined ? { autoComplete } : {})} {...(inputMode !== undefined ? { inputMode } : {})} {...(name !== undefined ? { name } : {})}