Files
hnh-map/frontend-nuxt/components/map/MapSearch.vue
Nikolay Tatarinov adfdfd01c4 Update frontend components for accessibility and functionality improvements
- Modified docker-compose.dev.yml to conditionally run npm install based on the presence of package-lock.json.
- Upgraded Nuxt version in package-lock.json from 3.21.1 to 4.3.1 for enhanced features.
- Enhanced ConfirmModal component with aria-modal attribute for better accessibility.
- Updated MapErrorBoundary component's error message for clarity.
- Added role and aria-label attributes to MapView and MapSearch components for improved screen reader support.
- Refactored various components to manage focus behavior on modal close, enhancing user experience.
- Improved ToastContainer styling for better responsiveness and visibility.
- Updated layout components to include skip navigation links for improved accessibility.
2026-03-04 01:00:56 +03:00

205 lines
6.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-search class="size-3.5 opacity-80" aria-hidden="true" />
Search
</h3>
<div class="relative">
<input
ref="inputRef"
v-model="query"
type="text"
class="input input-sm w-full pr-8 focus:ring-2 focus:ring-primary"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
placeholder="Coords (x, y) or marker name"
aria-label="Search by coordinates or marker name"
aria-expanded="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)"
aria-haspopup="listbox"
aria-controls="map-search-listbox"
autocomplete="off"
@focus="showDropdown = true"
@blur="scheduleCloseDropdown"
@keydown.enter="onEnter"
@keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)"
/>
<button
v-if="query"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
aria-label="Clear"
@mousedown.prevent="query = ''; showDropdown = false"
>
×
</button>
<div
v-if="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)"
id="map-search-listbox"
role="listbox"
class="absolute left-0 right-0 top-full z-[600] mt-1 max-h-56 overflow-y-auto rounded-lg border border-base-300 bg-base-100 shadow-xl py-1"
:aria-activedescendant="suggestions.length > 0 ? `map-search-opt-${highlightIndex}` : recent.recent.length > 0 ? `map-search-recent-${highlightIndex}` : undefined"
>
<template v-if="suggestions.length > 0">
<button
v-for="(s, i) in suggestions"
:id="`map-search-opt-${i}`"
:key="s.key"
type="button"
role="option"
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
:class="{ 'bg-primary/10': i === highlightIndex }"
:aria-selected="i === highlightIndex"
@mousedown.prevent="goToSuggestion(s)"
>
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
<span class="truncate">{{ s.label }}</span>
</button>
</template>
<template v-else-if="recent.recent.length > 0">
<p class="px-3 py-1 text-xs text-base-content/60 uppercase tracking-wider">Recent</p>
<button
v-for="(r, i) in recent.recent"
:id="`map-search-recent-${i}`"
:key="r.at"
type="button"
role="option"
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
:class="{ 'bg-primary/10': i === highlightIndex }"
:aria-selected="i === highlightIndex"
@mousedown.prevent="goToRecent(r)"
>
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
<span class="truncate">{{ r.label || `${r.mapId} · ${r.x}, ${r.y}` }}</span>
</button>
</template>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
import { useMapNavigate } from '~/composables/useMapNavigate'
import { useRecentLocations } from '~/composables/useRecentLocations'
const props = withDefaults(
defineProps<{
maps: MapInfo[]
questGivers: Array<{ id: number; name: string }>
currentMapId: number
currentCoords: { x: number; y: number; z: number } | null
touchFriendly?: boolean
}>(),
{ touchFriendly: false }
)
const { goToCoords } = useMapNavigate()
const recent = useRecentLocations()
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const showDropdown = ref(false)
const highlightIndex = ref(0)
let closeDropdownTimer: ReturnType<typeof setTimeout> | null = null
interface Suggestion {
key: string
label: string
mapId: number
x: number
y: number
zoom?: number
markerId?: number
}
const suggestions = computed<Suggestion[]>(() => {
const q = query.value.trim().toLowerCase()
if (!q) return []
const coordMatch = q.match(/^\s*(-?\d+)\s*[,;\s]\s*(-?\d+)\s*$/)
if (coordMatch) {
const x = parseInt(coordMatch[1], 10)
const y = parseInt(coordMatch[2], 10)
return [
{
key: `coord-${x}-${y}`,
label: `Go to ${x}, ${y}`,
mapId: props.currentMapId,
x,
y,
zoom: props.currentCoords?.z,
},
]
}
const list: Suggestion[] = []
for (const qg of props.questGivers) {
if (qg.name.toLowerCase().includes(q)) {
list.push({
key: `qg-${qg.id}`,
label: qg.name,
mapId: props.currentMapId,
x: 0,
y: 0,
zoom: undefined,
markerId: qg.id,
})
}
}
if (list.length > 0) return list.slice(0, 8)
return []
})
function scheduleCloseDropdown() {
closeDropdownTimer = setTimeout(() => {
showDropdown.value = false
closeDropdownTimer = null
}, 150)
}
function moveHighlight(delta: number) {
const total = suggestions.value.length || recent.recent.length
if (total === 0) return
highlightIndex.value = (highlightIndex.value + delta + total) % total
}
function goToSuggestion(s: Suggestion) {
showDropdown.value = false
if (s.key.startsWith('coord-')) {
goToCoords(s.mapId, s.x, s.y, s.zoom)
recent.push({ mapId: s.mapId, x: s.x, y: s.y, zoom: s.zoom, label: `${s.x}, ${s.y}` })
} else if (s.markerId != null) {
emit('jump-to-marker', s.markerId)
}
}
function goToRecent(r: (typeof recent.recent.value)[0]) {
showDropdown.value = false
goToCoords(r.mapId, r.x, r.y, r.zoom)
}
function onEnter() {
if (suggestions.value.length > 0) {
const s = suggestions.value[highlightIndex.value] ?? suggestions.value[0]
goToSuggestion(s)
} else if (recent.recent.length > 0) {
const r = recent.recent[highlightIndex.value] ?? recent.recent[0]
goToRecent(r)
}
}
const emit = defineEmits<{
'jump-to-marker': [id: number]
}>()
onMounted(() => {
useMapNavigate().setFocusSearch(() => inputRef.value?.focus())
})
onBeforeUnmount(() => {
if (closeDropdownTimer) clearTimeout(closeDropdownTimer)
useMapNavigate().setFocusSearch(null)
})
</script>