diff --git a/packages/core/src/html/siteAndPageNavProcessor.ts b/packages/core/src/html/siteAndPageNavProcessor.ts index fcd39d8984..beaa78f950 100644 --- a/packages/core/src/html/siteAndPageNavProcessor.ts +++ b/packages/core/src/html/siteAndPageNavProcessor.ts @@ -104,7 +104,7 @@ export function renderSiteNav(node: MbNode) { $(ulElem).children('li').each((_i2, liElem) => { const nestedLists = $(liElem).children('ul'); - const nestedAnchors = $(liElem).children('a'); + const nestedAnchors = $(liElem).find('a'); if (nestedLists.length === 0 && nestedAnchors.length === 0) { $(liElem).addClass(customListItemClasses); return; diff --git a/packages/vue-components/src/Navbar.vue b/packages/vue-components/src/Navbar.vue index 76090244d9..5756201e07 100644 --- a/packages/vue-components/src/Navbar.vue +++ b/packages/vue-components/src/Navbar.vue @@ -155,63 +155,77 @@ export default { const defHlMode = this.defaultHighlightOn; const navLis = []; this.$el.querySelectorAll('.navbar-nav').forEach(nav => navLis.push(...Array.from(nav.children))); - // attempt an exact match first + + // Each li element in navbar grouped with all its own and children links + const allNavLinkGroups = []; for (let i = 0; i < navLis.length; i += 1) { const li = navLis[i]; const standardLinks = [li]; const navLinks = Array.from(li.querySelectorAll('a.nav-link')); const dropdownLinks = Array.from(li.querySelectorAll('a.dropdown-item')); - const allNavLinks = standardLinks.concat(navLinks).concat(dropdownLinks).filter(a => a.href); - for (let j = 0; j < allNavLinks.length; j += 1) { - const a = allNavLinks[j]; + const linksInLi = standardLinks.concat(navLinks).concat(dropdownLinks).filter(a => a.href); + + allNavLinkGroups.push({ + li, + links: linksInLi, + dropdownLinks, + }); + } + + // 1: Check for Exact Match,return immediately if found + for (let i = 0; i < allNavLinkGroups.length; i += 1) { + const group = allNavLinkGroups[i]; + for (let j = 0; j < group.links.length; j += 1) { + const a = group.links[j]; const hlMode = a.getAttribute('highlight-on') || defHlMode; - if (hlMode === 'none') { - // eslint-disable-next-line no-continue - continue; - } - // terminate early on an exact match - if (this.isExact(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); + if (hlMode !== 'none' && this.isExact(url, a.href)) { + group.li.classList.add('current'); + this.addClassIfDropdown(group.dropdownLinks, a, group.li); return; } } } - // fallback to user preference, otherwise - for (let i = 0; i < navLis.length; i += 1) { - const li = navLis[i]; - const standardLinks = [li]; - const navLinks = Array.from(li.querySelectorAll('a.nav-link')); - const dropdownLinks = Array.from(li.querySelectorAll('a.dropdown-item')); - const allNavLinks = standardLinks.concat(navLinks).concat(dropdownLinks).filter(a => a.href); - for (let j = 0; j < allNavLinks.length; j += 1) { - const a = allNavLinks[j]; + + // 2: Find Best Fuzzy Match + // Strategies: 'sibling-or-child' (default), 'sibling', 'child' + // Tie-breaker: Longest path length (most specific match) + let bestMatch = null; + let maxPathLength = -1; + + allNavLinkGroups.forEach((group) => { + group.links.forEach((a) => { const hlMode = a.getAttribute('highlight-on') || defHlMode; - if (hlMode === 'none') { - // eslint-disable-next-line no-continue - continue; - } - // Ignores invalid navbar highlight rule - if (hlMode === 'sibling-or-child') { - if (this.isSibling(url, a.href) || this.isChild(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); - return; - } - } else if (hlMode === 'sibling') { - if (this.isSibling(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); - return; + if (hlMode !== 'none') { + let isMatch = false; + if (hlMode === 'sibling-or-child') { + isMatch = this.isSibling(url, a.href) || this.isChild(url, a.href); + } else if (hlMode === 'sibling') { + isMatch = this.isSibling(url, a.href); + } else if (hlMode === 'child') { + isMatch = this.isChild(url, a.href); } - } else if (hlMode === 'child') { - if (this.isChild(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); - return; + + if (isMatch) { + const linkParts = this.splitUrl(a.href); + const pathLength = linkParts.length; + + if (pathLength > maxPathLength) { + maxPathLength = pathLength; + bestMatch = { + li: group.li, + a, + dropdownLinks: group.dropdownLinks, + }; + } } } - } + }); + }); + + // Apply the highlight to the best match found (if any) + if (bestMatch) { + bestMatch.li.classList.add('current'); + this.addClassIfDropdown(bestMatch.dropdownLinks, bestMatch.a, bestMatch.li); } }, toggleLowerNavbar() { diff --git a/packages/vue-components/src/__tests__/Navbar.spec.js b/packages/vue-components/src/__tests__/Navbar.spec.js index 4953a844ba..dead88df76 100644 --- a/packages/vue-components/src/__tests__/Navbar.spec.js +++ b/packages/vue-components/src/__tests__/Navbar.spec.js @@ -157,3 +157,49 @@ describe('Mobile nav buttons test:', () => { wrapper.unmount(); }); }); + +describe('Navbar Highlight Logic', () => { + test('correctly highlights the deepest matching link (Best Match)', async () => { + const deepUrl = 'http://localhost/lessons/trail/recordingFolderHistory/index.html'; + const TEST_NAVBAR_CONTENT = ` +
  • Home
  • +
  • Lessons
  • +
  • Trail
  • +
  • About
  • + `; + + // Test highlightLink(url) method on the component. + // Mount it, then call highlightLink manually with test URL. + const wrapper = mount(Navbar, { + slots: { + brand: NAVBAR_BRAND, + default: TEST_NAVBAR_CONTENT, + }, + global: { + stubs: DEFAULT_STUBS, + }, + }); + + // Clear any initial highlighting from mounted() + wrapper.findAll('li').forEach(li => li.element.classList.remove('current')); + + // Manually trigger highlight with the problematic URL + wrapper.vm.highlightLink(deepUrl); + await wrapper.vm.$nextTick(); + + const homeLink = wrapper.find('a[href="/index.html"]').element; + const lessonsLink = wrapper.find('a[href="/lessons/index.html"]').element; + const trailLink = wrapper.find('a[href="/lessons/trail/index.html"]').element; + + const homeLi = homeLink.closest('li'); + const lessonsLi = lessonsLink.closest('li'); + const trailLi = trailLink.closest('li'); + + // Expectation: Trail should be current because it is the deepest matching link (depth 3 vs 2 vs 1) + expect(trailLi.classList.contains('current')).toBe(true); + + // Expectation: Shallower matches should NOT be current + expect(lessonsLi.classList.contains('current')).toBe(false); + expect(homeLi.classList.contains('current')).toBe(false); + }); +});