fix: clamp custom style numeric inputs (#3262)

This commit is contained in:
落尘
2026-07-27 13:13:48 +02:00
committed by GitHub
parent d4cf260aed
commit 2a0782517c
2 changed files with 140 additions and 11 deletions
@@ -269,6 +269,94 @@ describe("CustomStylesSectionBuilder", () => {
]);
});
it("clamps manually typed style values to the schema bounds", () => {
styleRules.splice(0, styleRules.length);
renderCustomStyles();
fireEvent.change(screen.getByLabelText("Margin Top"), { target: { value: "100" } });
fireEvent.change(screen.getByLabelText("Font Size"), { target: { value: "2" } });
expect(updateResumeData).toHaveBeenCalledTimes(2);
const marginRecipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
metadata: { styleRules: unknown[] };
}) => void;
const marginDraft = { metadata: { styleRules: [] } };
marginRecipe(marginDraft);
expect(marginDraft.metadata.styleRules).toEqual([
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { marginTop: 72 } },
},
]);
const fontSizeRecipe = updateResumeData.mock.calls[1]?.[0] as (draft: {
metadata: { styleRules: unknown[] };
}) => void;
const fontSizeDraft = { metadata: { styleRules: [] } };
fontSizeRecipe(fontSizeDraft);
expect(fontSizeDraft.metadata.styleRules).toEqual([
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { fontSize: 6 } },
},
]);
});
it("keeps intermediate numeric text while the input is focused", () => {
styleRules.splice(0, styleRules.length);
renderCustomStyles();
const fontSizeInput = screen.getByLabelText("Font Size");
fireEvent.focus(fontSizeInput);
fireEvent.change(fontSizeInput, { target: { value: "1" } });
expect(fontSizeInput).toHaveValue(1);
fireEvent.change(fontSizeInput, { target: { value: "12" } });
expect(fontSizeInput).toHaveValue(12);
fireEvent.blur(fontSizeInput);
expect(fontSizeInput).toHaveValue(12);
expect(updateResumeData).toHaveBeenCalledTimes(2);
});
it("commits normalized legacy values when the input loses focus", () => {
styleRules[0] = {
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { borderWidth: 100 } },
};
renderCustomStyles();
const borderWidthInput = screen.getByLabelText("Border Width");
expect(borderWidthInput).toHaveValue(100);
fireEvent.focus(borderWidthInput);
fireEvent.blur(borderWidthInput);
expect(borderWidthInput).toHaveValue(24);
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
metadata: { styleRules: StyleRule[] };
}) => void;
const draft = { metadata: { styleRules: structuredClone(styleRules) } };
recipe(draft);
expect(draft.metadata.styleRules[0]?.slots.heading?.borderWidth).toBe(24);
});
it("stores list slot rules for rich text lists", async () => {
styleRules.splice(0, styleRules.length);
renderCustomStyles();
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
import type { ComboboxOption } from "@/components/ui/combobox";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
@@ -329,23 +329,63 @@ type NumberInputProps = {
onChange: (value: number | undefined) => void;
};
function parseBoundedNumberInput(value: string, min: number, max: number): number | undefined {
if (value === "") return undefined;
const number = Number(value);
if (!Number.isFinite(number)) return undefined;
return Math.min(max, Math.max(min, number));
}
function useBoundedNumberInput(
value: number | undefined,
min: number,
max: number,
onChange: (value: number | undefined) => void,
) {
const [inputValue, setInputValue] = useState(value?.toString() ?? "");
const isFocused = useRef(false);
useEffect(() => {
if (!isFocused.current) setInputValue(value?.toString() ?? "");
}, [value]);
return {
inputValue,
onFocus: () => {
isFocused.current = true;
},
onBlur: () => {
isFocused.current = false;
const normalizedValue = parseBoundedNumberInput(inputValue, min, max);
setInputValue(normalizedValue?.toString() ?? "");
if (normalizedValue !== value) onChange(normalizedValue);
},
onInputChange: (nextValue: string) => {
setInputValue(nextValue);
onChange(parseBoundedNumberInput(nextValue, min, max));
},
};
}
function NumberInput({ label, id, value, min, max, step = 1, onChange }: NumberInputProps) {
const inputId = id ?? `style-${label.toLowerCase().replaceAll(" ", "-")}`;
const boundedInput = useBoundedNumberInput(value, min, max, onChange);
return (
<Field label={label} id={inputId}>
<Input
id={inputId}
className="tabular-nums"
value={value ?? ""}
value={boundedInput.inputValue}
type="number"
min={min}
max={max}
step={step}
onChange={(event) => {
const value = event.target.value;
onChange(value === "" ? undefined : Number(value));
}}
onFocus={boundedInput.onFocus}
onBlur={boundedInput.onBlur}
onChange={(event) => boundedInput.onInputChange(event.target.value)}
/>
</Field>
);
@@ -833,21 +873,22 @@ function CompactNumberInput({
step = 1,
onChange,
}: CompactNumberInputProps) {
const boundedInput = useBoundedNumberInput(value, min, max, onChange);
return (
<Input
id={id}
aria-label={ariaLabel}
className={compactSpacingInputClassName}
value={value ?? ""}
value={boundedInput.inputValue}
placeholder={placeholder}
type="number"
min={min}
max={max}
step={step}
onChange={(event) => {
const value = event.target.value;
onChange(value === "" ? undefined : Number(value));
}}
onFocus={boundedInput.onFocus}
onBlur={boundedInput.onBlur}
onChange={(event) => boundedInput.onInputChange(event.target.value)}
/>
);
}