Skip to main content

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.ts
  • src/components/Tree/Tree.tsx
  • src/components/Tree/Tree.test.tsx
  • src/components/Tree/TreeDesignSpec.md
  • packages/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-1 sits in the between-rows gap of the root's gap-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.tsx pattern)
  • Drag states are driven by dispatchEvent + an in-page real DataTransfer (Playwright has no native HTML5 DnD); since the real drag-preview node is destroyed the frame after dragstart, it is statically reproduced using TREE_DRAG_PREVIEW_CLASS
  • vitest.config.ts coverage 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:gallery are 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); scrollWidth is not used (the title uses a mask gradient rather than ellipsis, and the content can be any ReactNode). Implementation in utils.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.current live — 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 resetting overflowed back 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.