Add control for moving items up and down a list.

This commit is contained in:
araemer 2025-10-21 15:09:15 +02:00
parent 05595fba94
commit 3c9c94957f
4 changed files with 113 additions and 29 deletions

View file

@ -0,0 +1,36 @@
import Button from "./Button.tsx";
import {ArrowDown, ArrowUp} from "lucide-react";
import {ButtonType} from "./BasicButtonDefinitions.ts";
type MoveButtonControlProps = {
isUpDisabled: boolean;
isDownDisabled: boolean;
onMoveUp(): void;
onMoveDown(): void;
}
/**
* Up and down buttons for reordering list items when not using drag & drop, e.g., on mobile devices.
* @param isUpDisabled Indicates whether the up button is enabled
* @param isDownDisabled Indicates whether the down button is enabled
* @param onMoveUp Method to call when move up is clicked
* @param onMoveDown Method to call when move down is clicked
*/
export function MoveButtonControl({isUpDisabled, isDownDisabled, onMoveUp, onMoveDown}: MoveButtonControlProps) {
return (
<div className="flex flex-col mt-2">
<Button
icon={ArrowUp}
onClick={() => onMoveUp()}
buttonType={ButtonType.TransparentButton}
disabled={isUpDisabled} // disable if first item
/>
<Button
icon={ArrowDown}
onClick={() => onMoveDown()}
buttonType={ButtonType.TransparentButton}
disabled={isDownDisabled}
/>
</div>
);
}

View file

@ -1,7 +1,3 @@
/**
* Desktop editor using drag-and-drop via @dnd-kit
*/
import {closestCenter, DndContext, PointerSensor, useSensor, useSensors,} from "@dnd-kit/core"; import {closestCenter, DndContext, PointerSensor, useSensor, useSensors,} from "@dnd-kit/core";
import {arrayMove, SortableContext, verticalListSortingStrategy,} from "@dnd-kit/sortable"; import {arrayMove, SortableContext, verticalListSortingStrategy,} from "@dnd-kit/sortable";
import type {InstructionStepModel} from "../../models/InstructionStepModel"; import type {InstructionStepModel} from "../../models/InstructionStepModel";
@ -16,10 +12,19 @@ type InstructionStepListDesktopEditorProps = {
onChange: (steps: InstructionStepModel[]) => void; onChange: (steps: InstructionStepModel[]) => void;
}; };
/**
* Desktop editor using drag-and-drop via @dnd-kit
* @param instructionStepList List instruction step models
* @param onChange Method to call on any change to the list
* @constructor
*/
export function InstructionStepListDesktopEditor({ export function InstructionStepListDesktopEditor({
instructionStepList, instructionStepList,
onChange, onChange,
}: InstructionStepListDesktopEditorProps) { }: InstructionStepListDesktopEditorProps) {
/* Import methods for handling update of instruction steps as well as adding and removing steps.
* Those methods are shared by all versions of the instruction step editor, e.g., mobile and desktop.
*/
const {handleUpdate, handleAdd, handleRemove} = instructionStepListEditorMethods( const {handleUpdate, handleAdd, handleRemove} = instructionStepListEditorMethods(
instructionStepList, instructionStepList,
onChange onChange
@ -27,6 +32,9 @@ export function InstructionStepListDesktopEditor({
const sensors = useSensors(useSensor(PointerSensor, {activationConstraint: {distance: 8}})); const sensors = useSensors(useSensor(PointerSensor, {activationConstraint: {distance: 8}}));
/**
* Handle the drag end event and reorder list
*/
const handleDragEnd = (event: any) => { const handleDragEnd = (event: any) => {
const {active, over} = event; const {active, over} = event;
if (active.id !== over.id) { if (active.id !== over.id) {

View file

@ -1,7 +1,3 @@
/**
* Mobile editor using Up/Down buttons for reordering
*/
import {Plus} from "lucide-react"; import {Plus} from "lucide-react";
import type {InstructionStepModel} from "../../models/InstructionStepModel"; import type {InstructionStepModel} from "../../models/InstructionStepModel";
import Button from "../basics/Button"; import Button from "../basics/Button";
@ -14,15 +10,28 @@ type InstructionStepListEditorMobileProps = {
onChange: (steps: InstructionStepModel[]) => void; onChange: (steps: InstructionStepModel[]) => void;
}; };
/**
* Mobile editor using Up/Down buttons for reordering
* @param instructionStepList List instruction step models
* @param onChange Method to call on any change to the list
*/
export function InstructionStepListMobileEditor({ export function InstructionStepListMobileEditor({
instructionStepList, instructionStepList,
onChange, onChange,
}: InstructionStepListEditorMobileProps) { }: InstructionStepListEditorMobileProps) {
/* Import methods for handling update of instruction steps as well as adding and removing steps.
* Those methods are shared by all versions of the instruction step editor, e.g., mobile and desktop.
*/
const {handleUpdate, handleAdd, handleRemove} = instructionStepListEditorMethods( const {handleUpdate, handleAdd, handleRemove} = instructionStepListEditorMethods(
instructionStepList, instructionStepList,
onChange onChange
); );
/**
* Move an instruction step either up or down in list.
* @param index Current index of the step to move
* @param direction Direction to move the step in. Either "up" or "down".
*/
const moveStep = (index: number, direction: "up" | "down") => { const moveStep = (index: number, direction: "up" | "down") => {
const newIndex = direction === "up" ? index - 1 : index + 1; const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= instructionStepList.length) return; if (newIndex < 0 || newIndex >= instructionStepList.length) return;
@ -35,11 +44,13 @@ export function InstructionStepListMobileEditor({
return ( return (
<div> <div>
<h2 className="section-heading mb-2">Zubereitung</h2> <h2 className="section-heading mb-2">Zubereitung</h2>
{/* Instruction list */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{instructionStepList.map((step, index) => ( {instructionStepList.map((step, index) => (
<InstructionStepMobileListItem <InstructionStepMobileListItem
index={index} index={index}
step={step} stepModel={step}
onMove={moveStep} onMove={moveStep}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onRemove={handleRemove} onRemove={handleRemove}
@ -47,6 +58,8 @@ export function InstructionStepListMobileEditor({
isLast={index === instructionStepList.length - 1} isLast={index === instructionStepList.length - 1}
/> />
))} ))}
{/* Button for adding an additional instruction step */}
<Button <Button
onClick={handleAdd} onClick={handleAdd}
icon={Plus} icon={Plus}

View file

@ -1,21 +1,53 @@
import Button from "../basics/Button.tsx"; import Button from "../basics/Button.tsx";
import {ArrowDown, ArrowUp, X} from "lucide-react"; import {X} from "lucide-react";
import {ButtonType} from "../basics/BasicButtonDefinitions.ts"; import {ButtonType} from "../basics/BasicButtonDefinitions.ts";
import type {InstructionStepModel} from "../../models/InstructionStepModel.ts"; import type {InstructionStepModel} from "../../models/InstructionStepModel.ts";
import {MoveButtonControl} from "../basics/MoveButtonControl.tsx";
type InstructionStepMobileListItemProps = { type InstructionStepMobileListItemProps = {
/** Index of the instruction step */
index: number; index: number;
step: InstructionStepModel; /** Model holding the instruction step data, e.g., instruction text */
stepModel: InstructionStepModel;
/**
* Method to call on moving the instruction step.
* @param index Current index of the step to move
* @param direction Direction to move the step in (either "up" or "down")
*/
onMove(index: number, direction: "up" | "down"): void; onMove(index: number, direction: "up" | "down"): void;
onUpdate: (index: number, value: string) => void; /**
* Method to call on updating the instruction step.
* @param index Index of the step to be updated.
* @param instructionText new instruction text
*/
onUpdate: (index: number, instructionText: string) => void;
/**
* Method to call on removing the instruction step.
* @param index Index of the instruction step to be removed.
*/
onRemove: (index: number) => void; onRemove: (index: number) => void;
/** Indicates whether this is the first instruction step. Used to restrict movement. */
isFirst: boolean; isFirst: boolean;
/** Indicates whether this is the last instruction step. Used to restrict movement. */
isLast: boolean; isLast: boolean;
}; };
/**
* Numbered list item for instructions step list editor for mobile devices.
*
* Describes a single instruction step of a recipe. As drag & drop usually is a bit clumsy on
* mobile devices, the steps can be reordered with small up and down buttons below the step number.
* @param index Index of this instruction step
* @param stepModel Model containing the data for the current instruction step, e.g., instruction text.
* @param onMove Method to call on moving the instruction step.
* @param onUpdate Method to call on updating the instruction step.
* @param onRemove Method to call on removing the instruction step.
* @param isFirst Indicates whether this is the first instruction step. In this case, the step cannot be moved up.
* @param isLast Indicates whether this is the last instruction step. In this case, the step cannot be moved down.
*/
export function InstructionStepMobileListItem({ export function InstructionStepMobileListItem({
index, index,
step, stepModel,
onMove, onMove,
onUpdate, onUpdate,
onRemove, onRemove,
@ -24,33 +56,27 @@ export function InstructionStepMobileListItem({
}: InstructionStepMobileListItemProps) { }: InstructionStepMobileListItemProps) {
return ( return (
<div <div
key={step.id} key={stepModel.id}
className="flex items-start gap-3 bg-gray-50 rounded-xl p-3 shadow-sm" className="flex items-start gap-3 bg-gray-50 rounded-xl p-3 shadow-sm"
> >
{/* Left column: Number of the instruction step and move controls */}
<div className="flex flex-col items-center pt-1"> <div className="flex flex-col items-center pt-1">
<div className="circular-container"> <div className="circular-container">
{index + 1} {index + 1}
</div> </div>
<div className="flex flex-col mt-2"> <MoveButtonControl
<Button isUpDisabled={isFirst}
icon={ArrowUp} isDownDisabled={isLast}
onClick={() => onMove(index, "up")} onMoveUp={() => onMove(index, "up")}
buttonType={ButtonType.TransparentButton} onMoveDown={() => onMove(index, "down")}
disabled={isFirst} // disable if first item
/> />
<Button
icon={ArrowDown}
onClick={() => onMove(index, "down")}
buttonType={ButtonType.TransparentButton}
disabled={isLast} // disable if last item
/>
</div>
</div> </div>
{/* Center column: Instruction step */}
<textarea <textarea
className="input-field w-full min-h-[120px] resize-none overflow-hidden" className="input-field w-full min-h-[120px] resize-none overflow-hidden"
placeholder={`Schritt ${index + 1}`} placeholder={`Schritt ${index + 1}`}
value={step.text} value={stepModel.text}
onChange={(e) => onUpdate(index, e.target.value)} onChange={(e) => onUpdate(index, e.target.value)}
onInput={(e) => { onInput={(e) => {
const el = e.target as HTMLTextAreaElement; const el = e.target as HTMLTextAreaElement;
@ -59,6 +85,7 @@ export function InstructionStepMobileListItem({
}} }}
/> />
{/* Right column: Remove step button */}
<Button <Button
onClick={() => onRemove(index)} onClick={() => onRemove(index)}
icon={X} icon={X}