feat(web): make every drag-to-reorder move, lift and settle the same way

Builder sections and entries, keyword chips and layout pages now shuffle and
drop with the shared DRAG_SETTLE timing (200ms, ease-in-out-strong). Dragged
builder rows lift on a raised surface with a shadow instead of fading to 40%.
Chips get a drop animation instead of vanishing, and both drag overlays skip
the drop flight under reduced motion.
This commit is contained in:
Amruth Pillai
2026-09-29 12:50:37 +02:00
parent 30819e2fc7
commit d1aabaecb3
4 changed files with 28 additions and 10 deletions
+10 -2
View File
@@ -12,6 +12,7 @@ import { rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useS
import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -21,6 +22,7 @@ import { Input } from "@reactive-resume/ui/components/input";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { cn } from "@reactive-resume/utils/style";
import { useControlledState } from "@/hooks/use-controlled-state";
import { DRAG_SETTLE } from "@/libs/motion";
const RETURN_KEY = "Enter";
const COMMA_KEY = ",";
@@ -55,15 +57,21 @@ type ChipDragOverlayProps = {
};
function ChipDragOverlay({ activeChip }: ChipDragOverlayProps) {
const reduceMotion = useReducedMotion();
const overlay = (
<DragOverlay dropAnimation={null}>{activeChip ? <ChipDragPreview chip={activeChip} /> : null}</DragOverlay>
<DragOverlay dropAnimation={reduceMotion ? null : DRAG_SETTLE}>
{activeChip ? <ChipDragPreview chip={activeChip} /> : null}
</DragOverlay>
);
return createPortal(overlay, document.body);
}
function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
transition: DRAG_SETTLE,
});
const style = {
transition,
@@ -25,6 +25,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { DRAG_SETTLE } from "@/libs/motion";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
import { useEditorStore } from "../store";
import { EntryFields } from "./entries";
@@ -65,7 +66,7 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
const open = useEditorStore((state) => state.selection?.kind === "item" && state.selection.itemId === entryId);
const autoFocus = useEditorStore((state) => state.focusEntryId === entryId);
const select = useEditorStore((state) => state.select);
const sortable = useSortable({ id: entryId, disabled: locked });
const sortable = useSortable({ id: entryId, disabled: locked, transition: DRAG_SETTLE });
const isPhone = useBreakpoint() === "mobile";
// The first field took focus as it mounted; later openings shouldn't steal focus again.
@@ -101,9 +102,9 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
data-entry-id={entryId}
style={{ transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition }}
className={cn(
"group/entry relative scroll-mt-[60px] rounded-[10px] border border-line bg-surface transition-[border-color,box-shadow] duration-quick",
"group/entry relative scroll-mt-[60px] rounded-[10px] border border-line bg-surface transition-[border-color,box-shadow] duration-quick ease-enter",
open && "border-accent shadow-e1",
sortable.isDragging && "z-10 opacity-40",
sortable.isDragging && "z-10 bg-raised shadow-e2",
)}
>
<div className="flex items-center gap-0.5 pe-1">
@@ -28,6 +28,7 @@ import { cn } from "@reactive-resume/utils/style";
import { IconPicker } from "@/components/input/icon-picker";
import { useCurrentBuilderResumeSelector, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { usePrompt } from "@/hooks/use-confirm";
import { DRAG_SETTLE } from "@/libs/motion";
import { getSectionTitle } from "@/libs/resume/section";
import { pendingProposals } from "../proposals/proposals";
import { useEditorStore } from "../store";
@@ -64,7 +65,7 @@ export function SectionRow({ section, locked, onMove, children }: SectionRowProp
const open = useEditorStore((state) => state.openSections.includes(section.id));
const setOpen = useEditorStore((state) => state.setSectionOpen);
const updateResumeData = useUpdateResumeData();
const sortable = useSortable({ id: section.id, disabled: locked });
const sortable = useSortable({ id: section.id, disabled: locked, transition: DRAG_SETTLE });
const toggleHidden = () =>
updateResumeData(
@@ -87,7 +88,10 @@ export function SectionRow({ section, locked, onMove, children }: SectionRowProp
id={`sidebar-${section.id}`}
data-section-id={section.id}
style={{ transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition }}
className={cn("relative scroll-mt-[60px] rounded-lg", sortable.isDragging && "z-10 opacity-40")}
className={cn(
"relative scroll-mt-[60px] rounded-lg transition-shadow duration-quick ease-enter",
sortable.isDragging && "z-10 bg-raised shadow-e2",
)}
>
<div className="group/row flex h-11 items-center gap-0.5 rounded-lg pe-1 hover:bg-hover">
<button
@@ -14,6 +14,7 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy }
import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useReducedMotion } from "motion/react";
import { useCallback, useId, useState } from "react";
import { match } from "ts-pattern";
import { templateLayouts } from "@reactive-resume/schema/templates";
@@ -33,7 +34,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { EASE } from "@/libs/motion";
import { DRAG_SETTLE } from "@/libs/motion";
import { resolveLayoutSectionTitle } from "./title";
import { filterVisibleLayoutSectionIds } from "./visibility";
@@ -86,6 +87,7 @@ const createDroppableId = (pageIndex: number, columnId: ColumnId): string => {
export function LayoutPages() {
const [activeId, setActiveId] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const resume = useCurrentResume();
const template = resume.data.metadata.template;
@@ -263,7 +265,7 @@ export function LayoutPages() {
</Button>
</div>
<DragOverlay dropAnimation={{ duration: 150, easing: `cubic-bezier(${EASE})` }}>
<DragOverlay dropAnimation={reduceMotion ? null : DRAG_SETTLE}>
{activeId ? <LayoutItemContent id={activeId} isOverlay /> : null}
</DragOverlay>
</DndContext>
@@ -407,7 +409,10 @@ type SortableLayoutItemProps = {
};
function SortableLayoutItem({ id, pageIndex, columnId }: SortableLayoutItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
transition: DRAG_SETTLE,
});
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };