UNCLASSIFIED - NO CUI

Skip to content
Snippets Groups Projects
Search.vue 6.22 KiB
<template>
  <div
    class="search-container h-100 d-flex align-items-center"
    :class="{ 'full-width': fullWidth, closed: !isExpanded }"
  >
    <v-text-field
      placeholder="Search"
      data-cy="search"
      filled
      dense
      clearable
      full-width
      :hide-details="true"
      class="expanding-search mt-n1"
      v-model="searchText"
      prepend-inner-icon="mdi-magnify"
      @focus="expand"
      @blur="blur"
      @input="change"
      :loading="searchLoading"
    >
      <template v-slot:progress>
        <v-progress-linear
          indeterminate
          absolute
          color="primary"
        ></v-progress-linear>
      </template>
    </v-text-field>
    <div class="results-container">
      <div
        data-cy="emptySearchResults"
        class="empty-results"
        v-if="
          searchText &&
          !searchLoading &&
          displayResults &&
          displayResults.length === 0
        "
      >
        No search results found
      </div>
      <div
        data-cy="searchResults"
        class="results"
        v-else-if="displayResults && displayResults.length > 0"
      >
        <template v-for="(result, index) in displayResults">
          <div class="result" :key="index">
            <a class="results-link" :href="result.url">
              <cite>{{ result.url }}</cite>
              <span class="title">{{ result.title }}</span>
            </a>
            <div
              class="results-description text-left"
              v-if="result.match.description"
              v-html="result.match.description"
            ></div>
            <div
              class="results-content text-left"
              v-if="result.match.content"
              v-html="result.match.content"
            ></div>
          </div>
        </template>
      </div>
    </div>
  </div>
</template>
<script>
import Search from "@/api/search.js";
import { routesByPath } from "@/router/routes.js";

export default {
  name: "SearchComponent",
  props: {
    fullWidth: {
      type: Boolean,
      default: false,
    },
  },
  data: () => ({
    searchClosed: true,
    searchText: null,
    searchLoading: false,
    debounce: null,
    searchApi: null,
    displayResults: null,
  }),
  mounted() {
    this.searchApi = new Search();
  },
  methods: {
    expand() {
      this.searchClosed = false;
      this.$emit("expanded");
    },
    blur() {
      this.searchClosed = true;
      this.searchLoading = false;
      if (!this.isExpanded) {
        this.$emit("collapsed");
      }
    },
    change() {
      // cancel any pending search (debounce)
      if (this.debounce) {
        clearTimeout(this.debounce);
      }
      // empty text short-circuit
      if (!this.searchText) {
        this.displayResults = [];
        return;
      }
      this.searchLoading = true;
      // debounce the call to search to only execute once per time window
      this.debounce = setTimeout(async () => {
        this.search();
        this.searchLoading = false;
      }, 300);
    },
    /**
     * Perform a search using the search api
     */
    search() {
      const searchResults = this.searchApi.search(this.searchText);

      this.displayResults = searchResults.map((result) => ({
        url: `${window.location.origin}${result.ref}`,
        title:
          routesByPath[result.ref]?.meta.title ||
          routesByPath[result.ref]?.header?.title,
        match: this.getMatch(result),
      }));
    },
    /**
     * Logic to calculate the match details of a specific result to display.
     *
     * @returns {Object} matchResult
     * @returns {String} matchResult.content - html string for content, including highlights
     * @returns {String} matchResult.description - html string for description, including highlights
     */
    getMatch(result) {
      const context = this.searchApi.getContext(result, 140);
      const bestContentContext = this.searchApi.getBestContentContext(context);

      let content = bestContentContext;
      let description = "";

      for (const matchMeta of Object.values(context)) {
        if (matchMeta.content) {
          content = this.searchApi.highlight(matchMeta.content, content);
        }
        if (matchMeta.description) {
          description = this.searchApi.highlight(
            matchMeta.description,
            description
          );
        }
      }

      return {
        content,
        description,
      };
    },
  },
  computed: {
    isExpanded() {
      return !this.searchClosed || this.searchText;
    },
  },
};
</script>
<style lang="scss" scoped>
.search-container {
  &.full-width {
    .expanding-search,
    .results-container {
      max-width: 100%;
    }
  }

  .expanding-search,
  .results-container {
    position: absolute;
    right: 0;
    width: 100%;
    max-width: 800px;
  }

  &.full-width.closed {
    .expanding-search {
      right: 64px;
    }
  }

  &.closed {
    .expanding-search {
      max-width: 45px;
      :deep(.v-input__slot) {
        cursor: pointer !important;
        background: transparent !important;

        :deep(.v-input__icon--append) {
          display: none;
        }
      }
    }
  }

  .expanding-search {
    transition: max-width 0.25s;
    min-width: 45px;

    :deep(.v-input__slot) {
      &:before,
      &:after {
        border-color: transparent !important;
        background-color: transparent;
      }
    }
  }

  .results-container {
    top: 68px;
    background-color: #021827;
    max-height: 80vh;
    overflow-y: scroll;
    padding-left: 16px;
    padding-right: 16px;

    .results > .result {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      margin-top: 32px;
      margin-bottom: 32px;
      &:first-child {
        margin-top: 16px;
      }
      &:last-child {
        margin-bottom: 16px;
      }

      color: #fcfafa;

      .results-link {
        text-align: left;
        &:hover {
          color: lighten($link-color, 10%) !important;
        }

        cite {
          font-style: normal;
          color: #fcfafa;
          display: block;
          font-size: 14px;
        }
      }

      .results-content {
        color: #bdc1c6;
      }

      :deep(em) {
        font-weight: bold;
        font-style: normal;
        color: $p1-light-green;
      }
    }
  }
}
</style>