Skip to content
Snippets Groups Projects
Commit 51fbce93 authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch...

Merge branch '1203-tackle-accessiblity-review-and-write-a-simple-checklist-for-frontend-mrs' into 'master'

Resolve "Tackle accessiblity review and write a simple checklist for frontend MRs"

Closes #1203

See merge request !1752
parents 9845e08e a67431a9
No related branches found
No related tags found
1 merge request!1752Resolve "Tackle accessiblity review and write a simple checklist for frontend MRs"
Pipeline #196940 failed
Showing
with 425 additions and 93 deletions
......@@ -122,6 +122,7 @@ Fixed
* The OpenID Connect Discovery endpoint now returns the issuer data directly
under the URI without a trailing `/`.
* Not-logged in users were able to access all PDF files (CVE-2025-25683).
* Accessibility issues with new frontend.
Removed
~~~~~~~
......
......@@ -14,6 +14,7 @@ from django.utils.text import slugify
from django.utils.translation import gettext as _
import reversion
from color_contrast import AccessibilityLevel, ModulationMode, check_contrast, modulate
from reversion import set_comment
from tqdm import tqdm
......@@ -432,3 +433,194 @@ class EmailUniqueDataCheck(DataCheck):
if person.email and person.email in known_email_addresses:
cls.register_result(person)
known_email_addresses.add(person.email)
def accessible_colors_factory(
app_name: str,
model_name: str,
fg_field_name: str = None,
bg_field_name: str = None,
fg_color: str = "#ffffff",
bg_color: str = "#000000",
modulation_mode: ModulationMode = ModulationMode.BOTH,
) -> None:
ColorAccessibilityDataCheck.models.append(
(
app_name,
model_name,
fg_field_name,
bg_field_name,
fg_color,
bg_color,
modulation_mode,
)
)
def _get_colors_from_model_instance(instance, fg_field_name, bg_field_name, fg_color, bg_color):
colors: list[str] = [fg_color, bg_color]
if fg_field_name is not None:
colors[0] = getattr(instance, fg_field_name)
if bg_field_name is not None:
colors[1] = getattr(instance, bg_field_name)
# Transparency is not support for checking contrasts, so simply truncate it
for index, color in enumerate(colors):
if not color.startswith("#"):
continue
if len(color) == 5:
# color is of format "#RGBA"
colors[index] = color[:-1]
elif len(color) == 9:
# color is of format "#RRGGBBAA"
colors[index] = color[:-2]
return colors
class ModulateColorsSolveOption(SolveOption):
name = "modulate_colors"
verbose_name = _("Auto-adjust Colors")
@classmethod
def solve(cls, check_result: "DataCheckResult"):
instance = check_result.related_object
ctype = check_result.content_type
model_info = list(
filter(
lambda m: m[0] == ctype.app_label and m[1] == ctype.model,
ColorAccessibilityDataCheck.models,
)
)
if len(model_info) == 0:
check_result.solved = False
check_result.save()
logging.error(f"Modulate Colors check failed for {check_result}: Model Info not found")
return
elif len(model_info) > 1:
check_result.solved = False
check_result.save()
logging.error(f"Modulate Colors check failed for {check_result}: Duplicate Model Info")
return
[_, _, fg_field_name, bg_field_name, fg_color, bg_color, modulation_mode] = model_info[0]
colors = _get_colors_from_model_instance(
instance, fg_field_name, bg_field_name, fg_color, bg_color
)
fg_new, bg_new, success = modulate(*colors, mode=modulation_mode)
if not success:
check_result.solved = False
check_result.save()
logging.error(
f"Modulate Colors check failed for {check_result}: Modulation not possible"
)
return
if fg_field_name:
setattr(instance, fg_field_name, fg_new)
if bg_field_name:
setattr(instance, bg_field_name, bg_new)
instance.save()
check_result.solved = True
check_result.save()
class ColorAccessibilityDataCheck(DataCheck):
name = "colors_accessibility_datacheck"
verbose_name = _("Validate contrast accessibility of colors of customizable objects.")
problem_name = _("The colors of this object are not accessible.")
solve_options = {
IgnoreSolveOption._class_name: IgnoreSolveOption,
ModulateColorsSolveOption._class_name: ModulateColorsSolveOption,
}
models = []
@classmethod
def check_data(cls):
from django.apps import apps
for [
app_name,
model_name,
fg_field_name,
bg_field_name,
fg_color,
bg_color,
_modulation_mode,
] in cls.models:
model: Model = apps.get_model(app_name, model_name)
for obj in model.objects.all():
colors = _get_colors_from_model_instance(
obj, fg_field_name, bg_field_name, fg_color, bg_color
)
if not check_contrast(*colors, level=AccessibilityLevel.AA):
logging.info(f"Insufficient contrast in {app_name}.{model_name}.{obj}")
cls.register_result(obj)
class ModulateThemeColorsSolveOption(SolveOption):
name = "modulate_theme_colors"
verbose_name = _("Auto-adjust Color")
@classmethod
def solve(cls, check_result: "DataCheckResult"):
instance = check_result.related_object
preference = f"{instance.section}__{instance.name}"
prefs = get_site_preferences()
color = prefs[preference]
fg_new, color_new, success = modulate("#fff", color, mode=ModulationMode.BACKGROUND)
if not success:
check_result.solved = False
check_result.save()
logging.error(f"Modulate {instance.name} theme color failed: Modulation not possible.")
return
prefs[preference] = str(color_new)
check_result.solved = True
check_result.save()
class AccessibleThemeColorsDataCheck(DataCheck):
name = "accessible_themes_colors_datacheck"
verbose_name = _("Validate that theme colors are accessible.")
problem_name = _("The color does not provide enough contrast")
solve_options = {
IgnoreSolveOption._class_name: IgnoreSolveOption,
ModulateThemeColorsSolveOption._class_name: ModulateThemeColorsSolveOption,
}
@classmethod
def check_data(cls):
from dynamic_preferences.models import GlobalPreferenceModel
from .util.core_helpers import get_site_preferences
prefs = get_site_preferences()
primary = prefs["theme__primary"]
secondary = prefs["theme__secondary"]
# White text on primary colored background
if not check_contrast("#fff", primary, level=AccessibilityLevel.AA):
logging.info("Insufficient contrast in primary color")
obj = GlobalPreferenceModel.objects.get(section="theme", name="primary")
cls.register_result(obj)
# White text on secondary colored background
if not check_contrast("#fff", secondary, level=AccessibilityLevel.AA):
logging.info("Insufficient contrast in primary color")
obj = GlobalPreferenceModel.objects.get(section="theme", name="secondary")
cls.register_result(obj)
......@@ -37,6 +37,15 @@ const vuetifyOpts = {
updatePwa: "mdi-update",
},
},
theme: {
options: {
customProperties: true,
themeCache: {
get: (key) => localStorage.getItem(key),
set: (key, value) => localStorage.setItem(key, value),
},
},
},
};
export default vuetifyOpts;
......@@ -82,6 +82,7 @@ export default {
// Write title of iframe to SPA window
const title = this.$refs.contentIFrame.contentWindow.document.title;
this.$root.$setPageTitle(title);
this.$refs.contentIFrame.title = title;
// Adapt height of IFrame according to the height of its contents once and observe height changes
if (
......
<template>
<v-menu offset-y>
<v-menu offset-y max-height="80vh">
<template #activator="{ on, attrs }">
<v-avatar v-bind="attrs" v-on="on">
<v-avatar
v-bind="attrs"
v-on="on"
tag="button"
tabindex="0"
:aria-label="$t('actions.account_menu')"
>
<img
v-if="
systemProperties.sitePreferences.accountPersonPreferPhoto &&
......
......@@ -17,6 +17,7 @@
/>
<div v-else>
<side-nav
ref="sidenav"
v-model="drawer"
:system-properties="systemProperties"
:side-nav-menu="sideNavMenu"
......@@ -25,14 +26,19 @@
app
:color="$vuetify.theme.dark ? undefined : 'primary white--text'"
>
<v-app-bar-nav-icon @click="drawer = !drawer" color="white" />
<v-app-bar-nav-icon
@click="drawer = !drawer"
color="white"
:aria-label="$t('actions.toogle_sidenav')"
/>
<v-toolbar-title
tag="a"
class="white--text text-decoration-none"
@click="$router.push({ name: 'dashboard' })"
>
{{ $root.toolbarTitle }}
<v-toolbar-title>
<router-link
class="white--text text-decoration-none"
:to="{ name: 'dashboard' }"
>
{{ $root.toolbarTitle }}
</router-link>
</v-toolbar-title>
<v-progress-linear
......@@ -41,7 +47,8 @@
absolute
bottom
:color="$vuetify.theme.dark ? 'primary' : 'grey lighten-3'"
></v-progress-linear>
aria-hidden="true"
/>
<v-spacer />
<v-btn
......@@ -355,6 +362,12 @@ export default {
},
immediate: true,
},
drawer: function (newValue) {
if (newValue) {
// Drawer was opened, → focus sidenav
this.$refs.sidenav.focusList();
}
},
},
name: "App",
components: {
......@@ -382,7 +395,7 @@ export default {
<style>
div[aria-required="true"] .v-input .v-label::after {
content: " *";
color: red;
color: var(--v-error-base);
}
.main-container {
......
<script setup>
import Mascot from "../generic/mascot/Mascot.vue";
import PrimaryActionButton from "../generic/buttons/PrimaryActionButton.vue";
</script>
<template>
......@@ -15,14 +16,13 @@ import Mascot from "../generic/mascot/Mascot.vue";
/>
<h1 class="text-h2">{{ $t(shortErrorMessageKey) }}</h1>
<div>{{ $t(longErrorMessageKey) }}</div>
<v-btn
<primary-action-button
color="secondary"
:to="{ name: redirectRouteName }"
v-if="!hideButton"
>
<v-icon left>{{ redirectButtonIcon }}</v-icon>
{{ $t(redirectButtonTextKey) }}
</v-btn>
:icon-text="redirectButtonIcon"
:i18n-key="redirectButtonTextKey"
/>
</div>
</template>
......
......@@ -43,6 +43,7 @@ export default {
document.cookie = languageOption.cookie;
this.$i18n.locale = languageOption.code;
this.$vuetify.lang.current = languageOption.code;
document.getElementsByTagName("html")[0].lang = languageOption.code;
this.language = languageOption;
},
nameForMenu: function (item) {
......
......@@ -4,16 +4,17 @@
:value="value"
height="100dvh"
@input="$emit('input', $event)"
tag="aside"
>
<v-list nav dense shaped>
<v-list-item class="logo">
<a
id="logo-container"
@click="$router.push({ name: 'dashboard' })"
class="brand-logo"
>
<brand-logo :site-preferences="systemProperties.sitePreferences" />
</a>
<v-list nav dense shaped tag="nav">
<v-list-item
class="focusable"
ref="listItem"
:to="{ name: 'dashboard' }"
exact
color="transparent"
>
<brand-logo :site-preferences="systemProperties.sitePreferences" />
</v-list-item>
<v-list-item v-if="checkPermission('core.search_rule')" class="search">
<sidenav-search />
......@@ -146,6 +147,15 @@ export default {
comparator(array, value) {
return Array.isArray(array) && array.includes(value);
},
focusList() {
this.$nextTick(() => {
// console.log(this.$refs.listItem)
console.log(this.$refs.listItem.$el);
this.$refs.listItem.$el.focus();
// let el = document.querySelector(".focusable")
// el.focus()
});
},
},
};
</script>
......
......@@ -14,14 +14,20 @@ export default {
<template>
<div class="d-flex justify-center mx-2">
<v-btn icon @click="$emit('prev')" :small="small">
<v-icon>$prev</v-icon>
</v-btn>
<icon-button
@click="$emit('prev')"
:small="small"
icon-text="$prev"
i18n-key="actions.scroll_prev"
/>
<v-btn outlined text class="mx-1" @click="$emit('today')" :small="small">
{{ $t("calendar.today") }}
</v-btn>
<v-btn icon @click="$emit('next')" :small="small">
<v-icon>$next</v-icon>
</v-btn>
<icon-button
@click="$emit('next')"
:small="small"
icon-text="$next"
i18n-key="actions.scroll_next"
/>
</div>
</template>
......@@ -4,12 +4,14 @@
v-for="calendarFeed in calendarFeeds"
:key="calendarFeed.name"
:value="calendarFeed.name"
:tabindex="-1"
>
<template #default="{ active }">
<v-list-item-action>
<v-checkbox
:input-value="active"
:color="calendarFeed.color"
class="focusable"
></v-checkbox>
</v-list-item-action>
......@@ -20,23 +22,22 @@
</v-list-item-content>
<v-list-item-action>
<v-menu bottom>
<template #activator="{ on, attrs }">
<v-btn fab x-small icon v-bind="attrs" v-on="on">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item :href="calendarFeed.url">
<v-list-item-icon>
<v-icon>mdi-calendar-export</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $t("calendar.download_ics") }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<button-menu
icon-only
:outlined="false"
icon="mdi-dots-vertical"
:text="false"
text-translation-key="actions.more_actions"
>
<v-list-item :href="calendarFeed.url">
<v-list-item-icon>
<v-icon>mdi-calendar-export</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $t("calendar.download_ics") }}
</v-list-item-title>
</v-list-item>
</button-menu>
</v-list-item-action>
</template>
</v-list-item>
......
......@@ -54,6 +54,7 @@ export default {
v-for="calendarType in availableCalendarTypes"
:value="calendarType.type"
:key="calendarType.type"
:aria-label="nameForMenu(calendarType)"
>
<v-icon v-if="$vuetify.breakpoint.smAndDown">{{
calendarType.type === innerValue
......
<template>
<v-btn color="secondary" v-bind="$attrs">
<v-icon left>$prev</v-icon>
{{ $t("actions.back") }}
</v-btn>
</template>
<script>
import PrimaryActionButton from "./buttons/PrimaryActionButton.vue";
export default {
name: "BackButton",
extends: PrimaryActionButton,
props: {
iconText: {
type: String,
required: false,
default: "$prev",
},
i18nKey: {
type: String,
required: false,
default: "actions.back",
},
color: {
required: false,
default: "secondary",
},
},
};
</script>
......@@ -4,14 +4,26 @@
offset-y
:close-on-content-click="closeOnContentClick"
>
<template #activator="{ on, attrs }">
<slot name="activator" v-bind="{ on, attrs }">
<v-btn outlined text v-bind="attrs" v-on="on">
<v-icon :left="!!textTranslationKey" :center="!textTranslationKey">
{{ icon }}
</v-icon>
<span v-if="textTranslationKey">{{ $t(textTranslationKey) }}</span>
</v-btn>
<template #activator="menu">
<slot name="activator" v-bind="menu">
<v-tooltip bottom :disabled="!iconOnly">
<template #activator="tooltip">
<v-btn
:outlined="outlined"
:text="text"
:icon="!outlined && iconOnly"
v-bind="{ ...tooltip.attrs, ...menu.attrs }"
v-on="{ ...tooltip.on, ...menu.on }"
:aria-label="$t(textTranslationKey)"
>
<v-icon :left="!iconOnly" :center="iconOnly">
{{ icon }}
</v-icon>
<span v-if="!iconOnly">{{ $t(textTranslationKey) }}</span>
</v-btn>
</template>
<span v-if="iconOnly">{{ $t(textTranslationKey) }}</span>
</v-tooltip>
</slot>
</template>
......@@ -32,14 +44,26 @@ export default {
},
textTranslationKey: {
type: String,
required: true,
},
iconOnly: {
type: Boolean,
required: false,
default: "",
default: false,
},
closeOnContentClick: {
type: Boolean,
required: false,
default: true,
},
outlined: {
type: Boolean,
default: true,
},
text: {
type: Boolean,
default: true,
},
},
};
</script>
......
......@@ -26,9 +26,13 @@ export default {
<v-card tile class="full-width">
<v-card-title class="auto-margin">
<div class="d-flex align-center justify-center full-width">
<v-btn icon large class="me-4" @click="$emit('prev')">
<v-icon>$prev</v-icon>
</v-btn>
<icon-button
large
class="me-4"
@click="$emit('prev')"
icon-text="$prev"
i18n-key="actions.scroll_prev"
/>
<div class="flex-grow-0">
<date-field
solo-inverted
......@@ -41,9 +45,13 @@ export default {
readonly
/>
</div>
<v-btn icon large class="ms-4" @click="$emit('next')">
<v-icon>$next</v-icon>
</v-btn>
<icon-button
large
class="ms-4"
@click="$emit('next')"
icon-text="$next"
i18n-key="actions.scroll_next"
/>
</div>
</v-card-title>
</v-card>
......
<script>
import SecondaryActionButton from "./buttons/SecondaryActionButton.vue";
export default {
name: "SlideIterator",
components: { SecondaryActionButton },
extends: "v-data-iterator",
data() {
return {
......@@ -113,9 +110,12 @@ export default {
/>
</v-list-item-content>
<v-list-item-action>
<v-btn icon large :disabled="disabled">
<v-icon large>$next</v-icon>
</v-btn>
<icon-button
large
:disabled="disabled"
icon-text="$next"
i18n-key="actions.open_details"
/>
</v-list-item-action>
</template>
</v-list-item>
......
<template>
<v-btn v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<slot>
<v-icon v-if="iconText" :left="!icon">{{ iconText }}</v-icon>
<span v-if="!icon" v-t="i18nKey" />
</slot>
</v-btn>
<v-tooltip bottom :disabled="!icon && !forceTooltip" eager tag="div">
<template #activator="{ on, attrs }">
<v-btn
v-bind="{ ...$props, ...attrs, ...$attrs }"
v-on="{ ...on, ...$listeners }"
:aria-label="$t(i18nKey)"
>
<slot>
<v-icon v-if="iconText" :left="!icon">{{ iconText }}</v-icon>
<span v-if="!icon" v-t="i18nKey" />
</slot>
</v-btn>
</template>
<span v-if="forceTooltip || icon" v-t="i18nKey" />
</v-tooltip>
</template>
<script>
import VBtn from "@/vuetify/lib/components/VBtn";
export default {
name: "BaseButton",
inheritAttrs: true,
extends: "v-btn",
extends: VBtn,
props: {
i18nKey: {
type: String,
......@@ -31,6 +42,11 @@ export default {
required: false,
default: false,
},
forceTooltip: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
......
<template>
<v-btn v-bind="{ ...$props, ...$attrs }" v-on="$listeners" icon large>
<v-icon>$close</v-icon>
</v-btn>
<icon-button
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
:icon-text="iconText"
:i18n-key="i18nKey"
large
/>
</template>
<script>
import IconButton from "./IconButton.vue";
export default {
name: "DialogCloseButton",
components: { IconButton },
inheritAttrs: true,
extends: "v-btn",
extends: IconButton,
props: {
iconText: {
required: false,
default: "$close",
},
i18nKey: {
required: false,
default: "actions.close",
},
},
};
</script>
......@@ -8,24 +8,24 @@
<v-badge color="secondary" :value="numFilters" :content="numFilters" inline>
<span v-t="i18nKey" />
</v-badge>
<v-btn
icon
<icon-button
@click.stop="$emit('clear')"
small
v-if="numFilters"
class="mr-n1"
>
<v-icon>$clear</v-icon>
</v-btn>
icon-text="$clear"
i18n-key="actions.clear_filters"
/>
</secondary-action-button>
</template>
<script>
import SecondaryActionButton from "./SecondaryActionButton.vue";
import IconButton from "./IconButton.vue";
export default {
name: "FilterButton",
components: { SecondaryActionButton },
components: { IconButton, SecondaryActionButton },
extends: SecondaryActionButton,
computed: {
filterIcon() {
......
<script>
import BaseButton from "./BaseButton.vue";
export default {
name: "IconButton",
components: { BaseButton },
extends: BaseButton,
props: {
icon: {
required: false,
default: true,
},
},
};
</script>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment