diff --git a/docroot/modules/custom/mass_content/tests/src/ExistingSiteJavascript/GlossaryPopoverTest.php b/docroot/modules/custom/mass_content/tests/src/ExistingSiteJavascript/GlossaryPopoverTest.php index 2f99b5fb67..f77bea0700 100644 --- a/docroot/modules/custom/mass_content/tests/src/ExistingSiteJavascript/GlossaryPopoverTest.php +++ b/docroot/modules/custom/mass_content/tests/src/ExistingSiteJavascript/GlossaryPopoverTest.php @@ -248,4 +248,173 @@ public function testGlossaryPopoverPunctuationDifferences() { $this->assertNotNull($dialog); } + /** + * Test that overlapping glossary terms prefer the longer match. + */ + public function testGlossaryPopoverOverlappingTermsUseLongestMatch() { + + $glossary = $this->createNode([ + 'type' => 'glossary', + 'title' => 'Overlap Glossary', + 'field_terms' => [ + [ + 'key' => 'Audit', + 'value' => 'Short definition', + ], + [ + 'key' => 'Audit Report', + 'value' => 'Long definition', + ], + ], + 'moderation_state' => 'published', + ]); + + $node = $this->createNode([ + 'type' => 'service_page', + 'title' => 'Overlap Test Service Page', + 'field_service_body' => 'The audit reports were reviewed.', + 'moderation_state' => 'published', + ]); + + $node->set('field_glossaries', $glossary); + $node->save(); + + $this->drupalGet($node->toUrl()->toString()); + $page = $this->getSession()->getPage(); + + $page->waitFor(10, function () use ($page) { + return count($page->findAll('css', '.popover__trigger')) === 1; + }); + + $triggers = $page->findAll('css', '.popover__trigger'); + $this->assertCount(1, $triggers, 'Expected only one glossary trigger for overlapping glossary terms.'); + $this->assertSame('audit reports', $triggers[0]->getText()); + + $dialog = $page->find('css', '.popover__dialog'); + $this->assertNotNull($dialog); + + $triggers[0]->click(); + $this->assertTrue($dialog->isVisible()); + $this->assertSession()->elementTextContains('css', '.popover__dialog', 'Long definition'); + $this->assertSession()->pageTextContains('The audit reports were reviewed.'); + $this->assertSession()->pageTextNotContains('audit reportsaudits'); + } + + /** + * Test that shorter glossary terms still match elsewhere on the page. + */ + public function testGlossaryPopoverSeparateTermsStillMatch() { + + $glossary = $this->createNode([ + 'type' => 'glossary', + 'title' => 'Separate Overlap Glossary', + 'field_terms' => [ + [ + 'key' => 'Audit', + 'value' => 'Short definition', + ], + [ + 'key' => 'Audit Report', + 'value' => 'Long definition', + ], + ], + 'moderation_state' => 'published', + ]); + + $node = $this->createNode([ + 'type' => 'service_page', + 'title' => 'Separate Overlap Test Service Page', + 'field_service_body' => 'The audit reports were reviewed. Another audit happened later.', + 'moderation_state' => 'published', + ]); + + $node->set('field_glossaries', $glossary); + $node->save(); + + $this->drupalGet($node->toUrl()->toString()); + $page = $this->getSession()->getPage(); + + $page->waitFor(10, function () use ($page) { + return count($page->findAll('css', '.popover__trigger')) === 2; + }); + + $triggers = $page->findAll('css', '.popover__trigger'); + $this->assertCount(2, $triggers, 'Expected both glossary triggers when terms are used separately.'); + $this->assertSame('audit reports', $triggers[0]->getText()); + $this->assertSame('audit', $triggers[1]->getText()); + $this->assertSession()->pageTextContains('The audit reports were reviewed. Another audit happened later.'); + } + + /** + * Test that each glossary term is only highlighted once per page. + */ + public function testGlossaryPopoverOnlyHighlightsFirstOccurrencePerTerm() { + + $glossary = $this->createNode([ + 'type' => 'glossary', + 'title' => 'Single Highlight Glossary', + 'field_terms' => [ + [ + 'key' => 'Audit', + 'value' => 'Short definition', + ], + [ + 'key' => 'Audit Report', + 'value' => 'Long definition', + ], + ], + 'moderation_state' => 'published', + ]); + + $node = $this->createNode([ + 'type' => 'service_page', + 'title' => 'Single Highlight Test Service Page', + 'field_service_body' => 'The audit reports were reviewed. Another audit happened later. A final audit report was archived.', + 'moderation_state' => 'published', + ]); + + $node->set('field_glossaries', $glossary); + $node->save(); + + $this->drupalGet($node->toUrl()->toString()); + $page = $this->getSession()->getPage(); + + $page->waitFor(10, function () use ($page) { + return count($page->findAll('css', '.popover__trigger')) === 2; + }); + + $triggers = $page->findAll('css', '.popover__trigger'); + $this->assertCount(2, $triggers, 'Expected one highlight per glossary term.'); + $this->assertSame('audit reports', $triggers[0]->getText()); + $this->assertSame('audit', $triggers[1]->getText()); + } + + /** + * Test that trailing punctuation stays grouped with glossary terms. + */ + public function testGlossaryPopoverKeepsTrailingPunctuationWithTerm() { + + $node = $this->createNode([ + 'type' => 'service_page', + 'title' => 'Glossary Punctuation Test Service Page', + 'field_service_body' => 'Test definition popover ' . $this->term . ', followed by more text.', + 'moderation_state' => 'published', + ]); + + $node->set('field_glossaries', $this->glossary); + $node->save(); + + $this->drupalGet($node->toUrl()->toString()); + $page = $this->getSession()->getPage(); + + $page->waitFor(10, function () use ($page) { + return $page->find('css', '.glossary-term-group') !== NULL; + }); + + $group = $page->find('css', '.glossary-term-group'); + $this->assertNotNull($group); + $this->assertStringContainsString('white-space: nowrap', $group->getAttribute('style')); + $this->assertStringContainsString($this->term . ',', $group->getText()); + } + } diff --git a/docroot/themes/custom/mass_theme/overrides/js/glossaries.js b/docroot/themes/custom/mass_theme/overrides/js/glossaries.js index 915a1c4d83..3fd08fc111 100644 --- a/docroot/themes/custom/mass_theme/overrides/js/glossaries.js +++ b/docroot/themes/custom/mass_theme/overrides/js/glossaries.js @@ -109,6 +109,7 @@ function findMatches(context) { const matches = []; const mainContent = document.querySelector(mainContentSelector); + const foundSearchStrings = new Set(); // Use cached mainContent if (!mainContent) { @@ -141,25 +142,31 @@ let node = walker.nextNode(); while (node) { const text = node.textContent; + const nodeMatches = []; - // Quit looping over nodes if we've used all the terms. - if (!searchRegexes.size) { - break; - } - - // Loop over unfound terms + // Check all glossary terms against each eligible text node. for (const [searchString, searchRegex] of searchRegexes) { - if (text.match(searchRegex)) { - matches.push({ - node, - searchString, - searchRegex - }); - - // Avoid searching for this term again. - searchRegexes.delete(searchString); + if (foundSearchStrings.has(searchString)) { + continue; } + + nodeMatches.push(...findMatchPositions(text, searchRegex, searchString)); } + + const nonOverlappingMatches = filterOverlappingMatches(nodeMatches); + + nonOverlappingMatches.forEach(match => { + if (foundSearchStrings.has(match.searchString)) { + return; + } + + matches.push({ + node, + ...match + }); + foundSearchStrings.add(match.searchString); + }); + node = walker.nextNode(); } @@ -211,9 +218,82 @@ return element; } + /** + * Wrap a glossary term and trailing punctuation so they stay on the same line. + * @param {HTMLElement} tooltip - The glossary tooltip element. + * @param {string} punctuation - Punctuation that immediately follows the term. + * @return {HTMLSpanElement} Inline wrapper containing the tooltip and punctuation. + */ + function createTooltipGroup(tooltip, punctuation) { + const wrapper = document.createElement('span'); + wrapper.classList.add('glossary-term-group'); + wrapper.style.whiteSpace = 'nowrap'; + wrapper.appendChild(tooltip); + + if (punctuation) { + wrapper.appendChild(document.createTextNode(punctuation)); + } + + return wrapper; + } + + /** + * Filter out overlapping matches, preferring the longest match. + * @param {Array<{start: number, end: number, matchText: string, searchString: string}>} matchPositions - Candidate matches. + * @return {Array<{start: number, end: number, matchText: string, searchString: string}>} Non-overlapping matches. + */ + function filterOverlappingMatches(matchPositions) { + const sortedMatches = [...matchPositions].sort((a, b) => { + if (a.start !== b.start) { + return a.start - b.start; + } + + return (b.end - b.start) - (a.end - a.start); + }); + const filteredMatches = []; + + sortedMatches.forEach(match => { + const overlapsExistingMatch = filteredMatches.some(filteredMatch => { + return match.start < filteredMatch.end && match.end > filteredMatch.start; + }); + + if (!overlapsExistingMatch) { + filteredMatches.push(match); + } + }); + + return filteredMatches; + } + + /** + * Find every occurrence of a glossary term within a text node. + * @param {string} text - Text node content. + * @param {RegExp} searchRegex - Regex used to find glossary terms. + * @param {string} searchString - Glossary term label. + * @return {Array<{start: number, end: number, matchText: string, searchString: string}>} Matches in the text. + */ + function findMatchPositions(text, searchRegex, searchString) { + const globalRegex = new RegExp(searchRegex.source, 'gi'); + const matches = []; + let match = globalRegex.exec(text); + + while (match) { + matches.push({ + start: match.index, + end: match.index + match[0].length, + matchText: match[0], + searchString + }); + + match = globalRegex.exec(text); + } + + return matches; + } + /** * Highlight matches in the text. - * @param {Array<{node: Node, searchRegex: RegExp, searchString: string}>} matches - Array of matches containing text nodes, search regex and search string + * @param {Array<{node: Node, start: number, end: number, matchText: string, searchString: string}>} matches - Array of matches containing text nodes and match positions. * @return {void} */ function highlightMatches(matches) { @@ -236,51 +316,27 @@ let text = node.textContent; let currentNode = node; - // Create a set to track processed search strings for THIS node only - const processedSearchStrings = new Set(); - - // Find all matches and their positions first - const matchPositions = []; - - nodeMatches.forEach(({searchRegex, searchString}) => { - // Skip if this search string has already been processed for this node - if (processedSearchStrings.has(searchString)) { - return; - } - - // Reset regex lastIndex - searchRegex.lastIndex = 0; - - const match = searchRegex.exec(text); - if (match) { - matchPositions.push({ - start: match.index, - end: match.index + match[0].length, - matchText: match[0], - searchString: searchString - }); - - // Mark this search string as processed for this node only - processedSearchStrings.add(searchString); - } - }); - // Sort match positions by their start index (descending) - matchPositions.sort((a, b) => b.start - a.start); + nodeMatches.sort((a, b) => b.start - a.start); // Process matches from right to left to avoid position shifts - matchPositions.forEach(({start, end, matchText, searchString}) => { + nodeMatches.forEach(({start, end, matchText, searchString}) => { + const trailingPunctuationMatch = text.substring(end).match(/^[,.;:!?]+/); + const trailingPunctuation = trailingPunctuationMatch ? trailingPunctuationMatch[0] : ''; + const afterTextStart = end + trailingPunctuation.length; + // Create text nodes for before and after the match const beforeText = document.createTextNode(text.substring(0, start)); - const afterText = document.createTextNode(text.substring(end)); + const afterText = document.createTextNode(text.substring(afterTextStart)); // Create the tooltip const definition = createTooltipContent(terms[searchString]); const tooltip = createTooltip(matchText, definition); + const tooltipGroup = createTooltipGroup(tooltip, trailingPunctuation); // Replace the original text node parent.insertBefore(beforeText, currentNode); - parent.insertBefore(tooltip, currentNode); + parent.insertBefore(tooltipGroup, currentNode); parent.insertBefore(afterText, currentNode); parent.removeChild(currentNode);