Extract NumberStepControl

This commit is contained in:
araemer 2025-10-21 07:54:13 +02:00
parent f980d4d86d
commit 13fe0ee852
4 changed files with 134 additions and 83 deletions

View file

@ -108,7 +108,7 @@
@apply py-4 border-b border-gray-400;
}
.enumeration-indicator {
.circular-container {
@apply flex-shrink-0 w-7 h-7 rounded-full bg-blue-300 text-white flex items-center justify-center shadow-sm;
}

View file

@ -0,0 +1,74 @@
import {Minus, Plus} from "lucide-react";
import {defaultIconSize} from "./SvgIcon.tsx";
type NumberStepControlProps = {
/** Current numeric value */
value: number;
/** Callback when value changes */
onChange: (newValue: number) => void;
/** Optional: minimum allowed value */
min?: number;
/** Optional: maximum allowed value */
max?: number;
/** Optional: step increment (default = 1) */
step?: number;
/** Optional: additional Tailwind classes */
className?: string;
};
/**
* A compact number input with +/ buttons.
* Removes native browser spinners and supports min/max limits.
*/
export function NumberStepControl({
value,
onChange,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
step = 1,
className = "",
}: NumberStepControlProps) {
const handleDecrease = () => {
const newValue = Math.max(value - step, min);
onChange(newValue);
};
const handleIncrease = () => {
const newValue = Math.min(value + step, max);
onChange(newValue);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const num = Number(e.target.value);
if (!isNaN(num)) onChange(Math.min(Math.max(num, min), max));
};
return (
<div className={`flex items-center gap-2 ${className}`}>
<button
type="button"
onClick={handleDecrease}
className="circular-container primary-button-bg"
>
<Minus size={defaultIconSize}/>
</button>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value}
onChange={handleInputChange}
className="w-16 text-center input-field"
/>
<button
type="button"
onClick={handleIncrease}
className="circular-container primary-button-bg"
>
<Plus size={defaultIconSize}/>
</button>
</div>
);
}

View file

@ -2,64 +2,64 @@
* Single step component for Desktop editor with drag-and-drop support.
*/
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { InstructionStepModel } from "../../models/InstructionStepModel";
import {useSortable} from "@dnd-kit/sortable";
import {CSS} from "@dnd-kit/utilities";
import type {InstructionStepModel} from "../../models/InstructionStepModel";
import Button from "../basics/Button";
import { X } from "lucide-react"
import { ButtonType } from "../basics/BasicButtonDefinitions";
import {X} from "lucide-react"
import {ButtonType} from "../basics/BasicButtonDefinitions";
type InstructionStepDesktopListItemProps = {
id: string | number;
index: number;
step: InstructionStepModel;
onUpdate: (index: number, value: string) => void;
onRemove: (index: number) => void;
id: string | number;
index: number;
step: InstructionStepModel;
onUpdate: (index: number, value: string) => void;
onRemove: (index: number) => void;
};
export function InstructionStepDesktopListItem({
id,
index,
step,
onUpdate,
onRemove,
}: InstructionStepDesktopListItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
id,
index,
step,
onUpdate,
onRemove,
}: InstructionStepDesktopListItemProps) {
const {attributes, listeners, setNodeRef, transform, transition} = useSortable({id});
const style = { transform: CSS.Transform.toString(transform), transition };
const style = {transform: CSS.Transform.toString(transform), transition};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-start gap-3"
>
<div
{...attributes}
{...listeners}
className="enumeration-indicator cursor-grab"
title="Ziehen, um neu zu ordnen"
>
{index + 1}
</div>
return (
<div
ref={setNodeRef}
style={style}
className="flex items-start gap-3"
>
<div
{...attributes}
{...listeners}
className="circular-container cursor-grab"
title="Ziehen, um neu zu ordnen"
>
{index + 1}
</div>
<textarea
className="input-field w-full min-h-[60px] resize-none overflow-hidden"
placeholder={`Schritt ${index + 1}`}
value={step.text}
onChange={(e) => onUpdate(index, e.target.value)}
onInput={(e) => {
const el = e.target as HTMLTextAreaElement;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
/>
<textarea
className="input-field w-full min-h-[60px] resize-none overflow-hidden"
placeholder={`Schritt ${index + 1}`}
value={step.text}
onChange={(e) => onUpdate(index, e.target.value)}
onInput={(e) => {
const el = e.target as HTMLTextAreaElement;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
/>
<Button
onClick={() => onRemove(index)}
icon={X}
buttonType={ButtonType.DarkButton}
/>
</div>
);
<Button
onClick={() => onRemove(index)}
icon={X}
buttonType={ButtonType.DarkButton}
/>
</div>
);
}

View file

@ -5,6 +5,7 @@ import {fetchRecipe} from "../../api/points/RecipePoint"
import {getRecipeEditUrl, getRecipeListUrl} from "../../routes"
import ButtonLink from "../basics/ButtonLink"
import {mapRecipeDtoToModel} from "../../mappers/RecipeMapper"
import {NumberStepControl} from "../basics/NumberStepControl.tsx";
/**
@ -99,36 +100,12 @@ export default function RecipeDetailPage() {
Für {recipeWorkingCopy.servings.amount} {recipeWorkingCopy.servings.unit}
</p>
<div className="flex items-center justify-end sm:justify-center gap-2">
{/* Minus button */}
<button
type="button"
onClick={() => recalculateIngredients(Math.max(1, recipeWorkingCopy.servings.amount - 1))}
className="enumeration-indicator primary-button-bg"
>
</button>
{/* Number input (no spin buttons) */}
<input
type="number"
inputMode="numeric"
pattern="[0-9]*"
className="w-16 text-center input-field"
value={recipeWorkingCopy.servings.amount}
onChange={e => recalculateIngredients(Number(e.target.value))}
min={1}
/>
{/* Plus button */}
<button
type="button"
onClick={() => recalculateIngredients(recipeWorkingCopy.servings.amount + 1)}
className="enumeration-indicator primary-button-bg"
>
+
</button>
</div>
<NumberStepControl
value={recipeWorkingCopy.servings.amount}
onChange={recalculateIngredients}
min={1}
className="justify-end sm:justify-center"
/>
</div>
{/* Ingredients */}
@ -156,7 +133,7 @@ export default function RecipeDetailPage() {
{recipe.instructionStepList.map((step, j) => (
<li key={j} className="flex items-start gap-4">
{/* Step number circle */}
<div className="enumeration-indicator">
<div className="circular-container">
{j + 1}
</div>