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

@@ -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" />