From ff03bb356d6ee542a54d15a76383119d93638e83 Mon Sep 17 00:00:00 2001 From: Bryan Doss Date: Sat, 12 Sep 2026 11:44:30 -0700 Subject: [PATCH] fix: smooth drag-and-drop reordering of relation chips Migrate the relation-chip reorder component to the next-gen dnd-kit (@dnd-kit/react + @dnd-kit/helpers). The classic sortable strategies compute transforms assuming uniform item sizes, which stretched, mis-spaced, or snapped variable-width chips in wrapped multi-row lists. The new library measures the real DOM and FLIP-animates optimistic reordering, so wrapped chip rows reflow smoothly and land correctly. Positions are committed on drop, reusing the existing position set. - reorderable-relation-chips-dnd.tsx: rewritten on DragDropProvider/ useSortable; adds afterChips slot so the add-entity button lives inside the flex-wrapped list - chip.tsx: LinkableRelationChip accepts sortableDragHandleRef (the new API marks handles via callback ref); dots button unchanged - editable-entity-page.tsx: renders the add button via afterChips; IMAGE/VIDEO branching normalized to renderableTypeStrict - vitest.setup.ts: stub ResizeObserver in the test environment; @dnd-kit/dom references it at module scope and jsdom does not implement it Replays PR #1806 on current master so a Vercel preview can be tested. The only change from that branch: master has since added its own vitest.setup.ts (the network-refusal guard) and wired setupFiles in vite.config.js, so the ResizeObserver stub is appended to master's setup file rather than creating one, and the duplicate setupFiles key is dropped. Co-authored-by: Juan Mardikian --- apps/web/design-system/chip.tsx | 17 +- .../reorderable-relation-chips-dnd.tsx | 236 ++++++------------ apps/web/package.json | 2 + .../entity-page/editable-entity-page.tsx | 225 +++++++++-------- apps/web/vitest.setup.ts | 13 + bun.lock | 18 ++ 6 files changed, 242 insertions(+), 269 deletions(-) diff --git a/apps/web/design-system/chip.tsx b/apps/web/design-system/chip.tsx index 768439af72..902d64eff3 100644 --- a/apps/web/design-system/chip.tsx +++ b/apps/web/design-system/chip.tsx @@ -87,6 +87,8 @@ type LinkableRelationChipProps = { className?: string; disableLink?: boolean; sortableDragHandleListeners?: DraggableSyntheticListeners; + /** Callback ref marking the dots button as the drag handle (next-gen @dnd-kit/react). */ + sortableDragHandleRef?: (element: HTMLButtonElement | null) => void; truncateLabel?: boolean; children: React.ReactNode; @@ -199,6 +201,7 @@ export function LinkableRelationChip({ className = '', disableLink = false, sortableDragHandleListeners, + sortableDragHandleRef, truncateLabel = false, children, }: LinkableRelationChipProps) { @@ -217,6 +220,16 @@ export function LinkableRelationChip({ const triggerRef = React.useRef(null); const contentRef = React.useRef(null); + // Stable merged ref: an inline callback would re-run on every render (null then node), + // detaching/re-attaching the drag handle during hover/popover state updates. + const dotsButtonRef = React.useCallback( + (node: HTMLButtonElement | null) => { + triggerRef.current = node; + sortableDragHandleRef?.(node); + }, + [sortableDragHandleRef] + ); + const shouldClamp = !truncateLabel && typeof children === 'string' && children.length >= 42; const labelInner = truncateLabel ? {children} : children; @@ -302,7 +315,7 @@ export function LinkableRelationChip({