authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 00:48:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 00:48:44-07:00
logb919d6389929698ddf223d35d20139f01c8bc899
treeff94be3745c497c23e9b45c990f6064fcd26eecb
parent062987b2fb0ef48cc7776d654931bd9f3bcf1f9f
signature Signed by PGP key B5690EEEBB952194

fix(web): keep composer toolbar controls anchored during transitions (#10478)


4 files changed, 34 insertions(+), 39 deletions(-)

apps/web/src/components/BranchToolbar.tsx+28-33
...@@ -143,7 +143,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -143,7 +143,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
143 >143 >
144 <span144 <span
145 data-composer-label-motion145 data-composer-label-motion
146 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"146 className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
147 >147 >
148 {autoEnvironmentLabel ??148 {autoEnvironmentLabel ??
149 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}149 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}
...@@ -274,12 +274,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -274,12 +274,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
274 */274 */
275const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;275const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
276const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";276const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
277const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]";277const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]";
278278
279function useLabelsOverflow(element: HTMLDivElement | null): boolean {279function useLabelsOverflow(element: HTMLDivElement | null): boolean {
280 const [overflows, setOverflows] = useState(false);280 const [overflows, setOverflows] = useState(false);
281 const pendingControlRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);281 const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
282 const controlAnimationsRef = useRef(new Map<HTMLElement, Animation>());282 const labelAnimationsRef = useRef(new Map<HTMLElement, Animation>());
283 // A render-synced mirror instead of useEffectEvent: the compiler memoizes283 // A render-synced mirror instead of useEffectEvent: the compiler memoizes
284 // the event callback, which left observers reading the first render's null284 // the event callback, which left observers reading the first render's null
285 // element forever.285 // element forever.
...@@ -344,15 +344,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -344,15 +344,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
344 for (const inner of label.querySelectorAll<HTMLElement>("*")) {344 for (const inner of label.querySelectorAll<HTMLElement>("*")) {
345 textWidth = Math.max(textWidth, inner.scrollWidth);345 textWidth = Math.max(textWidth, inner.scrollWidth);
346 }346 }
347 if (compact) {347 // Subtract the visible width even during an animation. The content
348 // Compact: the label is squeezed to zero width but keeps reporting348 // sum already includes it; only the hidden text needs reserving.
349 // the full width it would need when expanded.349 needed += Math.max(0, textWidth - label.getBoundingClientRect().width);
350 needed += textWidth;
351 } else {
352 // Expanded: the label is in flow; only the clipped remainder is
353 // missing from the content sum.
354 needed += Math.max(0, textWidth - label.clientWidth);
355 }
356 }350 }
357 const nextOverflows = resolveContextStripLabelsCompact({351 const nextOverflows = resolveContextStripLabelsCompact({
358 compact,352 compact,
...@@ -360,9 +354,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -360,9 +354,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
360 availableWidth: available,354 availableWidth: available,
361 });355 });
362 if (nextOverflows !== compact) {356 if (nextOverflows !== compact) {
363 pendingControlRectsRef.current = new Map(357 pendingLabelRectsRef.current = new Map(
364 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map(358 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)).map(
365 (control) => [control, control.getBoundingClientRect()],359 (label) => [label, label.getBoundingClientRect()],
366 ),360 ),
367 );361 );
368 }362 }
...@@ -370,28 +364,29 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -370,28 +364,29 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
370 }, []);364 }, []);
371365
372 useLayoutEffect(() => {366 useLayoutEffect(() => {
373 const previousRects = pendingControlRectsRef.current;367 const previousRects = pendingLabelRectsRef.current;
374 if (!previousRects) return;368 if (!previousRects) return;
375 pendingControlRectsRef.current = null;369 pendingLabelRectsRef.current = null;
376370
377 for (const animation of controlAnimationsRef.current.values()) {371 for (const animation of labelAnimationsRef.current.values()) {
378 animation.cancel();372 animation.cancel();
379 }373 }
380 controlAnimationsRef.current.clear();374 labelAnimationsRef.current.clear();
381375
382 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;376 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
383377
384 for (const [control, previousRect] of previousRects) {378 for (const [label, previousRect] of previousRects) {
385 if (!control.isConnected) continue;379 if (!label.isConnected) continue;
386 const nextRect = control.getBoundingClientRect();380 const nextWidth = label.getBoundingClientRect().width;
387 const deltaX = previousRect.left - nextRect.left;381 if (Math.abs(previousRect.width - nextWidth) < 0.5) continue;
388 const deltaY = previousRect.top - nextRect.top;
389 if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue;
390382
391 const animation = control.animate(383 // Animate the space occupied by each label so flex layout keeps the
384 // trailing controls anchored. Translating the whole group after its
385 // width snaps sends expanded text beyond the strip's right edge.
386 const animation = label.animate(
392 [387 [
393 { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` },388 { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` },
394 { transform: "translate3d(0, 0, 0)" },389 { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` },
395 ],390 ],
396 {391 {
397 duration: COMPOSER_CONTEXT_MOTION_DURATION_MS,392 duration: COMPOSER_CONTEXT_MOTION_DURATION_MS,
...@@ -399,12 +394,12 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -399,12 +394,12 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
399 fill: "backwards",394 fill: "backwards",
400 },395 },
401 );396 );
402 controlAnimationsRef.current.set(control, animation);397 labelAnimationsRef.current.set(label, animation);
403 animation.addEventListener(398 animation.addEventListener(
404 "finish",399 "finish",
405 () => {400 () => {
406 if (controlAnimationsRef.current.get(control) === animation) {401 if (labelAnimationsRef.current.get(label) === animation) {
407 controlAnimationsRef.current.delete(control);402 labelAnimationsRef.current.delete(label);
408 }403 }
409 },404 },
410 { once: true },405 { once: true },
...@@ -414,7 +409,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -414,7 +409,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
414409
415 useEffect(410 useEffect(
416 () => () => {411 () => () => {
417 for (const animation of controlAnimationsRef.current.values()) {412 for (const animation of labelAnimationsRef.current.values()) {
418 animation.cancel();413 animation.cancel();
419 }414 }
420 },415 },
apps/web/src/components/BranchToolbarBranchSelector.tsx+2-2
...@@ -756,7 +756,7 @@ export function BranchToolbarBranchSelector({...@@ -756,7 +756,7 @@ export function BranchToolbarBranchSelector({
756 >756 >
757 <span757 <span
758 data-composer-label-motion758 data-composer-label-motion
759 className="block w-full min-w-0 max-w-12 origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"759 className="block w-full min-w-0 max-w-12 truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
760 >760 >
761 #{branchPr.number}761 #{branchPr.number}
762 </span>762 </span>
...@@ -784,7 +784,7 @@ export function BranchToolbarBranchSelector({...@@ -784,7 +784,7 @@ export function BranchToolbarBranchSelector({
784 >784 >
785 <span785 <span
786 data-composer-label-motion786 data-composer-label-motion
787 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"787 className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
788 >788 >
789 {triggerLabel}789 {triggerLabel}
790 </span>790 </span>
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+2-2
...@@ -66,7 +66,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -66,7 +66,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
66 >66 >
67 <span67 <span
68 data-composer-label-motion68 data-composer-label-motion
69 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"69 className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
70 >70 >
71 {resolveLockedWorkspaceLabel(activeWorktreePath)}71 {resolveLockedWorkspaceLabel(activeWorktreePath)}
72 </span>72 </span>
...@@ -108,7 +108,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -108,7 +108,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
108 >108 >
109 <span109 <span
110 data-composer-label-motion110 data-composer-label-motion
111 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"111 className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
112 >112 >
113 <SelectValue />113 <SelectValue />
114 </span>114 </span>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-2
...@@ -72,7 +72,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -72,7 +72,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
72 >72 >
73 <span73 <span
74 data-composer-label-motion74 data-composer-label-motion
75 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"75 className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
76 >76 >
77 {activeEnvironment?.label ?? "Run on"}77 {activeEnvironment?.label ?? "Run on"}
78 </span>78 </span>
...@@ -111,7 +111,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -111,7 +111,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
111 >111 >
112 <span112 <span
113 data-composer-label-motion113 data-composer-label-motion
114 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"114 className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
115 >115 >
116 <SelectValue />116 <SelectValue />
117 </span>117 </span>