Skip to content
This repository has been archived by the owner on Feb 22, 2023. It is now read-only.

Commit

Permalink
Add UI for recent search results (#1867)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhruvkb authored Oct 6, 2022
1 parent 18d6039 commit 83dbe5d
Show file tree
Hide file tree
Showing 13 changed files with 452 additions and 54 deletions.
10 changes: 1 addition & 9 deletions feat/feature-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,9 @@
"staging": "switchable",
"production": "disabled"
},
"description": "Toggle the new header.",
"description": "Toggle the new header, including recent searches.",
"defaultState": "off"
},
"recent_searches": {
"status": {
"staging": "switchable",
"production": "disabled"
},
"description": "Save and display the user's recent searches.",
"defaultState": "on"
},
"feat_disabled": {
"status": "disabled",
"description": "Will always be disabled"
Expand Down
214 changes: 180 additions & 34 deletions src/components/VHeader/VSearchBar/VSearchBar.vue
Original file line number Diff line number Diff line change
@@ -1,42 +1,77 @@
<template>
<form
class="search-bar group flex flex-row items-center rounded-sm border-tx bg-white"
:class="{ 'h-[57px] md:h-[69px]': size === 'standalone' }"
@submit.prevent="handleSearch"
>
<VInputField
:placeholder="placeholder || $t('hero.search.placeholder')"
v-bind="$attrs"
class="search-field flex-grow focus:border-pink"
:class="[route === 'home' ? 'border-tx' : 'border-dark-charcoal-20']"
:label-text="
$t('search.search-bar-label', { openverse: 'Openverse' }).toString()
"
:connection-sides="['end']"
:size="size"
field-id="search-bar"
type="search"
name="q"
:model-value="searchText"
@update:modelValue="updateSearchText"
<div ref="searchBarEl" class="relative">
<form
class="search-bar group flex flex-row items-center rounded-sm border-tx bg-white"
:class="{ 'h-[57px] md:h-[69px]': size === 'standalone' }"
@submit.prevent="handleSearch"
>
<!-- @slot Extra information such as loading message or result count goes here. -->
<slot />
</VInputField>
<VSearchButton type="submit" :size="size" :route="route" />
</form>
<VInputField
v-bind="$attrs"
v-model="modelMedium"
:placeholder="placeholder || $t('hero.search.placeholder')"
class="search-field flex-grow focus:border-pink"
:class="[route === 'home' ? 'border-tx' : 'border-dark-charcoal-20']"
:label-text="
$t('search.search-bar-label', { openverse: 'Openverse' }).toString()
"
:connection-sides="['end']"
:size="size"
field-id="search-bar"
type="search"
autocomplete="off"
name="q"
role="combobox"
aria-autocomplete="none"
:aria-expanded="isRecentVisible"
aria-controls="recent-searches-list"
:aria-activedescendant="
selectedIdx !== undefined ? `option-${selectedIdx}` : undefined
"
@focus="handleFocus"
@keydown="handleKeydown"
>
<!-- @slot Extra information such as loading message or result count goes here. -->
<slot />
</VInputField>
<VSearchButton type="submit" :size="size" :route="route" />
</form>
<VRecentSearches
v-show="isNewHeaderEnabled && isRecentVisible"
:selected-idx="selectedIdx"
:entries="entries"
class="absolute inset-x-0 lg:flex"
:class="recentClasses"
@select="handleSelect"
@clear="handleClear"
/>
</div>
</template>

<script lang="ts">
import { computed, defineComponent, PropType } from '@nuxtjs/composition-api'
import {
computed,
defineComponent,
PropType,
ref,
} from '@nuxtjs/composition-api'
import { onClickOutside } from '@vueuse/core'
import { useMatchHomeRoute } from '~/composables/use-match-routes'
import { defineEvent } from '~/types/emits'
import { useSearchStore } from '~/stores/search'
import { useFeatureFlagStore } from '~/stores/feature-flag'
import { keycodes } from '~/constants/key-codes'
import { cyclicShift } from '~/utils/math'
import VInputField, {
FIELD_SIZES,
} from '~/components/VInputField/VInputField.vue'
import VSearchButton from '~/components/VHeader/VSearchBar/VSearchButton.vue'
import VRecentSearches from '~/components/VRecentSearches/VRecentSearches.vue'
/**
* Displays a text field for a search query and is attached to an action button
Expand All @@ -45,7 +80,7 @@ import VSearchButton from '~/components/VHeader/VSearchBar/VSearchButton.vue'
*/
export default defineComponent({
name: 'VSearchBar',
components: { VInputField, VSearchButton },
components: { VRecentSearches, VInputField, VSearchButton },
inheritAttrs: false,
props: {
/**
Expand Down Expand Up @@ -73,27 +108,138 @@ export default defineComponent({
submit: defineEvent(),
},
setup(props, { emit }) {
const searchBarEl = ref<HTMLElement | null>(null)
const { matches: isHomeRoute } = useMatchHomeRoute()
const route = computed(() => {
return isHomeRoute?.value ? 'home' : props.is404 ? '404' : undefined
})
const searchText = computed(() => props.value)
const updateSearchText = (val: string) => {
emit('input', val)
}
const modelMedium = computed<string>({
get: () => props.value ?? '',
set: (value: string) => {
emit('input', value)
},
})
const handleSearch = () => {
emit('submit')
}
/* Focus */
const handleFocus = () => {
isRecentVisible.value = true
}
const handleBlur = () => {
isRecentVisible.value = false
}
onClickOutside(searchBarEl, handleBlur)
/* Recent searches */
const featureFlagStore = useFeatureFlagStore()
const isNewHeaderEnabled = featureFlagStore.isOn('new_header')
const searchStore = useSearchStore()
const isRecentVisible = ref(false)
const recentClasses = computed(() => {
// Calculated by adding 8px to all heights defined in `VInputField.vue`.
const FIELD_OFFSETS = {
small: 'top-12',
medium: 'top-14',
large: 'top-16',
standalone: 'top-[65px] md:top-[77px]',
} as const
return FIELD_OFFSETS[props.size]
})
/**
* Refers to the current suggestion that has visual focus (not DOM focus)
* and is the active descendant. This should be set to `undefined` when the
* visual focus is on the input field.
*/
const selectedIdx = ref<number | undefined>(undefined)
const entries = computed(() => searchStore.recentSearches)
const handleVerticalArrows = (event: KeyboardEvent) => {
event.preventDefault() // Prevent the cursor from moving horizontally.
const { key, altKey } = event
// Show the recent searches.
isRecentVisible.value = true
if (altKey) return
// Shift selection (if Alt was not pressed with arrow keys)
let defaultValue: number
let offset: number
if (key == keycodes.ArrowUp) {
defaultValue = 0
offset = -1
} else {
defaultValue = -1
offset = 1
}
selectedIdx.value = cyclicShift(
selectedIdx.value ?? defaultValue,
offset,
0,
entries.value.length
)
}
const handleOtherKeys = (event: KeyboardEvent) => {
const { key } = event
if (key === keycodes.Enter && selectedIdx.value)
// If a recent search is selected, populate its value into the input.
modelMedium.value = entries.value[selectedIdx.value]
if (([keycodes.Escape, keycodes.Enter] as string[]).includes(key))
// Hide the recent searches.
isRecentVisible.value = false
selectedIdx.value = undefined // Lose visual focus from entries.
}
const handleKeydown = (event: KeyboardEvent) => {
const { key } = event
return ([keycodes.ArrowUp, keycodes.ArrowDown] as string[]).includes(key)
? handleVerticalArrows(event)
: handleOtherKeys(event)
}
/* Populate the input with the clicked entry and execute the search. */
const handleSelect = (idx: number) => {
modelMedium.value = entries.value[idx]
isRecentVisible.value = false
selectedIdx.value = undefined // Lose visual focus from entries.
handleSearch() // Immediately execute the search manually.
}
/* Clear all recent searches from the store. */
const handleClear = () => {
searchStore.clearRecentSearches()
}
return {
searchBarEl,
handleSearch,
route,
searchText,
updateSearchText,
modelMedium,
handleFocus,
handleBlur,
isNewHeaderEnabled,
isRecentVisible,
recentClasses,
selectedIdx,
entries,
handleKeydown,
handleSelect,
handleClear,
}
},
})
Expand Down
15 changes: 11 additions & 4 deletions src/components/VHeader/VSearchBar/meta/VSearchBar.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import VSearchBar from '~/components/VHeader/VSearchBar/VSearchBar.vue'
export const Template = (args) => ({
template: `
<VSearchBar v-bind="args" v-on="args">
12,345 results
<span class="info text-xs font-semibold text-dark-charcoal-70 mx-4 whitespace-nowrap group-hover:text-dark-charcoal group-focus:text-dark-charcoal">
12,345 results
</span>
</VSearchBar>`,
components: { VSearchBar },
setup() {
Expand Down Expand Up @@ -56,11 +58,13 @@ button is clicked.
The recommended way to use it is with `v-model` mapping to a `String`
representing the search query.

export const vModelTemplate = () => ({
export const vModelTemplate = (args) => ({
template: `
<div>
<VSearchBar v-model="text" size="small">
{{ text.length }}
<VSearchBar v-model="text" size="standalone" v-on="args">
<span class="info text-xs font-semibold text-dark-charcoal-70 mx-4 whitespace-nowrap group-hover:text-dark-charcoal group-focus:text-dark-charcoal">
{{ text.length }} chars
</span>
</VSearchBar>
{{ text }}
</div>
Expand All @@ -71,6 +75,9 @@ export const vModelTemplate = () => ({
text: 'Hello, World!',
}
},
setup() {
return { args }
},
})

<Canvas>
Expand Down
Loading

0 comments on commit 83dbe5d

Please sign in to comment.