add and fix keyboard nav

This commit is contained in:
Sabrina Hennrich 2025-06-12 14:31:39 +02:00
parent 33bf025fdb
commit ca031126e1
8 changed files with 319 additions and 145 deletions

View File

@ -143,7 +143,12 @@ body
opacity: 0 opacity: 0
*:focus *:focus
outline: 1px solid transparent outline: none
body.user-is-tabbing *:focus
outline: none
box-shadow: 0 0 0 4px $pink !important
border-radius: 5px
/*.router-link-active /*.router-link-active
position: relative position: relative

View File

@ -6,7 +6,17 @@
aria-label="Breadcrumb" aria-label="Breadcrumb"
> >
<div class="breadcrumb-container"> <div class="breadcrumb-container">
<div class="breadcrumb-trigger" @click="toggleOpen" aria-label="Breadcrumb öffnen"> <!-- Trigger -->
<div
class="breadcrumb-trigger"
role="button"
tabindex="0"
@click="toggleOpen"
@keydown.space.prevent="toggleOpen"
@keydown.enter.prevent="toggleOpen"
:aria-expanded="isOpen.toString()"
aria-label="Breadcrumb öffnen"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512" viewBox="0 0 576 512"
@ -20,7 +30,7 @@
</svg> </svg>
</div> </div>
<!-- Breadcrumbs selbst (ein- und ausklappbar) --> <!-- Breadcrumbs -->
<ul :class="{ open: isOpen, closed: !isOpen }"> <ul :class="{ open: isOpen, closed: !isOpen }">
<li <li
v-for="(crumb, index) in breadcrumbs" v-for="(crumb, index) in breadcrumbs"
@ -30,19 +40,33 @@
v-if="index < breadcrumbs.length - 1" v-if="index < breadcrumbs.length - 1"
:to="crumb.to" :to="crumb.to"
:title="crumb.labelFull" :title="crumb.labelFull"
>{{ crumb.label }} >
{{ crumb.label }}
</router-link> </router-link>
<span v-else :title="crumb.labelFull"><span v-if="breadcrumbs.length > 1" class="between"></span>{{ crumb.label }}</span> <span v-else :title="crumb.labelFull">
<span v-if="breadcrumbs.length > 1" class="between"></span>
{{ crumb.label }}
</span>
</li>
<li>
<div
class="close-btn"
role="button"
tabindex="0"
@click="isOpen = false"
@keydown.space.prevent="isOpen = false"
@keydown.enter.prevent="isOpen = false"
aria-label="Breadcrumb schließen"
title="Schließen"
></div>
</li> </li>
<li class="close-btn" @click="isOpen = false" title="Schließen"></li>
</ul> </ul>
</div> </div>
</nav> </nav>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { i18nPages } from '~/i18n/i18n-pages' import { i18nPages } from '~/i18n/i18n-pages'
@ -54,11 +78,30 @@ interface Breadcrumb {
} }
const isOpen = ref(false) const isOpen = ref(false)
const route = useRoute()
const { locale, t } = useI18n()
function toggleOpen() { function toggleOpen() {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
} }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen.value = false
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
})
watch(() => route.fullPath, () => {
isOpen.value = false
})
function formatLabel(segment: string): { label: string; labelFull: string } { function formatLabel(segment: string): { label: string; labelFull: string } {
const labelFull = segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ') const labelFull = segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
let label = labelFull let label = labelFull
@ -69,14 +112,8 @@ function formatLabel(segment: string): { label: string; labelFull: string } {
} }
function buildUrl(loc: string, path: string): string { function buildUrl(loc: string, path: string): string {
if (loc === 'de') { return loc === 'de' ? '/' + path.replace(/^\//, '') : `/${loc}/${path.replace(/^\//, '')}`
return path.startsWith('/') ? path : '/' + path
} }
return `/${loc}${path.startsWith('/') ? path : '/' + path}`
}
const route = useRoute()
const { locale, t } = useI18n()
const breadcrumbs = computed<Breadcrumb[]>(() => { const breadcrumbs = computed<Breadcrumb[]>(() => {
const loc = locale.value const loc = locale.value
@ -86,61 +123,32 @@ const breadcrumbs = computed<Breadcrumb[]>(() => {
if (segments.length === 2 && segments[0] === 'projekt') { if (segments.length === 2 && segments[0] === 'projekt') {
const referencesPath = i18nPages.references?.[loc] || '/references' const referencesPath = i18nPages.references?.[loc] || '/references'
const referencesLabel = t('references') || 'References' const referencesLabel = t('references') || 'References'
return [
const first = { { label: referencesLabel, labelFull: referencesLabel, to: buildUrl(loc, referencesPath) },
label: referencesLabel, { ...formatLabel(segments[1]), to: route.path }
labelFull: referencesLabel, ]
to: buildUrl(loc, referencesPath)
}
const { label, labelFull } = formatLabel(segments[1])
const second = {
label,
labelFull,
to: route.path
}
return [first, second]
} }
if (segments.length === 2 && segments[0] === 'artikel') { if (segments.length === 2 && segments[0] === 'artikel') {
const magazinePath = i18nPages.magazin?.[loc] || '/magazin' const magazinePath = i18nPages.magazin?.[loc] || '/magazin'
const magazineLabel = t('magazin') || 'Magazin' const magazineLabel = t('magazin') || 'Magazin'
return [
const first = { { label: magazineLabel, labelFull: magazineLabel, to: buildUrl(loc, magazinePath) },
label: magazineLabel, { ...formatLabel(segments[1]), to: route.path }
labelFull: magazineLabel, ]
to: buildUrl(loc, magazinePath)
}
const { label, labelFull } = formatLabel(segments[1])
const second = {
label,
labelFull,
to: route.path
}
return [first, second]
} }
let path = '' let path = ''
return segments.map(segment => { return segments.map(segment => {
path += '/' + segment path += '/' + segment
const { label, labelFull } = formatLabel(segment) const { label, labelFull } = formatLabel(segment)
return { return { label, labelFull, to: buildUrl(loc, path) }
label,
labelFull,
to: buildUrl(loc, path)
}
}) })
}) })
watch(() => route.fullPath, () => {
isOpen.value = false
})
</script> </script>
<style lang="sass" scoped>
<style lang="sass">
.breadcrumbs .breadcrumbs
position: fixed position: fixed
top: 12vh top: 12vh
@ -165,14 +173,17 @@ watch(() => route.fullPath, () => {
justify-content: center justify-content: center
width: 1.8rem width: 1.8rem
height: 1.8rem height: 1.8rem
background: none
border: none
margin: 0 0 0 .5rem
svg svg
width: 1rem width: 1rem !important
height: 1rem height: 1rem !important
transform: translateX(10px) display: inline-block
path path
fill: darken($lightgrey, 20%) fill: darken($lightgrey, 30%) !important
&:hover path &:hover path
fill: darken($lightgrey, 30%) fill: darken($lightgrey, 30%)
@ -224,6 +235,8 @@ watch(() => route.fullPath, () => {
border-radius: 50% border-radius: 50%
position: relative position: relative
margin: .2rem .5rem 0 1rem margin: .2rem .5rem 0 1rem
border: none
text-transform: none
&::before &::before
position: absolute position: absolute

View File

@ -13,6 +13,8 @@
role="button" role="button"
aria-label="Toggle contact form" aria-label="Toggle contact form"
@click="toggleContactBubble" @click="toggleContactBubble"
@keydown.space.prevent="toggleContactBubble"
@keydown.enter.prevent="toggleContactBubble"
> >
<use :xlink:href="`/assets/icons/collection.svg#${isContactBubbleOpen ? 'times' : 'talk'}`" /> <use :xlink:href="`/assets/icons/collection.svg#${isContactBubbleOpen ? 'times' : 'talk'}`" />
</svg> </svg>
@ -77,6 +79,7 @@
class="form-control" class="form-control"
type="text" type="text"
name="name" name="name"
ref="firstInput"
required required
autocomplete="name" autocomplete="name"
:aria-invalid="!!errors.name" :aria-invalid="!!errors.name"
@ -188,10 +191,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main'; import { useMainStore } from '@/stores/main';
import { ref, reactive, computed } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const firstInput = ref<HTMLInputElement | null>(null);
// i18n Setup // i18n Setup
const { t } = useI18n(); const { t } = useI18n();
@ -291,6 +294,30 @@ const resetForm = () => {
errors.email = null; errors.email = null;
errors.phone = null; errors.phone = null;
}; };
// ESC & Fokusmanagement
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isContactBubbleOpen.value) {
toggleContactBubble();
}
};
// Füge den EventListener beim Mount hinzu
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown);
});
// Wenn die Bubble geöffnet wird: Fokus setzen
watch(isContactBubbleOpen, async (newVal) => {
if (newVal) {
await nextTick()
firstInput.value?.focus()
}
})
</script> </script>

View File

@ -46,6 +46,34 @@ const accordionItems = computed(() =>
) )
const toggleContactBubble = () => mainStore.toggleContactBubble() const toggleContactBubble = () => mainStore.toggleContactBubble()
// FAQ JSON-LD
useHead(() => {
if (!accordionItems.value.length) return {}
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": accordionItems.value.map(item => ({
"@type": "Question",
"name": item.title,
"acceptedAnswer": {
"@type": "Answer",
"text": item.html.replace(/<[^>]*>?/gm, '') // HTML-Tags entfernen
}
}))
}
return {
script: [
{
type: 'application/ld+json',
children: JSON.stringify(faqJsonLd)
}
]
}
})
</script> </script>

View File

@ -1,20 +1,35 @@
<template> <template>
<nav class="mainNav" :class="[ <nav
class="mainNav"
:class="[
isMenuOpen ? 'active' : '', isMenuOpen ? 'active' : '',
screenWidth < 1350 ? 'mobile-nav' : 'desk-nav', screenWidth < 1350 ? 'mobile-nav' : 'desk-nav',
scrollPosition > 50 ? 'scrolled' : '' scrollPosition > 50 ? 'scrolled' : ''
]"> ]"
<!-- Burger Icon nur bei Mobile sichtbar --> aria-label="Hauptnavigation"
<div class="burger" @click="toggleMenu" v-if="screenWidth < 1350" :class="{ open: isMenuOpen }"> >
<!-- Burger Icon als Button -->
<button
class="burger"
@click="toggleMenu"
v-if="screenWidth < 1350"
:aria-expanded="isMenuOpen"
aria-controls="main-navigation"
:class="{ open: isMenuOpen }"
type="button"
>
<span class="visually-hidden">Menü öffnen</span>
<span></span> <span></span>
<span></span> <span></span>
</div> </button>
<!-- Navigation --> <!-- Navigation -->
<ul <ul
:id="'main-navigation'"
:class="[ :class="[
screenWidth < 1350 && isMenuOpen ? 'mobile-menu active' : screenWidth < 1350 && isMenuOpen ? 'mobile-menu active' :
screenWidth < 1350 ? 'mobile-menu' : 'desk-menu']" screenWidth < 1350 ? 'mobile-menu' : 'desk-menu'
]"
v-show="screenWidth >= 1350 || isMenuOpen" v-show="screenWidth >= 1350 || isMenuOpen"
> >
<li <li
@ -23,21 +38,38 @@
class="nav-item" class="nav-item"
@mouseenter="screenWidth >= 1350 && link.subNav && showSubNav(link.label)" @mouseenter="screenWidth >= 1350 && link.subNav && showSubNav(link.label)"
@mouseleave="screenWidth >= 1350 && link.subNav && hideSubNav(link.label)" @mouseleave="screenWidth >= 1350 && link.subNav && hideSubNav(link.label)"
> >
<!-- Mit SubNav --> <!-- Mit SubNav -->
<template v-if="link.subNav"> <template v-if="link.subNav">
<div @click="screenWidth < 1350 ? toggleMobileSubNav(link.label) : null"> <button
v-if="screenWidth < 1350"
class="subnav-toggle"
@click="toggleMobileSubNav(link.label)"
:aria-expanded="isActiveSubNav(link.label)"
:aria-controls="`submenu-${link.label}`"
type="button"
>
{{ $t(link.label) }} {{ $t(link.label) }}
<span class="arrow" :class="{ open: (screenWidth < 1350 ? isMobileSubNavOpen : isSubNavOpen) === link.label }"></span> <span class="arrow" :class="{ open: isActiveSubNav(link.label) }"></span>
<span class="visually-hidden">
{{ isActiveSubNav(link.label) ? 'Untermenü schließen' : 'Untermenü öffnen' }}
</span>
</button>
<div v-else>
{{ $t(link.label) }}
<span class="arrow" :class="{ open: isSubNavOpen === link.label }"></span>
</div> </div>
<ul <ul
:id="`submenu-${link.label}`"
class="submenu" class="submenu"
:class="{ open: isActiveSubNav(link.label) }" :class="{ open: isActiveSubNav(link.label) }"
> >
<li v-for="sublink in link.subNav" :key="sublink.label"> <li v-for="sublink in link.subNav" :key="sublink.label">
<NuxtLinkLocale :to="sublink.routeKey" @click.native="handleMobileClose"> <NuxtLinkLocale
:to="sublink.routeKey"
@click.native="handleMobileClose"
>
{{ $t(sublink.label) }} {{ $t(sublink.label) }}
</NuxtLinkLocale> </NuxtLinkLocale>
</li> </li>
@ -46,7 +78,10 @@
<!-- Ohne SubNav --> <!-- Ohne SubNav -->
<template v-else> <template v-else>
<NuxtLinkLocale :to="link.routeKey" @click.native="handleMobileClose"> <NuxtLinkLocale
:to="link.routeKey"
@click.native="handleMobileClose"
>
{{ $t(link.label) }} {{ $t(link.label) }}
</NuxtLinkLocale> </NuxtLinkLocale>
</template> </template>
@ -56,6 +91,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
@ -154,6 +190,15 @@
right: 0 right: 0
background-color: transparent background-color: transparent
.visually-hidden
position: absolute
width: 1px
height: 1px
padding: 0
margin: -1px
overflow: hidden
clip: rect(0, 0, 0, 0)
border: 0
.burger .burger
width: 4rem width: 4rem
@ -184,9 +229,9 @@
border: 1px solid transparent border: 1px solid transparent
span span
height: 3px height: 3px
&:nth-child(1)
transform: rotate(45deg) translate(2px, 5px)
&:nth-child(2) &:nth-child(2)
transform: rotate(45deg) translate(2px, 5px)
&:nth-child(3)
transform: rotate(-45deg) translate(3px, -6px) transform: rotate(-45deg) translate(3px, -6px)
.nav-item .nav-item
@ -257,9 +302,9 @@
.submenu .submenu
position: absolute position: absolute
top: 100% top: 100%
left: -50% left: -30%
width: 200% width: 200%
background-image: linear-gradient(to bottom, rgba(#fff, 0.1) 0%, rgba(#fff, 0.9) 8%, rgba(#fff, .98) 100%) background-image: linear-gradient(to bottom, rgba(#fff, 0.1) 0%, rgba(#fff, 0.9) 18%, rgba(#fff, .98) 100%)
border-bottom-right-radius: 1rem border-bottom-right-radius: 1rem
border-bottom-left-radius: 1rem border-bottom-left-radius: 1rem
list-style: none list-style: none
@ -294,6 +339,13 @@
cursor: pointer cursor: pointer
font-size: 1.25rem font-size: 1.25rem
color: white color: white
.subnav-toggle
background-color: transparent
border: none
color: white
font-size: 1.25rem
text-transform: uppercase
margin-top: -6px
&::before &::before
content: '' content: ''
width: .7rem width: .7rem

View File

@ -0,0 +1,45 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
export function useDeferredTabFocus() {
const focusableElements = ref<HTMLElement[]>([])
function register(el: HTMLElement | null) {
if (el) {
el.setAttribute('tabindex', '-1')
focusableElements.value.push(el)
}
}
function activateFocus() {
focusableElements.value.forEach(el => el.removeAttribute('tabindex'))
document.body.classList.add('user-is-tabbing') // < das hier neu
window.removeEventListener('keydown', onKeyDown)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab') {
activateFocus()
}
}
function onMouseDown() {
document.body.classList.remove('user-is-tabbing')
window.addEventListener('keydown', onKeyDown) // optional: erlaubt Wiederaktivierung nach Maus
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('mousedown', onMouseDown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('mousedown', onMouseDown)
})
return {
register
}
}

View File

@ -17,6 +17,10 @@
</template> </template>
<script setup> <script setup>
import { useDeferredTabFocus } from '@/composables/useDeferredTabFocus'
useDeferredTabFocus()
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
const mainStore = useMainStore() const mainStore = useMainStore()
import { usePageMeta } from '~/composables/usePageMeta' import { usePageMeta } from '~/composables/usePageMeta'