components/search/search_box.vue (212 lines of code) (raw):

<script> import lunr from 'lunr'; import { GlSearchBoxByType, GlDropdownItem } from '../../helpers/gitlab_ui'; import fixUrlInReviewApp from '../../helpers/fix_url_in_review_app'; import SearchResult from './search_result.vue'; const STATUS_SEARCHING = 'STATUS_SEARCHING'; const STATUS_NO_RESULTS = 'STATUS_NO_RESULTS'; const STATUS_ERROR = 'STATUS_ERROR'; const STATUS_MESSAGE = { [STATUS_SEARCHING]: 'Searching...', [STATUS_NO_RESULTS]: 'No results found', [STATUS_ERROR]: 'Could not load search index', }; export default { components: { GlSearchBoxByType, GlDropdownItem, SearchResult, }, data() { return { statusId: null, searchIndex: null, searchMeta: null, searchText: '', searchResults: [], resultsVisible: false, }; }, async fetch() { const url = fixUrlInReviewApp(`/_nuxt/search-index/en.json`); try { const searchJson = await fetch(url).then((res) => { if (res.status === 200) { return res.json(); } return this.setStatus(STATUS_ERROR); }); this.searchMeta = searchJson.metas || null; this.searchIndex = lunr.Index.load(searchJson); } catch { this.setStatus(STATUS_ERROR); } }, computed: { statusMsg() { return STATUS_MESSAGE[this.statusId] || ''; }, }, watch: { searchText(searchText) { if (!searchText) { this.closeResults(); return; } clearTimeout(this.searchTimeout); this.searchTimeout = setTimeout(() => this.search(searchText), 200); }, resultsVisible(resultsVisible) { if (resultsVisible) { this.addBodyListener(); } else { this.removeBodyListener(); } }, }, methods: { addBodyListener() { document.body.addEventListener('mousedown', this.bodyListener); }, removeBodyListener() { document.body.removeEventListener('mousedown', this.bodyListener); }, bodyListener(event) { if (this.$refs.lunr && !this.$refs.lunr.contains(event.target)) { this.resultsVisible = false; } }, openResults() { this.resultsVisible = true; }, closeResults() { this.resultsVisible = false; this.removeBodyListener(); this.clearStatus(); }, search(txt) { if (!this.searchIndex) { return; } this.setStatus(STATUS_SEARCHING); const searchTokens = txt.split(' ').map((term) => { // Remove ':' to prevent field-specific search // https://lunrjs.com/guides/searching.html#fields const searchTerm = term.replace(':', ''); if (!searchTerm) { return ''; } // Add a wildcard to match words that begin with the search term // https://lunrjs.com/guides/searching.html#wildcards let queryString = `${searchTerm}*`; // Add a bit of fuzziness to the search term // https://lunrjs.com/guides/searching.html#fuzzy-matches queryString += ` ${searchTerm}~1`; return queryString; }); const searchResults = this.searchIndex.search(searchTokens.join(' ')); this.searchResults = searchResults.filter(({ score }) => score > 0); if (!this.searchResults?.length) { this.setStatus(STATUS_NO_RESULTS); } else { this.clearStatus(); } this.openResults(); }, setStatus(id) { this.statusId = id; }, clearStatus() { this.statusId = null; }, getResultMeta(ref) { return this.searchMeta?.[ref]; }, getFocusedResult() { return this.$refs.results?.querySelector(':focus'); }, keyEnter() { const el = this.getFocusedResult(); if (el) { el.click(); this.closeResults(); } }, keyUp() { if (!this.resultsVisible) { return; } const el = this.getFocusedResult(); if (!el) { return; } const previousResult = el.parentElement.previousSibling; if (previousResult.querySelector) { previousResult.querySelector('a').focus(); } else { this.$refs.input.$el.querySelector('input').focus(); } }, keyDown() { if (!this.resultsVisible) { return; } const el = this.getFocusedResult(); if (!el) { this.$refs.results.querySelector(':first-child a')?.focus(); return; } const nextResult = el.parentElement.nextSibling?.querySelector('a'); if (nextResult) { nextResult.focus(); } }, onFocus() { if (this.searchResults.length) { this.resultsVisible = true; } }, }, }; </script> <template> <div ref="lunr" class="gl-relative" @keydown.enter.prevent="keyEnter" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.esc="closeResults" > <gl-search-box-by-type ref="input" v-model="searchText" aria-label="Search" aria-haspopup="true" :aria-expanded="resultsVisible" autocomplete="off" spellcheck="false" @focus="onFocus" /> <ul v-if="resultsVisible" role="menu" tabindex="-1" class="gl-new-dropdown dropdown-menu show !gl-bg-overlap !gl-shadow-sm" > <div class="gl-new-dropdown-inner"> <div ref="results" class="gl-new-dropdown-contents"> <gl-dropdown-item v-if="statusMsg"> {{ statusMsg }} </gl-dropdown-item> <search-result v-for="{ ref } in searchResults" :key="ref" :meta="getResultMeta(ref)" @click.prevent="closeResults" /> </div> </div> </ul> </div> </template>