| ... | ... | @@ -312,6 +312,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 312 | 312 | ); |
| 313 | 313 | }); |
| 314 | 314 | |
| 315 | const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; |
| 316 | const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; |
| 317 | const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; |
| 318 | |
| 319 | /** |
| 320 | * The width a label takes when shown, clipped parts included. |
| 321 | * |
| 322 | * Text keeps its full width when its box clips it, so each text run measures |
| 323 | * whole. A label can hold more than one run (MiddleTruncate splits a branch |
| 324 | * into a head and a tail), so the runs are added. Reading one element's |
| 325 | * scrollWidth drops the tail when the label is hidden or squeezed, and the |
| 326 | * strip then flips between labels and icons on every measure. |
| 327 | * |
| 328 | * A shown label never grows past its motion span's max width, so longer text |
| 329 | * reserves only that much. |
| 330 | */ |
| 331 | function labelTextWidth(label: HTMLElement, range: Range): number { |
| 332 | const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT); |
| 333 | let width = 0; |
| 334 | for (let node = walker.nextNode(); node; node = walker.nextNode()) { |
| 335 | range.selectNodeContents(node); |
| 336 | width += range.getBoundingClientRect().width; |
| 337 | } |
| 338 | const motion = label.querySelector<HTMLElement>("[data-composer-label-motion]"); |
| 339 | const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN; |
| 340 | return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width; |
| 341 | } |
| 342 | |
| 315 | 343 | /** |
| 316 | 344 | * Collapse the strip's labels to icons only when the text no longer fits. |
| 317 | 345 | * |
| ... | ... | @@ -320,10 +348,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 320 | 348 | * the expanded width without remembered values that could go stale or latch |
| 321 | 349 | * the strip compact. A small hysteresis keeps the boundary from flapping. |
| 322 | 350 | */ |
| 323 | | const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; |
| 324 | | const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; |
| 325 | | const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; |
| 326 | | |
| 327 | 351 | function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 328 | 352 | const [overflows, setOverflows] = useState(false); |
| 329 | 353 | const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null); |
| ... | ... | @@ -384,17 +408,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 384 | 408 | needed += width; |
| 385 | 409 | } |
| 386 | 410 | needed += stripGap * Math.max(0, groups - 1); |
| 411 | const range = document.createRange(); |
| 387 | 412 | for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) { |
| 388 | | // The clipping can happen below the marker (SelectValue truncates |
| 389 | | // internally), where the outer span's scrollWidth matches its clipped |
| 390 | | // box. The text's real width is the largest scrollWidth in the subtree. |
| 391 | | let textWidth = label.scrollWidth; |
| 392 | | for (const inner of label.querySelectorAll<HTMLElement>("*")) { |
| 393 | | textWidth = Math.max(textWidth, inner.scrollWidth); |
| 394 | | } |
| 395 | 413 | // Subtract the visible width even during an animation. The content |
| 396 | 414 | // sum already includes it; only the hidden text needs reserving. |
| 397 | | needed += Math.max(0, textWidth - label.getBoundingClientRect().width); |
| 415 | needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width); |
| 398 | 416 | } |
| 399 | 417 | const nextOverflows = resolveContextStripLabelsCompact({ |
| 400 | 418 | compact, |