From 0ff6b495642f2a6ea4fc5bbcb11df03f21c50d75 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Sat, 26 Sep 2026 12:49:49 +0700 Subject: [PATCH] fix(ui): spring the org chart's folds and stop drags sticking to the cursor Folding and unfolding re-rendered every card and animated left/top on a CSS ease, so large teams stalled and new reports popped in. Cards now memoise apart from their position, and cards and wires share one spring (a CSS linear() curve), so new reports slide out from under their manager and folded ones tuck back beneath it. Cards and wires keep a stable DOM order so running transitions are never cancelled. The folded or dropped-on person holds still on screen while the chart reflows around them, and each card mounts its actions menu on first click. A drag listened only on the card, so a release lost to a context menu, another window, or the card moving in the DOM left the card following the cursor. Drags now listen on the window, end on blur, Escape, or any move with no button held, and canvas pans end the same way. Adds a 391-person StressTest story for folding and dragging at scale. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/ui/registry.json | 12 + .../components/org-chart/org-chart-node.tsx | 295 +++++++++------ .../components/org-chart/org-chart-scene.ts | 160 ++++++++ .../components/org-chart/org-chart-spring.ts | 36 ++ .../components/org-chart/org-chart-wires.tsx | 106 ++++++ .../ui/src/components/org-chart/org-chart.tsx | 343 ++++++++++-------- .../src/components/workspace-kit/canvas.tsx | 81 ++++- packages/ui/src/stories/OrgChart.stories.tsx | 48 +++ 8 files changed, 808 insertions(+), 273 deletions(-) create mode 100644 packages/ui/src/components/org-chart/org-chart-scene.ts create mode 100644 packages/ui/src/components/org-chart/org-chart-spring.ts create mode 100644 packages/ui/src/components/org-chart/org-chart-wires.tsx diff --git a/packages/ui/registry.json b/packages/ui/registry.json index 10a6e147..c812ff0f 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -3110,10 +3110,22 @@ "path": "registry/constructive/blocks/org-chart/org-chart-states.tsx", "type": "registry:component" }, + { + "path": "registry/constructive/blocks/org-chart/org-chart-wires.tsx", + "type": "registry:component" + }, { "path": "registry/constructive/blocks/org-chart/layout.ts", "type": "registry:lib" }, + { + "path": "registry/constructive/blocks/org-chart/org-chart-scene.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/org-chart/org-chart-spring.ts", + "type": "registry:lib" + }, { "path": "registry/constructive/blocks/org-chart/org-chart-utils.ts", "type": "registry:lib" diff --git a/packages/ui/src/components/org-chart/org-chart-node.tsx b/packages/ui/src/components/org-chart/org-chart-node.tsx index 3acc1094..e6ed4b35 100644 --- a/packages/ui/src/components/org-chart/org-chart-node.tsx +++ b/packages/ui/src/components/org-chart/org-chart-node.tsx @@ -9,13 +9,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara import { CANVAS_NODE_ATTRIBUTE, NodeShell } from '../workspace-kit/canvas'; import { focusRingClass, pressClass } from '../workspace-kit/primitives'; import type { PlacedPerson } from './layout'; +import { springTransition } from './org-chart-spring'; import { getInitials, personName, reportsLabel } from './org-chart-utils'; export type NodeHandlers = { + /** Starts a press; the chart follows the pointer from there until it is released anywhere. */ pointerDown: (id: string, event: React.PointerEvent) => void; - pointerMove: (id: string, event: React.PointerEvent) => void; - pointerUp: (id: string, event: React.PointerEvent) => void; - pointerCancel: () => void; keyDown: (id: string, event: React.KeyboardEvent) => void; focus: (id: string, event: React.FocusEvent) => void; toggle: (id: string) => void; @@ -35,80 +34,80 @@ export function PersonAvatar({ person, className }: { person: { displayName: str ); } -type OrgChartNodeProps = { +/** + * The card's actions menu. Until its first click it is a plain button that + * looks the same, because a menu per card is a third of the cost of + * unfolding a large team; the first click mounts the real menu, already open. + */ +function CardMenu({ id, name, selected, handlers }: { id: string; name: string; selected: boolean; handlers: NodeHandlers }) { + const [mounted, setMounted] = React.useState(false); + const trigger = { + tabIndex: -1, + 'aria-label': `Actions for ${name}`, + className: cn( + 'grid size-6 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground opacity-0 hover:bg-overlay-hover hover:text-foreground', + 'group-hover/node:opacity-100 group-focus-within/node:opacity-100 data-[popup-open]:opacity-100 pointer-coarse:opacity-100', + selected && 'opacity-100', + 'transition-opacity duration-(--duration-fast) motion-reduce:transition-none', + pressClass, + focusRingClass, + ), + children: