190 lines
5 KiB
Vue
190 lines
5 KiB
Vue
<script>
|
|
import { GlSearchBoxByType } from '@gitlab/ui';
|
|
import { escapeRegExp } from 'lodash';
|
|
import {
|
|
EXCLUDED_NODES,
|
|
HIDE_CLASS,
|
|
HIGHLIGHT_CLASS,
|
|
NONE_PADDING_CLASS,
|
|
TYPING_DELAY,
|
|
} from '../constants';
|
|
|
|
const origExpansions = new Map();
|
|
|
|
const findSettingsSection = (sectionSelector, node) => {
|
|
return node.parentElement.closest(sectionSelector);
|
|
};
|
|
|
|
const restoreExpansionState = ({ expandSection, collapseSection }) => {
|
|
origExpansions.forEach((isExpanded, section) => {
|
|
if (isExpanded) {
|
|
expandSection(section);
|
|
} else {
|
|
collapseSection(section);
|
|
}
|
|
});
|
|
|
|
origExpansions.clear();
|
|
};
|
|
|
|
const saveExpansionState = (sections, { isExpanded }) => {
|
|
// If we've saved expansions before, don't override it.
|
|
if (origExpansions.size > 0) {
|
|
return;
|
|
}
|
|
|
|
sections.forEach((section) => origExpansions.set(section, isExpanded(section)));
|
|
};
|
|
|
|
const resetSections = ({ sectionSelector }) => {
|
|
document.querySelectorAll(sectionSelector).forEach((section) => {
|
|
section.classList.remove(HIDE_CLASS);
|
|
});
|
|
};
|
|
|
|
const clearHighlights = () => {
|
|
document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((element) => {
|
|
const { parentNode } = element;
|
|
const textNode = document.createTextNode(element.textContent);
|
|
parentNode.replaceChild(textNode, element);
|
|
|
|
parentNode.normalize();
|
|
});
|
|
};
|
|
|
|
const hideSectionsExcept = (sectionSelector, visibleSections) => {
|
|
Array.from(document.querySelectorAll(sectionSelector))
|
|
.filter((section) => !visibleSections.includes(section))
|
|
.forEach((section) => {
|
|
section.classList.add(HIDE_CLASS);
|
|
});
|
|
};
|
|
|
|
const highlightTextNode = (textNode, searchTerm) => {
|
|
const escapedSearchTerm = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
|
|
const textList = textNode.data.split(escapedSearchTerm);
|
|
|
|
return textList.reduce((documentFragment, text) => {
|
|
let addElement;
|
|
|
|
if (escapedSearchTerm.test(text)) {
|
|
addElement = document.createElement('mark');
|
|
addElement.className = `${HIGHLIGHT_CLASS} ${NONE_PADDING_CLASS}`;
|
|
addElement.textContent = text;
|
|
escapedSearchTerm.lastIndex = 0;
|
|
} else {
|
|
addElement = document.createTextNode(text);
|
|
}
|
|
|
|
documentFragment.appendChild(addElement);
|
|
return documentFragment;
|
|
}, document.createDocumentFragment());
|
|
};
|
|
|
|
const highlightText = (textNodes = [], searchTerm) => {
|
|
textNodes.forEach((textNode) => {
|
|
const fragmentWithHighlights = highlightTextNode(textNode, searchTerm);
|
|
textNode.parentElement.replaceChild(fragmentWithHighlights, textNode);
|
|
});
|
|
};
|
|
|
|
const displayResults = ({ sectionSelector, expandSection, searchTerm }, matchingTextNodes) => {
|
|
const sections = Array.from(
|
|
new Set(matchingTextNodes.map((node) => findSettingsSection(sectionSelector, node))),
|
|
);
|
|
|
|
hideSectionsExcept(sectionSelector, sections);
|
|
sections.forEach(expandSection);
|
|
highlightText(matchingTextNodes, searchTerm);
|
|
};
|
|
|
|
const clearResults = (params) => {
|
|
resetSections(params);
|
|
clearHighlights();
|
|
};
|
|
|
|
const includeNode = (node, lowerSearchTerm) =>
|
|
node.textContent.toLowerCase().includes(lowerSearchTerm) &&
|
|
EXCLUDED_NODES.every((excluded) => !node.parentElement.closest(excluded));
|
|
|
|
const search = (root, searchTerm) => {
|
|
const iterator = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, {
|
|
acceptNode(node) {
|
|
return includeNode(node, searchTerm.toLowerCase())
|
|
? NodeFilter.FILTER_ACCEPT
|
|
: NodeFilter.FILTER_REJECT;
|
|
},
|
|
});
|
|
const textNodes = [];
|
|
|
|
for (let currentNode = iterator.nextNode(); currentNode; currentNode = iterator.nextNode()) {
|
|
textNodes.push(currentNode);
|
|
}
|
|
|
|
return textNodes;
|
|
};
|
|
|
|
export default {
|
|
components: {
|
|
GlSearchBoxByType,
|
|
},
|
|
props: {
|
|
searchRoot: {
|
|
type: Element,
|
|
required: true,
|
|
},
|
|
sectionSelector: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
isExpandedFn: {
|
|
type: Function,
|
|
required: false,
|
|
// default to a function that returns false
|
|
default: () => () => false,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
searchTerm: '',
|
|
};
|
|
},
|
|
methods: {
|
|
search(value) {
|
|
this.searchTerm = value;
|
|
const displayOptions = {
|
|
sectionSelector: this.sectionSelector,
|
|
expandSection: this.expandSection,
|
|
collapseSection: this.collapseSection,
|
|
isExpanded: this.isExpandedFn,
|
|
searchTerm: this.searchTerm,
|
|
};
|
|
|
|
clearResults(displayOptions);
|
|
|
|
if (value.length) {
|
|
saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions);
|
|
|
|
displayResults(displayOptions, search(this.searchRoot, this.searchTerm));
|
|
} else {
|
|
restoreExpansionState(displayOptions);
|
|
}
|
|
},
|
|
expandSection(section) {
|
|
this.$emit('expand', section);
|
|
},
|
|
collapseSection(section) {
|
|
this.$emit('collapse', section);
|
|
},
|
|
},
|
|
TYPING_DELAY,
|
|
};
|
|
</script>
|
|
<template>
|
|
<gl-search-box-by-type
|
|
:value="searchTerm"
|
|
:debounce="$options.TYPING_DELAY"
|
|
:placeholder="__('Search page')"
|
|
@input="search"
|
|
/>
|
|
</template>
|