Improve accessibility with ARIA roles, focus styles and language switching
This commit is contained in:
@@ -10,12 +10,14 @@ const options = [
|
|||||||
function select(code: string) {
|
function select(code: string) {
|
||||||
modelValue.value = code
|
modelValue.value = code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hintId = 'language-hint'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<section aria-labelledby="language-title" aria-describedby="language-hint" role="radiogroup">
|
||||||
<h2>Sprache auswählen</h2>
|
<h2 id="language-title">Sprache auswählen</h2>
|
||||||
<p class="hint">Interface-Sprache für Fragen und Optionen</p>
|
<p :id="hintId" class="hint">Interface-Sprache für Fragen und Optionen</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<button
|
<button
|
||||||
v-for="opt in options"
|
v-for="opt in options"
|
||||||
@@ -23,12 +25,15 @@ function select(code: string) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="lang-btn"
|
class="lang-btn"
|
||||||
:class="{ active: opt.code === modelValue }"
|
:class="{ active: opt.code === modelValue }"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="opt.code === modelValue"
|
||||||
|
:aria-label="opt.label"
|
||||||
@click="select(opt.code)"
|
@click="select(opt.code)"
|
||||||
>
|
>
|
||||||
{{ opt.label }}
|
<span class="label">{{ opt.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -39,7 +44,7 @@ h2 {
|
|||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #9ca3af;
|
color: #6b7280;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +57,7 @@ h2 {
|
|||||||
.lang-btn {
|
.lang-btn {
|
||||||
padding: 0.55rem 0.5rem;
|
padding: 0.55rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #d1d5db;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
@@ -67,8 +72,17 @@ h2 {
|
|||||||
|
|
||||||
.lang-btn.active {
|
.lang-btn.active {
|
||||||
background: #e5f0ff;
|
background: #e5f0ff;
|
||||||
border-color: #3b82f6;
|
border-color: #2563eb;
|
||||||
color: #0b1f4a;
|
color: #0b1f4a;
|
||||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:focus-visible {
|
||||||
|
outline: 3px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const props = defineProps<{
|
|||||||
max: number
|
max: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const sliderId = 'pain-slider'
|
||||||
|
const labelId = 'pain-title'
|
||||||
|
|
||||||
function onInput(ev: Event) {
|
function onInput(ev: Event) {
|
||||||
const target = ev.target as HTMLInputElement
|
const target = ev.target as HTMLInputElement
|
||||||
modelValue.value = Number(target.value)
|
modelValue.value = Number(target.value)
|
||||||
@@ -14,21 +17,26 @@ function onInput(ev: Event) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<section>
|
||||||
<h2>{{ props.title }}</h2>
|
<h2 :id="labelId">{{ props.title }}</h2>
|
||||||
<div class="value">{{ modelValue }}</div>
|
<div class="value">{{ modelValue }}</div>
|
||||||
<input
|
<input
|
||||||
|
:id="sliderId"
|
||||||
type="range"
|
type="range"
|
||||||
:min="props.min"
|
:min="props.min"
|
||||||
:max="props.max"
|
:max="props.max"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
|
:aria-labelledby="labelId"
|
||||||
|
:aria-valuemin="props.min"
|
||||||
|
:aria-valuemax="props.max"
|
||||||
|
:aria-valuenow="modelValue"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
/>
|
/>
|
||||||
<div class="scale">
|
<div class="scale">
|
||||||
<span>{{ props.min }}</span>
|
<span>{{ props.min }}</span>
|
||||||
<span>{{ props.max }}</span>
|
<span>{{ props.max }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -54,6 +62,11 @@ input[type='range'] {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type='range']:focus-visible {
|
||||||
|
outline: 3px solid #2563eb;
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
input[type='range']::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ function select(value: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<section class="symptom-section" aria-labelledby="symptom-title" role="radiogroup">
|
||||||
<h2>{{ props.title }}</h2>
|
<h2 id="symptom-title">{{ props.title }}</h2>
|
||||||
|
<p class="sr-only">Bitte wählen Sie genau eine Option aus.</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<button
|
<button
|
||||||
v-for="opt in props.options"
|
v-for="opt in props.options"
|
||||||
@@ -27,18 +28,24 @@ function select(value: string) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="symptom-btn"
|
class="symptom-btn"
|
||||||
:class="{ active: opt.value === modelValue }"
|
:class="{ active: opt.value === modelValue }"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="opt.value === modelValue"
|
||||||
@click="select(opt.value)"
|
@click="select(opt.value)"
|
||||||
>
|
>
|
||||||
<span class="icon-wrapper" v-if="opt.icon">
|
<span v-if="opt.icon" class="icon-wrapper">
|
||||||
<img :src="opt.icon" :alt="opt.label" class="icon" />
|
<img :src="opt.icon" :alt="opt.label" class="icon" />
|
||||||
</span>
|
</span>
|
||||||
<span class="label">{{ opt.label }}</span>
|
<span class="label">{{ opt.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.symptom-section {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
@@ -53,7 +60,7 @@ h2 {
|
|||||||
.symptom-btn {
|
.symptom-btn {
|
||||||
padding: 0.75rem 0.75rem;
|
padding: 0.75rem 0.75rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #d1d5db;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@@ -66,6 +73,22 @@ h2 {
|
|||||||
transform 0.08s ease-out;
|
transform 0.08s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.symptom-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symptom-btn.active {
|
||||||
|
background: linear-gradient(145deg, #16a34a, #15803d);
|
||||||
|
border-color: #15803d;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 10px 20px rgba(22, 163, 74, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.symptom-btn:focus-visible {
|
||||||
|
outline: 3px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-wrapper {
|
.icon-wrapper {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
@@ -84,14 +107,15 @@ h2 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.symptom-btn:hover {
|
.sr-only {
|
||||||
background: #f3f4f6;
|
position: absolute;
|
||||||
}
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
.symptom-btn.active {
|
padding: 0;
|
||||||
background: linear-gradient(145deg, #22c55e, #16a34a);
|
margin: -1px;
|
||||||
border-color: #16a34a;
|
overflow: hidden;
|
||||||
color: #ffffff;
|
clip: rect(0, 0, 0, 0);
|
||||||
box-shadow: 0 10px 20px rgba(34, 197, 94, 0.35);
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import { useRouter } from 'vue-router'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { language, isLoadingConfig, loadError, loadQuestions } = useTriageSession()
|
const { language, isLoadingConfig, loadError, loadQuestions } = useTriageSession()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => language.value,
|
||||||
|
(val) => {
|
||||||
|
document.documentElement.lang = val
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => language.value,
|
() => language.value,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user