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.
This commit is contained in:
2026-03-04 01:00:56 +03:00
parent 47b81c8f22
commit adfdfd01c4
22 changed files with 2703 additions and 1140 deletions

View File

@@ -17,7 +17,7 @@ services:
frontend:
image: node:20-alpine
working_dir: /app
command: sh -c "npm ci && npm run dev"
command: sh -c "if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm run dev"
ports:
- "3000:3000"
volumes:

View File

@@ -1,5 +1,5 @@
<template>
<dialog ref="dialogRef" class="modal" :aria-labelledby="titleId" @close="onClose">
<dialog ref="dialogRef" class="modal" :aria-labelledby="titleId" aria-modal="true" @close="onClose">
<div class="modal-box">
<h2 :id="titleId" class="font-bold text-lg mb-2">{{ title }}</h2>
<p>{{ message }}</p>
@@ -44,11 +44,13 @@ const emit = defineEmits<{
const titleId = computed(() => `confirm-modal-title-${Math.random().toString(36).slice(2)}`)
const dialogRef = ref<HTMLDialogElement | null>(null)
let previousActiveElement: HTMLElement | null = null
watch(
() => props.modelValue,
(open) => {
if (open) {
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
dialogRef.value?.showModal()
} else {
dialogRef.value?.close()
@@ -63,6 +65,9 @@ function cancel() {
function onClose() {
emit('update:modelValue', false)
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
nextTick(() => previousActiveElement?.focus())
}
}
function confirm() {

View File

@@ -8,7 +8,7 @@
>
<p class="text-center text-lg font-medium">Map failed to load</p>
<p class="text-center text-sm text-base-content/80">
Something went wrong while loading the map. You can try reloading the page.
Something went wrong while loading the map. This can be due to a network or script error. You can try reloading the page.
</p>
<button type="button" class="btn btn-primary" @click="reload">
Reload page

View File

@@ -16,8 +16,11 @@
</div>
<div
v-if="mapsLoaded && maps.length === 0"
role="region"
aria-label="No maps available"
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
>
<icons-icon-map-pin class="size-12 text-base-content/40 shrink-0" aria-hidden="true" />
<p class="text-center text-lg">Map list is empty.</p>
<p class="text-center text-sm opacity-80">
Make sure you are logged in and at least one map exists in Admin (uncheck «Hidden» if needed).

View File

@@ -13,6 +13,7 @@
:required="required"
:autocomplete="autocomplete"
:readonly="readonly"
:aria-describedby="ariaDescribedby"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
@@ -38,8 +39,9 @@ const props = withDefaults(
autocomplete?: string
readonly?: boolean
inputId?: string
ariaDescribedby?: string
}>(),
{ required: false, autocomplete: 'off', inputId: undefined }
{ required: false, autocomplete: 'off', inputId: undefined, ariaDescribedby: undefined }
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -1,7 +1,7 @@
<template>
<Teleport to="body">
<div
class="fixed bottom-4 right-4 z-[9998] flex flex-col gap-2 max-w-sm w-full pointer-events-none"
class="fixed bottom-4 right-4 z-[9998] flex flex-col gap-2 w-full max-w-[calc(100vw-2rem)] sm:max-w-sm pointer-events-none"
aria-live="polite"
aria-label="Notifications"
>

View File

@@ -1,7 +1,14 @@
<template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')">
<dialog
ref="modalRef"
class="modal"
aria-modal="true"
:aria-labelledby="titleId"
@cancel="$emit('close')"
@close="onDialogClose"
>
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">{{ title }}</h3>
<h3 :id="titleId" class="font-bold text-lg">{{ title }}</h3>
<div class="py-2">
<label class="label py-0"><span>Name</span></label>
<input
@@ -39,14 +46,17 @@ const emit = defineEmits<{
submit: [name: string]
}>()
const titleId = computed(() => `bookmark-modal-title-${Math.random().toString(36).slice(2)}`)
const modalRef = ref<HTMLDialogElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const localName = ref(props.defaultName)
let previousActiveElement: HTMLElement | null = null
watch(
() => props.open,
(open) => {
if (open) {
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
localName.value = props.defaultName || ''
nextTick(() => {
modalRef.value?.showModal()
@@ -59,6 +69,12 @@ watch(
{ immediate: true }
)
function onDialogClose() {
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
nextTick(() => previousActiveElement?.focus())
}
}
watch(
() => props.defaultName,
(name) => {

View File

@@ -7,6 +7,7 @@
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
<template v-if="bookmarks.length === 0">
<p class="text-xs text-base-content/60 py-1">No saved locations.</p>
<p class="text-xs text-base-content/50 py-0">Add current location or a selected quest giver below.</p>
</template>
<template v-else>
<div

View File

@@ -150,7 +150,10 @@
@jump-to-marker="$emit('jumpToMarker', $event)"
/>
</div>
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb">
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb flex flex-col gap-2">
<p class="text-xs text-base-content/50 text-center">
<kbd class="kbd kbd-xs">?</kbd> for shortcuts
</p>
<button
type="button"
class="btn btn-ghost btn-block touch-manipulation min-h-12"

View File

@@ -119,6 +119,9 @@
:selected-marker-for-bookmark="selectedMarkerForBookmark ?? null"
:touch-friendly="touchFriendly"
/>
<p class="text-xs text-base-content/50 mt-2">
<kbd class="kbd kbd-xs">?</kbd> for shortcuts
</p>
</div>
</template>

View File

@@ -1,10 +1,17 @@
<template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')">
<dialog
ref="modalRef"
class="modal"
aria-modal="true"
aria-labelledby="coord-set-modal-title"
@cancel="$emit('close')"
@close="onDialogClose"
>
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
<h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2">
<input v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" />
<input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" />
</div>
<div class="modal-action">
@@ -31,14 +38,20 @@ const emit = defineEmits<{
}>()
const modalRef = ref<HTMLDialogElement | null>(null)
const firstInputRef = ref<HTMLInputElement | null>(null)
const localTo = ref({ x: props.coordSet.x, y: props.coordSet.y })
let previousActiveElement: HTMLElement | null = null
watch(
() => props.open,
(open) => {
if (open) {
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
localTo.value = { x: props.coordSet.x, y: props.coordSet.y }
nextTick(() => modalRef.value?.showModal())
nextTick(() => {
modalRef.value?.showModal()
firstInputRef.value?.focus()
})
} else {
modalRef.value?.close()
}
@@ -46,6 +59,12 @@ watch(
{ immediate: true }
)
function onDialogClose() {
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
nextTick(() => previousActiveElement?.focus())
}
}
watch(
() => props.coordSet,
(c) => {

View File

@@ -39,6 +39,7 @@ function copyShareUrl() {
if (import.meta.client && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(() => {
copied.value = true
useToast().success('Link copied')
if (copyTimeout) clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => {
copied.value = false

View File

@@ -12,6 +12,10 @@
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"
@@ -30,15 +34,21 @@
</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" />
@@ -49,10 +59,13 @@
<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" />

View File

@@ -1,6 +1,18 @@
<template>
<div class="drawer h-screen flex flex-col bg-base-100 overflow-hidden">
<input id="nav-drawer" ref="drawerCheckboxRef" type="checkbox" class="drawer-toggle" />
<a
href="#main-content"
class="absolute left-0 -top-20 z-[1200] bg-primary text-primary-content px-4 py-2 rounded-b transition-[top] duration-200 focus:top-0 focus:outline-none focus:ring-2 focus:ring-primary-content"
>
Skip to main content
</a>
<input
id="nav-drawer"
ref="drawerCheckboxRef"
type="checkbox"
class="drawer-toggle"
@change="onDrawerChange"
/>
<div class="drawer-content flex flex-col h-screen overflow-hidden">
<header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
@@ -91,6 +103,7 @@
</template>
<!-- Mobile: hamburger to open drawer -->
<label
ref="hamburgerRef"
for="nav-drawer"
class="btn btn-ghost btn-square md:hidden flex items-center justify-center min-h-[2.75rem] min-w-[2.75rem]"
aria-label="Open menu"
@@ -98,7 +111,7 @@
<icons-icon-menu class="size-6" />
</label>
</header>
<main class="flex-1 min-h-0 overflow-y-auto relative flex flex-col">
<main id="main-content" class="flex-1 min-h-0 overflow-y-auto relative flex flex-col" tabindex="-1">
<AdminBreadcrumbs v-if="route.path.startsWith('/admin') && route.path !== '/admin'" />
<div class="flex-1 min-h-0">
<slot />
@@ -107,8 +120,15 @@
<ToastContainer />
</div>
<!-- Drawer sidebar (mobile menu) -->
<div class="drawer-side z-[1100]">
<label for="nav-drawer" aria-label="Close menu" class="drawer-overlay" />
<div ref="drawerSideRef" class="drawer-side z-[1100]">
<label
for="nav-drawer"
aria-label="Close menu"
class="drawer-overlay"
tabindex="0"
@keydown.enter.prevent="closeDrawer"
@keydown.space.prevent="closeDrawer"
/>
<aside class="bg-base-200/95 backdrop-blur-xl min-h-full w-72 p-4 flex flex-col">
<!-- Mobile: user + live when logged in -->
<div v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50">
@@ -197,6 +217,9 @@ const live = useState<boolean>('mapLive', () => false)
const me = useState<MeResponse | null>('me', () => null)
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
const drawerSideRef = ref<HTMLDivElement | null>(null)
const hamburgerRef = ref<HTMLLabelElement | null>(null)
let drawerTrapCleanup: (() => void) | null = null
function closeDropdown() {
userDropdownRef.value?.removeAttribute('open')
@@ -206,6 +229,51 @@ function closeDrawer() {
if (drawerCheckboxRef.value) drawerCheckboxRef.value.checked = false
}
function getDrawerFocusables(): HTMLElement[] {
const side = drawerSideRef.value
if (!side) return []
const selector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
return Array.from(side.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.offsetParent !== null && !el.hasAttribute('aria-hidden')
)
}
function onDrawerChange() {
const open = drawerCheckboxRef.value?.checked ?? false
if (drawerTrapCleanup) {
drawerTrapCleanup()
drawerTrapCleanup = null
}
if (open) {
nextTick(() => {
const focusables = getDrawerFocusables()
const first = focusables.find((el) => el.closest('aside')) ?? focusables[0]
if (first) first.focus()
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return
const focusables = getDrawerFocusables()
if (focusables.length === 0) return
const i = focusables.indexOf(document.activeElement as HTMLElement)
if (e.shiftKey) {
if (i <= 0) {
e.preventDefault()
focusables[focusables.length - 1]?.focus()
}
} else {
if (i === focusables.length - 1 || i < 0) {
e.preventDefault()
focusables[0]?.focus()
}
}
}
document.addEventListener('keydown', handleKeydown, true)
drawerTrapCleanup = () => document.removeEventListener('keydown', handleKeydown, true)
})
} else if (import.meta.client && hamburgerRef.value && typeof hamburgerRef.value.focus === 'function') {
nextTick(() => hamburgerRef.value?.focus())
}
}
const { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path))
const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
@@ -266,4 +334,11 @@ async function doLogout() {
await router.push('/login')
me.value = null
}
onBeforeUnmount(() => {
if (drawerTrapCleanup) {
drawerTrapCleanup()
drawerTrapCleanup = null
}
})
</script>

View File

@@ -106,6 +106,8 @@ export function createMarker(
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon, title: marker.name })
leafletMarker.addTo(mapview.markerLayer)
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
if (markerEl) markerEl.setAttribute('aria-label', marker.name)
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e)
})

File diff suppressed because it is too large Load Diff

View File

@@ -107,21 +107,21 @@
<table class="table table-sm table-zebra min-w-[32rem]">
<thead>
<tr>
<th>
<th scope="col">
<button type="button" class="btn btn-ghost btn-xs gap-0.5 min-h-9 touch-manipulation" :class="mapsSortBy === 'ID' ? 'btn-active' : ''" @click="setMapsSort('ID')">
ID
<span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span>
</button>
</th>
<th>
<th scope="col">
<button type="button" class="btn btn-ghost btn-xs gap-0.5 min-h-9 touch-manipulation" :class="mapsSortBy === 'Name' ? 'btn-active' : ''" @click="setMapsSort('Name')">
Name
<span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span>
</button>
</th>
<th>Hidden</th>
<th>Priority</th>
<th class="text-right"></th>
<th scope="col">Hidden</th>
<th scope="col">Priority</th>
<th scope="col" class="text-right"></th>
</tr>
</thead>
<tbody>
@@ -241,8 +241,10 @@
<div
v-if="rebuilding || merging || wiping"
class="fixed inset-0 z-50 flex items-center justify-center bg-base-100/80 backdrop-blur-sm"
role="status"
aria-live="polite"
aria-busy="true"
:aria-label="rebuilding ? 'Rebuilding zooms in progress' : merging ? 'Merging in progress' : 'Wiping data in progress'"
>
<div class="flex flex-col items-center gap-4 p-6 rounded-xl bg-base-200 border border-base-300 shadow-2xl">
<span class="loading loading-spinner loading-lg text-primary" />
@@ -279,6 +281,7 @@
import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' })
useHead({ title: 'Admin HnH Map' })
const api = useMapApi()
const toast = useToast()

View File

@@ -40,6 +40,7 @@
import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' })
useHead({ title: 'Edit map HnH Map' })
const route = useRoute()
const router = useRouter()

View File

@@ -14,10 +14,12 @@
:readonly="!isNew"
/>
</fieldset>
<p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p>
<PasswordInput
v-model="form.pass"
label="Password (leave blank to keep)"
label="Password"
autocomplete="new-password"
aria-describedby="admin-user-password-hint"
/>
<fieldset class="fieldset">
<label class="label">Auths</label>
@@ -61,6 +63,7 @@
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
useHead({ title: 'User HnH Map' })
const route = useRoute()
const router = useRouter()

View File

@@ -18,6 +18,7 @@
<fieldset class="fieldset">
<label class="label" for="user">User</label>
<input
ref="userInputRef"
id="user"
v-model="user"
type="text"
@@ -44,6 +45,8 @@
<script setup lang="ts">
// No auth required; auth.global skips this path
useHead({ title: 'Login HnH Map' })
const user = ref('')
const pass = ref('')
const oauthProviders = ref<string[]>([])
@@ -54,8 +57,13 @@ const { loading, error, run } = useFormSubmit('Login failed')
const redirect = computed(() => (route.query.redirect as string) || '')
const userInputRef = ref<HTMLInputElement | null>(null)
onMounted(async () => {
oauthProviders.value = await api.oauthProviders()
if (import.meta.client && window.matchMedia('(pointer: fine)').matches) {
nextTick(() => userInputRef.value?.focus())
}
})
async function submit() {

View File

@@ -48,6 +48,8 @@
placeholder="email@example.com"
class="input input-bordered input-sm w-full max-w-xs"
autocomplete="email"
:aria-describedby="emailError ? 'profile-email-error' : undefined"
:aria-invalid="!!emailError"
>
<button type="submit" class="btn btn-primary btn-sm" :disabled="loadingEmail">
{{ loadingEmail ? '…' : 'Save' }}
@@ -55,7 +57,7 @@
<button type="button" class="btn btn-ghost btn-sm" :disabled="loadingEmail" @click="cancelEditEmail">
Cancel
</button>
<p v-if="emailError" class="text-error text-sm w-full">{{ emailError }}</p>
<p v-if="emailError" id="profile-email-error" class="text-error text-sm w-full" role="alert">{{ emailError }}</p>
</form>
</div>
</div>
@@ -130,6 +132,8 @@
<script setup lang="ts">
import type { MeResponse } from '~/types/api'
useHead({ title: 'Profile HnH Map' })
const api = useMapApi()
const toast = useToast()
const initialLoad = ref(true)

View File

@@ -20,13 +20,15 @@
</button>
</form>
</AuthCard>
<NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink>
<NuxtLink to="/" class="btn btn-ghost btn-sm mt-4">Map</NuxtLink>
</div>
</template>
<script setup lang="ts">
// No auth required; auth.global skips this path
useHead({ title: 'Setup HnH Map' })
const pass = ref('')
const router = useRouter()
const api = useMapApi()