| ... | @@ -312,6 +312,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -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 | * Collapse the strip's labels to icons only when the text no longer fits. | 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,10 +348,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 320 | * the expanded width without remembered values that could go stale or latch | 348 | * the expanded width without remembered values that could go stale or latch |
| 321 | * the strip compact. A small hysteresis keeps the boundary from flapping. | 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 | function useLabelsOverflow(element: HTMLDivElement | null): boolean { | 351 | function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 328 | const [overflows, setOverflows] = useState(false); | 352 | const [overflows, setOverflows] = useState(false); |
| 329 | const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null); | 353 | const pendingLabelRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null); |
| ... | @@ -384,17 +408,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { | ... | @@ -384,17 +408,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 384 | needed += width; | 408 | needed += width; |
| 385 | } | 409 | } |
| 386 | needed += stripGap * Math.max(0, groups - 1); | 410 | needed += stripGap * Math.max(0, groups - 1); |
| | 411 | const range = document.createRange(); |
| 387 | for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) { | 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 | // Subtract the visible width even during an animation. The content | 413 | // Subtract the visible width even during an animation. The content |
| 396 | // sum already includes it; only the hidden text needs reserving. | 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 | const nextOverflows = resolveContextStripLabelsCompact({ | 417 | const nextOverflows = resolveContextStripLabelsCompact({ |
| 400 | compact, | 418 | compact, |