mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-19 21:11:45 +10:00
fix: clamp custom style numeric inputs (#3262)
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user