264 lines
7.1 KiB
Vue
264 lines
7.1 KiB
Vue
<script setup lang="ts">
|
|
import type { MapMarker, PlayerMarker } from '@/types/Leaflet';
|
|
import { useFocus, useMagicKeys, whenever } from '@vueuse/core';
|
|
import { computed, onMounted, ref, onUpdated } from 'vue';
|
|
|
|
const props = defineProps<{
|
|
markers: MapMarker[],
|
|
players: PlayerMarker,
|
|
}>()
|
|
|
|
const markers = props.markers
|
|
|
|
// Search functions
|
|
const qInput = ref()
|
|
const { focused: isFocused } = useFocus(qInput)
|
|
|
|
onMounted(() => {
|
|
isFocused.value = true
|
|
})
|
|
|
|
const shouldBeActive = computed(() => q.value.length > 0)
|
|
|
|
const q = ref<string>("")
|
|
|
|
const unifier = new RegExp(/[^a-zA-Z0-9\-\'']/g)
|
|
|
|
const filteredMarkers = computed(() => {
|
|
return markers?.filter(m => {
|
|
const queryString = new String(q.value).replace(unifier, "").toLocaleLowerCase()
|
|
const hitTitle = m.title.replace(unifier, "").toLocaleLowerCase().includes(queryString)
|
|
const hitDesc = m.description?.replace(unifier, "").toLocaleLowerCase().includes(queryString)
|
|
|
|
return hitTitle || hitDesc
|
|
})
|
|
})
|
|
|
|
// Key Combos
|
|
const keys = useMagicKeys()
|
|
const shortcutKeyCombo = keys['Shift+Period']
|
|
const eraseKeyCombo = keys['Escape']
|
|
|
|
whenever(shortcutKeyCombo, () => {
|
|
isFocused.value = true
|
|
})
|
|
whenever(eraseKeyCombo, () => {
|
|
q.value = ""
|
|
isFocused.value = true
|
|
})
|
|
|
|
/**
|
|
* Player geolocation
|
|
* Uses native JS to avoid changing leaflet library
|
|
*/
|
|
onMounted(() => {
|
|
const playerBtnRef = document.querySelector('[data-to-players]')
|
|
const flyToPlayers = new CustomEvent(`fly-to-players`, { bubbles: true })
|
|
|
|
playerBtnRef?.addEventListener('click', () => {
|
|
playerBtnRef.dispatchEvent(flyToPlayers)
|
|
})
|
|
})
|
|
|
|
const hasPlayers = computed(() => Object.keys(props.players).length > 0)
|
|
|
|
/**
|
|
* Marker geolocation
|
|
* We need to use onUpdated hook because the list is not always rendered, and it often rebuilt.
|
|
*/
|
|
onUpdated(() => {
|
|
const markerBtnRefs = document.querySelectorAll('[data-to-marker]')
|
|
|
|
markerBtnRefs.forEach((btn) => {
|
|
const { toMarker } = (btn as HTMLButtonElement).dataset
|
|
if (!toMarker) return
|
|
const flyToMarker = new CustomEvent(`fly-to-${toMarker}`, { bubbles: true })
|
|
|
|
btn.addEventListener('click', () => btn.dispatchEvent(flyToMarker))
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="searchBar" class="search-w" :data-focused="shouldBeActive">
|
|
<div class="input-w">
|
|
<i class="search-icon ph-fill ph-magnifying-glass"></i>
|
|
<input ref="qInput" name="recherche" type="text" v-model="q" title="Rechercher le monde" placeholder="Ville, point d'intérêt…">
|
|
|
|
<button v-if="hasPlayers" data-to-players class="player-btn" :tabindex="shouldBeActive ? 1 : 0" title="Aller à la position actuelle des joueurs">
|
|
<i class="pin-icon ph-fill ph-map-pin"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<ul class="search-results" v-if="shouldBeActive">
|
|
<li v-for="m in filteredMarkers?.slice(0, 10)" :key="m.title">
|
|
<button class="search-item" :data-to-marker="m.title" tabindex="0" :title="`Aller à ${m.title}`">
|
|
<span class="title">{{ m.title }}</span>
|
|
<span class="desc">{{ m.description }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.search-w {
|
|
margin-bottom: 1em;
|
|
padding: .5rem 1.2rem;
|
|
width: calc(100% - 3rem);
|
|
background: #fff;
|
|
border: 1px solid var(--slate-400);
|
|
border-radius: 25px;
|
|
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
|
|
pointer-events: all;
|
|
|
|
.input-w {
|
|
$search-items-gap: .5rem;
|
|
$search-icon-size: 1.2em;
|
|
|
|
position: relative;
|
|
padding-block: .2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $search-items-gap;
|
|
|
|
input {
|
|
display: block;
|
|
min-width: 10rem;
|
|
padding-inline-start: calc($search-icon-size + $search-items-gap);
|
|
flex-grow: 1;
|
|
|
|
&::placeholder {
|
|
color: var(--slate-400);
|
|
}
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
left: 0;
|
|
font-size: $search-icon-size;
|
|
color: var(--slate-400);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.player-btn {
|
|
position: relative;
|
|
isolation: isolate;
|
|
aspect-ratio: 1 / 1;
|
|
line-height: 1;
|
|
font-size: 1.5em;
|
|
color: #dc2626;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
|
|
&::before {
|
|
display: block;
|
|
content: '';
|
|
position: absolute;
|
|
inset: -.4rem;
|
|
background-color: transparent;
|
|
border-radius: 50%;
|
|
z-index: -1;
|
|
outline-offset: -.2rem;
|
|
transition-property: background-color;
|
|
transition-duration: .2s;
|
|
transition-timing-function: ease-in-out;
|
|
}
|
|
|
|
&:hover {
|
|
&::before {
|
|
background-color: var(--slate-300);
|
|
}
|
|
}
|
|
|
|
&:focus-visible {
|
|
&::before {
|
|
background-color: var(--slate-200);
|
|
outline: 1px dotted var(--slate-500);
|
|
outline: 4px auto var(--slate-900);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.search-results {
|
|
max-height: 50vh;
|
|
overflow-y: auto;
|
|
scrollbar-gutter: stable;
|
|
|
|
&::-webkit-scrollbar,
|
|
&::-webkit-scrollbar-thumb {
|
|
width: 26px;
|
|
border-radius: 13px;
|
|
background-clip: padding-box;
|
|
border: 10px solid transparent;
|
|
}
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
background-color: var(--slate-300);
|
|
}
|
|
|
|
> * + * {
|
|
margin-bottom: .33rem;
|
|
}
|
|
|
|
.search-item {
|
|
cursor: pointer;
|
|
padding: .4rem .25em;
|
|
width: 100%;
|
|
outline-offset: -1px;
|
|
|
|
&:hover {
|
|
background-color: var(--slate-100);
|
|
|
|
.title {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
|
|
&:focus-visible {
|
|
outline: 1px dotted var(--slate-500);
|
|
outline: 4px auto var(--slate-900);
|
|
|
|
.title {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
|
|
.title,
|
|
.desc {
|
|
display: block;
|
|
font-size: .85em;
|
|
}
|
|
|
|
.title {
|
|
font-weight: 500;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.desc {
|
|
color: var(--slate-500);
|
|
}
|
|
}
|
|
}
|
|
|
|
&[data-focused="true"] {
|
|
.input-w {
|
|
border-bottom: 1px solid var(--slate-200);
|
|
margin-bottom: .5rem;
|
|
padding-bottom: .5rem;
|
|
|
|
input {
|
|
padding-block-start: .2rem;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@media screen and (width >= 900px) {
|
|
.search-w {
|
|
width: 29%;
|
|
}
|
|
}
|
|
</style> |