mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 07:42:20 +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 () => {
|
it("stores list slot rules for rich text lists", async () => {
|
||||||
styleRules.splice(0, styleRules.length);
|
styleRules.splice(0, styleRules.length);
|
||||||
renderCustomStyles();
|
renderCustomStyles();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
|
|||||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
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 { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
|
||||||
import { Button } from "@reactive-resume/ui/components/button";
|
import { Button } from "@reactive-resume/ui/components/button";
|
||||||
import { Input } from "@reactive-resume/ui/components/input";
|
import { Input } from "@reactive-resume/ui/components/input";
|
||||||
@@ -329,23 +329,63 @@ type NumberInputProps = {
|
|||||||
onChange: (value: number | undefined) => void;
|
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) {
|
function NumberInput({ label, id, value, min, max, step = 1, onChange }: NumberInputProps) {
|
||||||
const inputId = id ?? `style-${label.toLowerCase().replaceAll(" ", "-")}`;
|
const inputId = id ?? `style-${label.toLowerCase().replaceAll(" ", "-")}`;
|
||||||
|
const boundedInput = useBoundedNumberInput(value, min, max, onChange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field label={label} id={inputId}>
|
<Field label={label} id={inputId}>
|
||||||
<Input
|
<Input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className="tabular-nums"
|
className="tabular-nums"
|
||||||
value={value ?? ""}
|
value={boundedInput.inputValue}
|
||||||
type="number"
|
type="number"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
onChange={(event) => {
|
onFocus={boundedInput.onFocus}
|
||||||
const value = event.target.value;
|
onBlur={boundedInput.onBlur}
|
||||||
onChange(value === "" ? undefined : Number(value));
|
onChange={(event) => boundedInput.onInputChange(event.target.value)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
@@ -833,21 +873,22 @@ function CompactNumberInput({
|
|||||||
step = 1,
|
step = 1,
|
||||||
onChange,
|
onChange,
|
||||||
}: CompactNumberInputProps) {
|
}: CompactNumberInputProps) {
|
||||||
|
const boundedInput = useBoundedNumberInput(value, min, max, onChange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={compactSpacingInputClassName}
|
className={compactSpacingInputClassName}
|
||||||
value={value ?? ""}
|
value={boundedInput.inputValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
type="number"
|
type="number"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
onChange={(event) => {
|
onFocus={boundedInput.onFocus}
|
||||||
const value = event.target.value;
|
onBlur={boundedInput.onBlur}
|
||||||
onChange(value === "" ? undefined : Number(value));
|
onChange={(event) => boundedInput.onInputChange(event.target.value)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user