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

Commit

Permalink
Add VHeaderMobile and VInputModal
Browse files Browse the repository at this point in the history
  • Loading branch information
obulat committed Oct 6, 2022
1 parent 135000e commit ca08d36
Show file tree
Hide file tree
Showing 4 changed files with 507 additions and 8 deletions.
348 changes: 348 additions & 0 deletions src/components/VHeader/VHeaderMobile/VHeaderMobile.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
<template>
<header
class="main-header z-30 flex w-full items-center border-b border-tx bg-white px-6 py-4"
:class="{ 'border-dark-charcoal-20': isHeaderScrolled }"
>
<VInputModal
class="flex w-full"
variant="recent-searches"
:is-active="isRecentSearchesModalOpen"
@close="deactivate"
>
<div class="flex w-full" :class="isRecentSearchesModalOpen ? 'px-2' : ''">
<form
class="search-bar group flex h-12 w-full flex-row items-center overflow-hidden rounded-sm border border-1.5"
:class="
searchBarIsActive
? 'border-pink bg-white'
: 'border-tx bg-dark-charcoal-06'
"
@submit.prevent="handleSearch"
>
<slot name="start">
<VLogoButton
v-if="!searchBarIsActive"
:is-fetching="isFetching"
:is-search-route="true"
class="w-12"
/>
<VBackButton v-else @click="handleBack" />
</slot>

<input
id="search-bar"
ref="searchInputRef"
name="q"
:placeholder="$t('hero.search.placeholder').toString()"
type="search"
class="search-field h-full w-full flex-grow appearance-none rounded-none border-tx bg-tx text-2xl text-dark-charcoal-70 placeholder-dark-charcoal-70 ms-1 focus-visible:outline-none"
:value="searchTerm"
:aria-label="
$t('search.search-bar-label', {
openverse: 'Openverse',
}).toString()
"
autocomplete="off"
role="combobox"
aria-autocomplete="none"
:aria-expanded="showRecentSearches"
aria-controls="recent-searches-list"
:aria-activedescendant="
selectedIdx !== undefined ? `option-${selectedIdx}` : undefined
"
@input="updateSearchText"
@focus="activate"
@keydown="handleKeydown"
/>
<slot>
<VClearButton
v-if="searchBarIsActive"
class="me-2"
@click="clearSearchText"
/>
<template v-else>
<span
v-show="searchStatus"
class="info mx-4 hidden whitespace-nowrap text-xs group-hover:text-dark-charcoal group-focus:text-dark-charcoal md:flex"
>
{{ searchStatus }}
</span>
<VContentSettingsModal />
</template>
</slot>
</form>
</div>

<VRecentSearches
v-show="showRecentSearches"
:selected-idx="selectedIdx"
:entries="entries"
:bordered="false"
class="mt-4"
@select="handleSelect"
@clear="handleClear"
/>
</VInputModal>
</header>
</template>

<script lang="ts">
import {
computed,
defineComponent,
inject,
nextTick,
ref,
useContext,
useRouter,
watch,
} from '@nuxtjs/composition-api'
import { ensureFocus } from '~/utils/reakit-utils/focus'
import { cyclicShift } from '~/utils/math'
import { searchPath } from '~/constants/media'
import { keycodes } from '~/constants/key-codes'
import { IsHeaderScrolledKey } from '~/types/provides'
import { useI18n } from '~/composables/use-i18n'
import { useI18nResultsCount } from '~/composables/use-i18n-utilities'
import { useMediaStore } from '~/stores/media'
import { isSearchTypeSupported, useSearchStore } from '~/stores/search'
import VBackButton from '~/components/VHeader/VHeaderMobile/VBackButton.vue'
import VClearButton from '~/components/VHeader/VSearchBar/VClearButton.vue'
import VLogoButton from '~/components/VHeader/VLogoButton.vue'
import VInputModal from '~/components/VModal/VInputModal.vue'
import VContentSettingsModal from '~/components/VHeader/VHeaderMobile/VContentSettingsModal.vue'
import VRecentSearches from '~/components/VRecentSearches/VRecentSearches.vue'
import closeIcon from '~/assets/icons/close-small.svg'
/**
* Displays a text field for a search query and is attached to an action button
* that fires a search request. The loading state and number of hits are also
* displayed in the bar itself.
*/
export default defineComponent({
name: 'VHeaderMobile',
components: {
VContentSettingsModal,
VBackButton,
VClearButton,
VInputModal,
VLogoButton,
VRecentSearches,
},
setup() {
const searchInputRef = ref<HTMLInputElement | null>(null)
const mediaStore = useMediaStore()
const searchStore = useSearchStore()
const { app } = useContext()
const i18n = useI18n()
const router = useRouter()
const searchBarIsActive = ref(false)
const isHeaderScrolled = inject(IsHeaderScrolledKey)
const isFetching = computed(() => mediaStore.fetchState.isFetching)
const resultsCount = computed(() => mediaStore.resultCount)
const { getI18nCount } = useI18nResultsCount()
/**
* Additional text at the end of the search bar.
* Shows the loading state or result count.
*/
const searchStatus = computed<string>(() => {
if (searchStore.searchTerm === '') return ''
if (isFetching.value) return i18n.t('header.loading').toString()
return getI18nCount(resultsCount.value)
})
const localSearchTerm = ref(searchStore.searchTerm)
let searchTermChanged = computed(() => {
return searchStore.searchTerm !== localSearchTerm.value
})
/**
* Search term has a getter and setter to be used as a v-model.
* To prevent sending unnecessary requests, we also keep track of whether
* the search term was changed.
*/
const searchTerm = computed({
get: () => localSearchTerm.value,
set: (value: string) => {
localSearchTerm.value = value
},
})
/**
* Called when the 'search' button in the header is clicked.
* There are several scenarios:
* - search term hasn't changed:
* - on a search route, do nothing.
* - on other routes: set searchType to 'All content', reset the media,
* change the path to `/search/` (All content).
* - search term changed:
* - on a search route: Update the store searchTerm value, update query `q` param, reset media,
* fetch new media.
* - on other routes: Update the store searchTerm value, set searchType to 'All content', reset media,
* update query `q` param.
* Updating the path causes the `search.vue` page's route watcher
* to run and fetch new media.
*/
const handleSearch = async () => {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' })
const mediaStore = useMediaStore()
const searchStore = useSearchStore()
const searchType = searchStore.searchType
if (!searchTermChanged.value || searchTerm.value === '') return
if (searchTermChanged.value) {
await mediaStore.clearMedia()
searchStore.setSearchTerm(searchTerm.value)
searchStore.setSearchType(searchType)
}
if (isSearchTypeSupported(searchType)) {
const newPath = app.localePath({
path: searchPath(searchType),
query: searchStore.searchQueryParams,
})
router.push(newPath)
}
deactivate()
}
const isRecentSearchesModalOpen = ref(false)
const activate = () => (searchBarIsActive.value = true)
const deactivate = () => {
searchBarIsActive.value = false
}
watch(searchBarIsActive, (active) => {
if (active) {
isRecentSearchesModalOpen.value = true
/**
* Without `nextTick`, the search bar is not focused on click in Firefox
*/
nextTick(() => ensureFocus(searchInputRef.value))
} else {
isRecentSearchesModalOpen.value = false
if (localSearchTerm.value === '' && searchStore.searchTerm !== '') {
localSearchTerm.value = searchStore.searchTerm
}
}
})
const updateSearchText = (event: Event) => {
searchTerm.value = (event.target as HTMLInputElement).value
}
const clearSearchText = () => {
searchTerm.value = ''
ensureFocus(searchInputRef.value)
}
const handleBack = () => {
deactivate()
}
/**
* 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.
isRecentSearchesModalOpen.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.
searchTerm.value = entries.value[selectedIdx.value]
if (([keycodes.Escape] as string[]).includes(key))
// Hide the recent searches.
isRecentSearchesModalOpen.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) => {
searchTerm.value = entries.value[idx]
isRecentSearchesModalOpen.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()
ensureFocus(searchInputRef.value)
}
const showRecentSearches = computed(
() => isRecentSearchesModalOpen.value && entries.value.length > 0
)
return {
closeIcon,
searchInputRef,
isHeaderScrolled,
isFetching,
isRecentSearchesModalOpen,
showRecentSearches,
searchBarIsActive,
activate,
deactivate,
searchStatus,
searchTerm,
clearSearchText,
updateSearchText,
handleSearch,
handleBack,
selectedIdx,
entries,
handleKeydown,
handleSelect,
handleClear,
}
},
})
</script>

<style scoped></style>
Loading

0 comments on commit ca08d36

Please sign in to comment.