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({
143143 >
144144 <span
145145 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"
147147 >
148148 {autoEnvironmentLabel ??
149149 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}
......@@ -274,12 +274,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
274274 */
275275const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
276276const 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
279279function useLabelsOverflow(element: HTMLDivElement | null): boolean {
280280 const [overflows, setOverflows] = useState(false);
281 const pendingControlRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
282 const controlAnimationsRef = useRef(new Map<HTMLElement, Animation>());
281 const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
282 const labelAnimationsRef = useRef(new Map<HTMLElement, Animation>());
283283 // A render-synced mirror instead of useEffectEvent: the compiler memoizes
284284 // the event callback, which left observers reading the first render's null
285285 // element forever.
......@@ -344,15 +344,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
344344 for (const inner of label.querySelectorAll<HTMLElement>("*")) {
345345 textWidth = Math.max(textWidth, inner.scrollWidth);
346346 }
347 if (compact) {
348 // Compact: the label is squeezed to zero width but keeps reporting
349 // the full width it would need when expanded.
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 }
347 // Subtract the visible width even during an animation. The content
348 // sum already includes it; only the hidden text needs reserving.
349 needed += Math.max(0, textWidth - label.getBoundingClientRect().width);
356350 }
357351 const nextOverflows = resolveContextStripLabelsCompact({
358352 compact,
......@@ -360,9 +354,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
360354 availableWidth: available,
361355 });
362356 if (nextOverflows !== compact) {
363 pendingControlRectsRef.current = new Map(
364 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map(
365 (control) => [control, control.getBoundingClientRect()],
357 pendingLabelRectsRef.current = new Map(
358 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)).map(
359 (label) => [label, label.getBoundingClientRect()],
366360 ),
367361 );
368362 }
......@@ -370,28 +364,29 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
370364 }, []);
371365
372366 useLayoutEffect(() => {
373 const previousRects = pendingControlRectsRef.current;
367 const previousRects = pendingLabelRectsRef.current;
374368 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()) {
378372 animation.cancel();
379373 }
380 controlAnimationsRef.current.clear();
374 labelAnimationsRef.current.clear();
381375
382376 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
383377
384 for (const [control, previousRect] of previousRects) {
385 if (!control.isConnected) continue;
386 const nextRect = control.getBoundingClientRect();
387 const deltaX = previousRect.left - nextRect.left;
388 const deltaY = previousRect.top - nextRect.top;
389 if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue;
378 for (const [label, previousRect] of previousRects) {
379 if (!label.isConnected) continue;
380 const nextWidth = label.getBoundingClientRect().width;
381 if (Math.abs(previousRect.width - nextWidth) < 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(
392387 [
393 { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` },
394 { transform: "translate3d(0, 0, 0)" },
388 { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` },
389 { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` },
395390 ],
396391 {
397392 duration: COMPOSER_CONTEXT_MOTION_DURATION_MS,
......@@ -399,12 +394,12 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
399394 fill: "backwards",
400395 },
401396 );
402 controlAnimationsRef.current.set(control, animation);
397 labelAnimationsRef.current.set(label, animation);
403398 animation.addEventListener(
404399 "finish",
405400 () => {
406 if (controlAnimationsRef.current.get(control) === animation) {
407 controlAnimationsRef.current.delete(control);
401 if (labelAnimationsRef.current.get(label) === animation) {
402 labelAnimationsRef.current.delete(label);
408403 }
409404 },
410405 { once: true },
......@@ -414,7 +409,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
414409
415410 useEffect(
416411 () => () => {
417 for (const animation of controlAnimationsRef.current.values()) {
412 for (const animation of labelAnimationsRef.current.values()) {
418413 animation.cancel();
419414 }
420415 },
apps/web/src/components/BranchToolbarBranchSelector.tsx+2-2
......@@ -756,7 +756,7 @@ export function BranchToolbarBranchSelector({
756756 >
757757 <span
758758 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"
760760 >
761761 #{branchPr.number}
762762 </span>
......@@ -784,7 +784,7 @@ export function BranchToolbarBranchSelector({
784784 >
785785 <span
786786 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"
788788 >
789789 {triggerLabel}
790790 </span>
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+2-2
......@@ -66,7 +66,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
6666 >
6767 <span
6868 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"
7070 >
7171 {resolveLockedWorkspaceLabel(activeWorktreePath)}
7272 </span>
......@@ -108,7 +108,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
108108 >
109109 <span
110110 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"
112112 >
113113 <SelectValue />
114114 </span>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-2
......@@ -72,7 +72,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
7272 >
7373 <span
7474 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"
7676 >
7777 {activeEnvironment?.label ?? "Run on"}
7878 </span>
......@@ -111,7 +111,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
111111 >
112112 <span
113113 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"
115115 >
116116 <SelectValue />
117117 </span>