|
6 | 6 | :class="{ 'is-animated': isAnimated }" |
7 | 7 | aria-label="Main navigation" |
8 | 8 | @mouseleave="hoveredItemKey = null" |
| 9 | + @focusout="handleNavFocusout" |
9 | 10 | > |
10 | 11 | <ul |
11 | 12 | v-for="(navGroup, groupKey) in responsiveNavLinks" |
@@ -199,6 +200,14 @@ const handleNavigationItemHover = (key: string) => { |
199 | 200 | closeAllNavigationDetails(); |
200 | 201 | }; |
201 | 202 |
|
| 203 | +const handleNavFocusout = (event: FocusEvent) => { |
| 204 | + // Only clear when focus moves outside the nav entirely, not between nav items. |
| 205 | + const nav = event.currentTarget as HTMLElement; |
| 206 | + if (!event.relatedTarget || !nav.contains(event.relatedTarget as Node)) { |
| 207 | + hoveredItemKey.value = null; |
| 208 | + } |
| 209 | +}; |
| 210 | +
|
202 | 211 | const handleSummaryAction = (event: MouseEvent | KeyboardEvent) => { |
203 | 212 | toggleDetailsElement(event); |
204 | 213 | }; |
@@ -462,46 +471,74 @@ onMounted(async () => { |
462 | 471 | setupClickOutsideListeners(); |
463 | 472 | }); |
464 | 473 |
|
| 474 | +// ─── Re-entrancy guard for the ResizeObserver callback ──────────────────── |
| 475 | +// The observer fires an async callback that awaits multiple ticks and toggles |
| 476 | +// layout-affecting state. If the observer fires again while the first pass is |
| 477 | +// still in flight, two concurrent passes would race on isOverflowVisibleForMeasurement |
| 478 | +// and mainNavigationState. |
| 479 | +// |
| 480 | +// Pattern: "run-latest" queue. |
| 481 | +// • isMeasuring gates entry — any new observer call while a pass is running |
| 482 | +// sets pendingMeasure = true and returns immediately. |
| 483 | +// • The do/while re-runs once after the pass completes if a new resize arrived, |
| 484 | +// so we never silently drop the final geometry update. |
| 485 | +let isMeasuring = false; |
| 486 | +let pendingMeasure = false; |
| 487 | +
|
465 | 488 | useResizeObserver(navigationWrapperRef, async () => { |
466 | | - if (!isGeometryReady.value) { |
467 | | - // ─── Two-pass measurement on initial mount / route change ────────────── |
468 | | - // |
469 | | - // We never use a "peek" check to decide whether phase 2 is needed. |
470 | | - // A peek after phase 1 gives the wrong answer at the breakpoint because: |
471 | | - // • updateNavigationConfig() sets secondaryNavRects reactively, but |
472 | | - // v-bind(mainNavigationMarginBlockEndStr) isn't flushed to the DOM until |
473 | | - // the next Vue render tick — so item rects are still measured against the |
474 | | - // OLD margin, producing a stale DOM/computed mismatch. |
475 | | - // • Even with correct timing, checking without the button reserved will |
476 | | - // always pass marginal items (those that fit only when button is absent). |
477 | | - // |
478 | | - // Phase 1: button is 'visually-hidden' (width:0). |
479 | | - // Measure the wrapper and secondary-nav rects without the button. |
480 | | - await updateNavigationConfig("phase1"); |
481 | | -
|
482 | | - // Phase 2: switch button to 'is-measuring' — opacity:0 / visibility:hidden |
483 | | - // but natural width — so secondaryNavRects captures the button's real width. |
484 | | - // The button is invisible to the user; nav items are still visually-hidden. |
485 | | - isOverflowVisibleForMeasurement.value = true; |
486 | | - await nextTick(); // let Vue render the 'is-measuring' class (button at natural width) |
487 | | - await updateNavigationConfig("phase2"); // secondaryNavRects now includes button width |
488 | | -
|
489 | | - // Wait for Vue to flush the updated mainNavigationMarginBlockEndStr via v-bind |
490 | | - // before reading item positions — without this tick, getBoundingClientRect() |
491 | | - // in initMainNavigationState would see the pre-phase-2 margin-inline-end. |
492 | | - await nextTick(); |
493 | | -
|
494 | | - // Final visibility pass — reads item rects against the correct margin. |
495 | | - // Also sets isGeometryReady = true. |
496 | | - initMainNavigationState(); |
497 | | - // Hand control back to showOverflowDetails; measurement flag no longer needed. |
498 | | - isOverflowVisibleForMeasurement.value = false; |
499 | | - } else { |
500 | | - // ─── Normal resize after geometry is settled ─────────────────────────── |
501 | | - // The button's state is already correct (driven by showOverflowDetails), |
502 | | - // so a single-pass measurement gives accurate secondaryNavRects. |
503 | | - await updateNavigationConfig("useResizeObserver"); |
504 | | - initMainNavigationState(); |
| 489 | + if (isMeasuring) { |
| 490 | + pendingMeasure = true; |
| 491 | + return; |
| 492 | + } |
| 493 | +
|
| 494 | + isMeasuring = true; |
| 495 | + try { |
| 496 | + do { |
| 497 | + pendingMeasure = false; |
| 498 | +
|
| 499 | + if (!isGeometryReady.value) { |
| 500 | + // ─── Two-pass measurement on initial mount / route change ────────────── |
| 501 | + // |
| 502 | + // We never use a "peek" check to decide whether phase 2 is needed. |
| 503 | + // A peek after phase 1 gives the wrong answer at the breakpoint because: |
| 504 | + // • updateNavigationConfig() sets secondaryNavRects reactively, but |
| 505 | + // v-bind(mainNavigationMarginBlockEndStr) isn't flushed to the DOM until |
| 506 | + // the next Vue render tick — so item rects are still measured against the |
| 507 | + // OLD margin, producing a stale DOM/computed mismatch. |
| 508 | + // • Even with correct timing, checking without the button reserved will |
| 509 | + // always pass marginal items (those that fit only when button is absent). |
| 510 | + // |
| 511 | + // Phase 1: button is 'visually-hidden' (width:0). |
| 512 | + // Measure the wrapper and secondary-nav rects without the button. |
| 513 | + await updateNavigationConfig("phase1"); |
| 514 | +
|
| 515 | + // Phase 2: switch button to 'is-measuring' — opacity:0 / visibility:hidden |
| 516 | + // but natural width — so secondaryNavRects captures the button's real width. |
| 517 | + // The button is invisible to the user; nav items are still visually-hidden. |
| 518 | + isOverflowVisibleForMeasurement.value = true; |
| 519 | + await nextTick(); // let Vue render the 'is-measuring' class (button at natural width) |
| 520 | + await updateNavigationConfig("phase2"); // secondaryNavRects now includes button width |
| 521 | +
|
| 522 | + // Wait for Vue to flush the updated mainNavigationMarginBlockEndStr via v-bind |
| 523 | + // before reading item positions — without this tick, getBoundingClientRect() |
| 524 | + // in initMainNavigationState would see the pre-phase-2 margin-inline-end. |
| 525 | + await nextTick(); |
| 526 | +
|
| 527 | + // Final visibility pass — reads item rects against the correct margin. |
| 528 | + // Also sets isGeometryReady = true. |
| 529 | + initMainNavigationState(); |
| 530 | + // Hand control back to showOverflowDetails; measurement flag no longer needed. |
| 531 | + isOverflowVisibleForMeasurement.value = false; |
| 532 | + } else { |
| 533 | + // ─── Normal resize after geometry is settled ─────────────────────────── |
| 534 | + // The button's state is already correct (driven by showOverflowDetails), |
| 535 | + // so a single-pass measurement gives accurate secondaryNavRects. |
| 536 | + await updateNavigationConfig("useResizeObserver"); |
| 537 | + initMainNavigationState(); |
| 538 | + } |
| 539 | + } while (pendingMeasure); |
| 540 | + } finally { |
| 541 | + isMeasuring = false; |
505 | 542 | } |
506 | 543 | }); |
507 | 544 |
|
|
0 commit comments