Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

}
154 changes: 105 additions & 49 deletions docroot/themes/custom/mass_theme/overrides/js/glossaries.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
function findMatches(context) {
const matches = [];
const mainContent = document.querySelector(mainContentSelector);
const foundSearchStrings = new Set();

// Use cached mainContent
if (!mainContent) {
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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);

Expand Down