From 2a0782517c55266ea20d7ac749821f3a7a90896d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=BD=E5=B0=98?= Date: Mon, 27 Jul 2026 19:13:48 +0800 Subject: [PATCH] fix: clamp custom style numeric inputs (#3262) --- .../right/sections/custom-styles.test.tsx | 88 +++++++++++++++++++ .../-sidebar/right/sections/custom-styles.tsx | 63 ++++++++++--- 2 files changed, 140 insertions(+), 11 deletions(-) diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx index 7c1ca8bc8..74613e8a0 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx @@ -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(); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx index 0cf01aba5..0cdfa35ed 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx @@ -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 ( { - const value = event.target.value; - onChange(value === "" ? undefined : Number(value)); - }} + onFocus={boundedInput.onFocus} + onBlur={boundedInput.onBlur} + onChange={(event) => boundedInput.onInputChange(event.target.value)} /> ); @@ -833,21 +873,22 @@ function CompactNumberInput({ step = 1, onChange, }: CompactNumberInputProps) { + const boundedInput = useBoundedNumberInput(value, min, max, onChange); + return ( { - const value = event.target.value; - onChange(value === "" ? undefined : Number(value)); - }} + onFocus={boundedInput.onFocus} + onBlur={boundedInput.onBlur} + onChange={(event) => boundedInput.onInputChange(event.target.value)} /> ); }