Tree drag-and-drop visuals aligned to Figma (DES-89)
Pre-release · Type: ✨ Feature
Linear: DES-89
Figma: the Design System adds a dedicated Tree page (canvas 24402:2236); the Tree Item State axis expands to 5 states — Default / Hover / Selected / Dragged Source / Drop Indicator — and a Hierarchy axis plus Drag Preview / Drop Indicator elements are added.
Changed Files
src/components/Tree/styles.tssrc/components/Tree/Tree.tsxsrc/components/Tree/Tree.test.tsxsrc/components/Tree/TreeDesignSpec.mdpackages/design-site/docs/components/patterns/tree.mdx(+ Chinese version)
Changes
1. Row layout tokens
- In-row gap:
gap-(--Spacing_12)→gap-(--Spacing_8) - In-row padding:
px-(--Spacing_8)→pl-(--Spacing_8) pr-(--Spacing_4) - actions container:
right-(--Spacing_8)→right-(--Spacing_4)
2. Hierarchy indentation
indent default 24 → 16; the indentation formula changes from level * indent to level * indent - 4 (first child level 12px, then +16px per level), aligning with the Figma Hierarchy axis (both the Item and Drop Indicator component groups verified the "first level +12, then +16" pattern).
3. Dragged Source state
The drag-source row changes from opacity-50 to a 1px inset stroke inset-ring inset-ring-(--Separators-Emphasized), with the content staying fully opaque (Figma 24448:14150).
4. Drop indicator
The between-rows drop indicator changes from "an in-row 2px --Labels-Primary horizontal line" to "a --Labels-Link ∅8 hollow dot (2px stroke) + 2px horizontal line inside the 4px between-rows gap" (Figma Tree Element - Drop Indicator 24459:4888):
- The container
-top-1/-bottom-1sits in the between-rows gap of the root'sgap-1. - Left edge = this level's icon left edge + 8px (follows the Hierarchy indentation), right edge 4px from the row's right.
5. Drag preview (new)
On dragstart, the row's icon + title are cloned to build a white floating layer (--Grays-White + 0 0 32px var(--Effects-Shadow-Default) shadow + 88% opacity, with width matching the row), and setDragImage replaces the browser's default row screenshot (Figma Tree Element - Drag Preivew 24448:14181); the node is removed the frame after the screenshot.
In environments where setDragImage is missing or throws when called (e.g. happy-dom has the method but does not implement it), it falls back to the default drag image and removes the preview node in sync, avoiding a leak.
2026-06-12 Addendum: visual comparison cases (same day)
Linear: DES-90
Added Tree.visual.spec.tsx (Playwright CT, 9 cases) + TreeFixture.visual.tsx (scenario fixture), and registered Tree's Figma node mapping in scripts/visual-diff-config.ts:
- cases: default / expanded / selected / leaf / hierarchy-2 / hover / dragged-source / drop-indicator / drag-preview, mapped one-to-one to the variants on the Figma Tree page (mapping table in
TreeDesignSpec.md§9) - Playwright CT specs can only pass serializable props, so icon / actions / drag config are consolidated into the fixture (following Toast's
ToastFixture.visual.tsxpattern) - Drag states are driven by
dispatchEvent+ an in-page realDataTransfer(Playwright has no native HTML5 DnD); since the real drag-preview node is destroyed the frame afterdragstart, it is statically reproduced usingTREE_DRAG_PREVIEW_CLASS vitest.config.tscoverage exclude adds*.visual.spec.tsx/*.visual.tsx(visual test files do not count toward coverage)- Verified: the 9-case screenshot baseline is stable (two runs are consistent), and all 9 Tree Figma nodes in
pnpm visual:galleryare successfully fetched and paired
2026-06-12 Addendum: auto tooltip for overlong titles (same day)
Linear: DES-91
When the title content exceeds the container width, it is automatically wrapped in a Tooltip (300ms hover delay) to display the full content; no tooltip is introduced when it does not overflow.
- Overflow detection: clone the title node and render it off-screen (fixed + hidden, attached to the same parent to preserve font cascade), and compare the clone's natural width against the container's
clientWidth(+1px tolerance);scrollWidthis not used (the title uses a mask gradient rather than ellipsis, and the content can be any ReactNode). Implementation inutils.measureTitleOverflow. - Re-measure timing: once after mount + a ResizeObserver watching container size changes + title content changes.
- Pitfall note: the RO must observe the stable row container, and the measurement callback must read
titleRef.currentlive — tooltip wrapping rebuilds the title node, so if the RO observes the title node itself and captures a stale reference in the closure, the RO measures the detached node at 0 width when the old node unmounts, incorrectly resettingoverflowedback to false (a regression caught by a CT test in a real browser). - Tests: 7 unit tests (4 for the measurement function + 3 for component behavior, injecting widths via prototype getters under happy-dom); 1 CT behavior test (under real layout, a long title shows a tooltip on hover, a short title is not wrapped in a trigger).
Root Cause (test environment)
The design package's vitest environment is happy-dom: e.dataTransfer is happy-dom's own DataTransfer instance (not the mock passed in by fireEvent), and its setDragImage exists but throws when called. The first implementation only did a typeof guard, which caused the preview node to leak into document.body, polluting the 39 subsequent cases; switching to a try/catch fallback fixed it. On the test side, vi.spyOn(DataTransfer.prototype, 'setDragImage') verifies the preview construction and injection.