diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 76412e6c5e37abe25464862c99aeee46cdbccedb..8aa19612a71d77e35ed4610232250121e7e065e5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 ~~~~~~~ diff --git a/aleksis/core/data_checks.py b/aleksis/core/data_checks.py index 382c037b7a03e41baeaf3c6be1426c9826d9032d..409435f529b57e3390b5d1f1a9d7d4d709a3775d 100644 --- a/aleksis/core/data_checks.py +++ b/aleksis/core/data_checks.py @@ -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) diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js index fa633f088bc0aea6b884705e1f3b14713e203ec8..b10c696946e6310a293b408a9e1c9aae050ddd6d 100644 --- a/aleksis/core/frontend/app/vuetify.js +++ b/aleksis/core/frontend/app/vuetify.js @@ -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; diff --git a/aleksis/core/frontend/components/LegacyBaseTemplate.vue b/aleksis/core/frontend/components/LegacyBaseTemplate.vue index 877d2ce85a4bc9f525a1ff6a5d4618148ab8a888..d61338fc17a8e1c4cda469b71e2b5851e9fd9db4 100644 --- a/aleksis/core/frontend/components/LegacyBaseTemplate.vue +++ b/aleksis/core/frontend/components/LegacyBaseTemplate.vue @@ -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 ( diff --git a/aleksis/core/frontend/components/app/AccountMenu.vue b/aleksis/core/frontend/components/app/AccountMenu.vue index c830df8458eb26ea1748c7ba507cc90c4a310a69..ac136f937642f03b766b1372c1e2f1bf476c80b1 100644 --- a/aleksis/core/frontend/components/app/AccountMenu.vue +++ b/aleksis/core/frontend/components/app/AccountMenu.vue @@ -1,7 +1,13 @@ <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 && diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue index 8fbc210b3fc65a1a3c380cdbe4906e06534d70e3..12d25ebd0a424feabfca9c71ab9ecf175863bacf 100644 --- a/aleksis/core/frontend/components/app/App.vue +++ b/aleksis/core/frontend/components/app/App.vue @@ -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 { diff --git a/aleksis/core/frontend/components/app/ErrorPage.vue b/aleksis/core/frontend/components/app/ErrorPage.vue index c45967829ba37f8d07081e2b70b17be77c833e4d..c0f5b710aff94f0946ad1972b774b23080b58a23 100644 --- a/aleksis/core/frontend/components/app/ErrorPage.vue +++ b/aleksis/core/frontend/components/app/ErrorPage.vue @@ -1,5 +1,6 @@ <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> diff --git a/aleksis/core/frontend/components/app/LanguageForm.vue b/aleksis/core/frontend/components/app/LanguageForm.vue index d5d1e71a7e92a9feff5fea35b888fcb53dc535ae..89a8479c4da73f5d25d369d5b49b51d051c4436e 100644 --- a/aleksis/core/frontend/components/app/LanguageForm.vue +++ b/aleksis/core/frontend/components/app/LanguageForm.vue @@ -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) { diff --git a/aleksis/core/frontend/components/app/SideNav.vue b/aleksis/core/frontend/components/app/SideNav.vue index 6cf3fa740a6c6090733553a58f6754fa11dc1c30..4a810047835fe5d717919a7e16dfe4d93409cbf2 100644 --- a/aleksis/core/frontend/components/app/SideNav.vue +++ b/aleksis/core/frontend/components/app/SideNav.vue @@ -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> diff --git a/aleksis/core/frontend/components/calendar/CalendarControlBar.vue b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue index 640b067425767b595fdc555cba4ca6771c9bb33b..91aba1f0e380fa438caf3dd88d14e796749af576 100644 --- a/aleksis/core/frontend/components/calendar/CalendarControlBar.vue +++ b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue @@ -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> diff --git a/aleksis/core/frontend/components/calendar/CalendarSelect.vue b/aleksis/core/frontend/components/calendar/CalendarSelect.vue index 71bb96254f4da986067932c1c761c40ed571a69a..11f87dcc694e4dd36faed0b3ee001654f77c5347 100644 --- a/aleksis/core/frontend/components/calendar/CalendarSelect.vue +++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue @@ -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> diff --git a/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue index 41993c50108a0112518e1fcec777eee1497ce228..85454c5301eeaa6decca1711edd8e0fa11e8ad77 100644 --- a/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue +++ b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue @@ -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 diff --git a/aleksis/core/frontend/components/generic/BackButton.vue b/aleksis/core/frontend/components/generic/BackButton.vue index f446f5d6f84d7427654b646785ac1c08e9cbaa40..535bb23cfc4f4c607989e814429540663e51f127 100644 --- a/aleksis/core/frontend/components/generic/BackButton.vue +++ b/aleksis/core/frontend/components/generic/BackButton.vue @@ -1,12 +1,24 @@ -<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> diff --git a/aleksis/core/frontend/components/generic/ButtonMenu.vue b/aleksis/core/frontend/components/generic/ButtonMenu.vue index f1ef728503695747b278003d85e405a76787a65d..79192950408d3103ffe7cbeb36a5f95d1da9c202 100644 --- a/aleksis/core/frontend/components/generic/ButtonMenu.vue +++ b/aleksis/core/frontend/components/generic/ButtonMenu.vue @@ -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> diff --git a/aleksis/core/frontend/components/generic/DateSelectFooter.vue b/aleksis/core/frontend/components/generic/DateSelectFooter.vue index 952a586d917a0d232728e03ba69253eea78c076f..414b4a9cce8ded961bcdb6e13e16003f0178de90 100644 --- a/aleksis/core/frontend/components/generic/DateSelectFooter.vue +++ b/aleksis/core/frontend/components/generic/DateSelectFooter.vue @@ -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> diff --git a/aleksis/core/frontend/components/generic/SlideIterator.vue b/aleksis/core/frontend/components/generic/SlideIterator.vue index b3bf02da80b7710ca2ba50e81399b6f4a8c4ed09..62b69d6b5943201f9a082b329990e30f33d184ff 100644 --- a/aleksis/core/frontend/components/generic/SlideIterator.vue +++ b/aleksis/core/frontend/components/generic/SlideIterator.vue @@ -1,9 +1,6 @@ <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> diff --git a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue index e3b17a23bc3be166a1be05c378bdae54027ba1bf..d7d49d84786c8e4be8c0795cc4d2b610567022f5 100644 --- a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue +++ b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue @@ -1,17 +1,28 @@ <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> diff --git a/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue b/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue index 5998f13f9895bb61583ee5daf5ad3c48eb67c0a2..08452f6daab63059cbccff61af3a134794006fad 100644 --- a/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue +++ b/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue @@ -1,13 +1,30 @@ <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> diff --git a/aleksis/core/frontend/components/generic/buttons/FilterButton.vue b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue index 54d6dc626ba7b039361bc8e590a927ce3313157d..eae873d64d75bb2ec16afe987a9774e4d1b5c3a6 100644 --- a/aleksis/core/frontend/components/generic/buttons/FilterButton.vue +++ b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue @@ -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() { diff --git a/aleksis/core/frontend/components/generic/buttons/IconButton.vue b/aleksis/core/frontend/components/generic/buttons/IconButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..98a819df9f33a361585cba0510439441ba975126 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/IconButton.vue @@ -0,0 +1,14 @@ +<script> +import BaseButton from "./BaseButton.vue"; +export default { + name: "IconButton", + components: { BaseButton }, + extends: BaseButton, + props: { + icon: { + required: false, + default: true, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue index 16fd418c34bbc3f3ffe3549e3a86635ffd45ec11..72c05c922b3a7437fe81e311202bf03c27fd3d5a 100644 --- a/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue +++ b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue @@ -2,9 +2,12 @@ <v-snackbar v-bind="$attrs" v-on="$listeners"> <slot /> <template #action="{ attrs }"> - <v-btn v-bind="attrs" @click="close()" icon> - <v-icon>$close</v-icon> - </v-btn> + <icon-button + v-bind="attrs" + @click="close()" + icon-text="$close" + i18n-key="actions.close" + /> </template> </v-snackbar> </template> diff --git a/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue b/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue index dc71aa8a0933e7d0491c01b3e5a79857fe8e8178..bde22c162dd64c87b539d3ecf91b68a7cb8cb90a 100644 --- a/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue +++ b/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue @@ -49,9 +49,7 @@ export default { <v-card-title v-if="isDialog" class="pa-0"> <v-toolbar> <slot name="cancel"> - <v-btn icon @click="handleClose"> - <v-icon>$cancel</v-icon> - </v-btn> + <dialog-close-button @click="handleClose" /> </slot> <v-toolbar-title> diff --git a/aleksis/core/frontend/components/generic/forms/FileField.vue b/aleksis/core/frontend/components/generic/forms/FileField.vue index abf8a9c9ddf01cee0705a49b09c475769bae7337..f7cfd89453e2e72558df191c31207ccc4eaeec54 100644 --- a/aleksis/core/frontend/components/generic/forms/FileField.vue +++ b/aleksis/core/frontend/components/generic/forms/FileField.vue @@ -8,9 +8,12 @@ :hint="hint" > <template #append> - <v-btn v-if="showClear" icon @click.stop="clearOrDelete"> - <v-icon>$clear</v-icon> - </v-btn> + <icon-button + v-if="showClear" + @click.stop="clearOrDelete" + icon-text="$clear" + i18n-key="actions.clear" + /> </template> <template #append-outer> <v-expand-x-transition> @@ -21,9 +24,12 @@ <div v-if="!internalState && initialState?.url" class="mr-1"> <slot name="append-outer" :file-url="initialState?.url" /> </div> - <v-btn v-if="showDelete" icon @click.stop="clearOrDelete"> - <v-icon>$deleteContent</v-icon> - </v-btn> + <icon-button + v-if="showDelete" + @click.stop="clearOrDelete" + icon-text="$deleteContent" + i18n-key="actions.delete" + /> </div> </v-expand-x-transition> </template> diff --git a/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue index aaf2a6277002dc66fa0cbf094798025bfdec759c..0a148d284e139521f4fec528c7ce95905169cf3f 100644 --- a/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue +++ b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue @@ -26,9 +26,11 @@ <slot name="progress" /> </template> <template #append-outer v-if="enableCreate"> - <v-btn icon @click="createMode = true"> - <v-icon>$plus</v-icon> - </v-btn> + <icon-button + @click="createMode = true" + icon-text="$plus" + i18n-key="actions.create" + /> <slot name="createComponent" diff --git a/aleksis/core/frontend/components/group/GroupActions.vue b/aleksis/core/frontend/components/group/GroupActions.vue index b9f5d606999822f4fbd4bde66dfcb7a0de18de11..fce07fb955800c0a4c48d0e0f29d2b911941ffb0 100644 --- a/aleksis/core/frontend/components/group/GroupActions.vue +++ b/aleksis/core/frontend/components/group/GroupActions.vue @@ -6,7 +6,12 @@ :to="{ name: 'core.editGroup', params: { id: group.id } }" /> - <button-menu :close-on-content-click="false" v-if="actions.length"> + <button-menu + :close-on-content-click="false" + v-if="actions.length" + icon-only + text-translation-key="actions.more_actions" + > <component :is="action.component" v-for="action in actions" diff --git a/aleksis/core/frontend/components/group/GroupMembers.vue b/aleksis/core/frontend/components/group/GroupMembers.vue index e35b896cdf6746bd838973ed3e50e3ca833bb3ec..c64fb96689ffd197f637f0a74565858bc17063ea 100644 --- a/aleksis/core/frontend/components/group/GroupMembers.vue +++ b/aleksis/core/frontend/components/group/GroupMembers.vue @@ -92,6 +92,7 @@ export default { v-on="on" icon icon-text="mdi-open-in-new" + i18n-key="actions.open_in_new" :outlined="false" target="_blank" :to="{ diff --git a/aleksis/core/frontend/components/notifications/NotificationItem.vue b/aleksis/core/frontend/components/notifications/NotificationItem.vue index 171c7f728dd9232ae4e5f4c4934817c87b1a0245..930d40c67730e558db8f328ab18defa8a80a7b39 100644 --- a/aleksis/core/frontend/components/notifications/NotificationItem.vue +++ b/aleksis/core/frontend/components/notifications/NotificationItem.vue @@ -42,37 +42,21 @@ </v-list-item-content> <v-list-item-action> - <v-tooltip bottom> - <template #activator="{ on, attrs }"> - <v-btn - icon - color="secondary" - v-if="!notification.read" - @click="mutate" - v-bind="attrs" - v-on="on" - > - <v-icon>mdi-email-outline</v-icon> - </v-btn> - </template> - <span>{{ $t("notifications.mark_as_read") }}</span> - </v-tooltip> + <icon-button + icon-text="mdi-email-outline" + color="secondary" + i18n-key="notifications.mark_as_read" + v-if="!notification.read" + @click="mutate" + /> - <v-tooltip bottom> - <template #activator="{ on, attrs }"> - <v-btn - icon - color="accent" - :href="notification.link" - v-if="notification.link" - v-bind="attrs" - v-on="on" - > - <v-icon>mdi-open-in-new</v-icon> - </v-btn> - </template> - <span>{{ $t("notifications.more_information") }}</span> - </v-tooltip> + <icon-button + icon-text="mdi-open-in-new" + color="accent" + i18n-key="notifications.more_information" + :href="notification.link" + v-if="notification.link" + /> </v-list-item-action> </v-list-item> </template> @@ -81,6 +65,7 @@ <script> import { DateTime } from "luxon"; + export default { props: { notification: { diff --git a/aleksis/core/frontend/components/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue index 47b741fbeb122f49d6fdbcb0d276df4fcf0d3761..02e4c9acf3f890f3492fc2c8319ab5cb47219240 100644 --- a/aleksis/core/frontend/components/notifications/NotificationList.vue +++ b/aleksis/core/frontend/components/notifications/NotificationList.vue @@ -14,6 +14,7 @@ v-on="on" :loading="$apollo.queries.myNotifications.loading" class="mx-2" + :aria-label="$t('actions.list_notifications')" > <v-icon v-if=" @@ -27,14 +28,14 @@ <v-icon color="white" v-else>mdi-bell-outline</v-icon> </v-btn> </template> - <v-skeleton-loader - v-if="$apollo.queries.myNotifications.loading" - class="mx-auto" - type="paragraph" - ></v-skeleton-loader> - <v-list v-else nav three-line dense class="overflow-y-auto"> + <v-list nav three-line dense class="overflow-y-auto"> + <v-skeleton-loader + v-if="$apollo.queries.myNotifications.loading" + class="mx-auto" + type="paragraph" + ></v-skeleton-loader> <template - v-if=" + v-else-if=" myNotifications.person && myNotifications.person.notifications && myNotifications.person.notifications.length @@ -57,16 +58,14 @@ ></v-divider> </template> </template> - <template v-else> - <v-list-item> - <div class="d-flex justify-center align-center flex-column"> - <div class="mb-4"> - <mascot type="no_notifications" width="min(200px, 30vw)" /> - </div> - <div>{{ $t("notifications.no_notifications") }}</div> + <v-list-item v-else value="empty"> + <div class="d-flex justify-center align-center flex-column"> + <div class="mb-4"> + <mascot type="no_notifications" width="min(200px, 30vw)" /> </div> - </v-list-item> - </template> + <div>{{ $t("notifications.no_notifications") }}</div> + </div> + </v-list-item> </v-list> </v-menu> </template> diff --git a/aleksis/core/frontend/components/person/PersonActions.vue b/aleksis/core/frontend/components/person/PersonActions.vue index 0929fde34b3d918f9fd23df8e381a74b32182934..59fcb0f49d0784e5ddd131296b39379dd257a0fd 100644 --- a/aleksis/core/frontend/components/person/PersonActions.vue +++ b/aleksis/core/frontend/components/person/PersonActions.vue @@ -36,6 +36,8 @@ import PersonForm from "./PersonForm.vue"; person.canInvitePerson || person.canDelete " + icon-only + text-translation-key="actions.more_actions" > <v-list-item v-if="person.canImpersonatePerson" diff --git a/aleksis/core/frontend/components/person/PersonOverview.vue b/aleksis/core/frontend/components/person/PersonOverview.vue index 761bb24da034d62c1e64271ced82c9cefe114190..326fac4704c1ecd67bf4ad725b4f660ff56b4154 100644 --- a/aleksis/core/frontend/components/person/PersonOverview.vue +++ b/aleksis/core/frontend/components/person/PersonOverview.vue @@ -276,9 +276,9 @@ <v-list-item-icon> <v-icon>mdi-account-tie-hat-outline</v-icon> </v-list-item-icon> - <v-list-item-title>{{ - $tc("group.owner_of_n", person.ownerOf.length) - }}</v-list-item-title> + <v-list-item-title> + {{ $tc("group.owner_of_n", person.ownerOf.length) }} + </v-list-item-title> </template> <group-collection :groups="person.ownerOf" dense /> </v-list-group> diff --git a/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue index e9c0543837faf74b89c6f314bc11c8079bdc70a6..de79b0d078b2b34d5357a02bb9200de130e22718 100644 --- a/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue +++ b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue @@ -114,6 +114,7 @@ export default { v-bind="{ ...$attrs, ...attrs }" v-on="on" :loading="$apollo.queries.activeSchoolTerm.loading" + :aria-label="$t('actions.select_school_term')" > <v-icon v-if="activeSchoolTerm?.current">$schoolTerm</v-icon> <v-icon v-else>mdi-calendar-alert-outline</v-icon> diff --git a/aleksis/core/frontend/css/global.scss b/aleksis/core/frontend/css/global.scss index 135927a8572c1c38f805eab2027ad7a9bc58224d..dda4e1193ffe3cb95b51b642ac3e7857d7420ae7 100644 --- a/aleksis/core/frontend/css/global.scss +++ b/aleksis/core/frontend/css/global.scss @@ -44,3 +44,10 @@ h6, display: flex; flex-direction: column; } + +.focusable:focus, +.focusable:focus-visible, +.focusable:focus-within { + outline: 2px solid var(--v-primary-base); + outline-offset: 4px; +} diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 14013b9d550dcbde776eaa10204e769e2a8ef5e5..aec2bae346f92746621bf461173ee72324f73785 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -74,6 +74,7 @@ "actions": { "back": "Back", "cancel": "Cancel", + "clear": "Clear field", "clear_filters": "Clear Filters", "close": "Close", "confirm": "Confirm", @@ -94,7 +95,16 @@ "title": "Actions", "update": "Update", "next": "Next", - "type_to_search": "Start typing to search" + "type_to_search": "Start typing to search", + "toggle_sidenav": "Toggle side navigation panel", + "select_school_term": "Open Active School Term Select", + "list_notifications": "Open Notification List", + "account_menu": "Open account menu", + "more_actions": "More actions", + "scroll_prev": "Scroll to previous", + "scroll_next": "Scroll to next", + "open_details": "View details", + "open_in_new": "Open in new tab" }, "administration": { "backend_admin": { diff --git a/aleksis/core/frontend/plugins/aleksis.js b/aleksis/core/frontend/plugins/aleksis.js index 22bf3e16551989d60c26e76ca9e2043d353d22bb..e268fc6c344c31a5720f9378dc68316c633b49c0 100644 --- a/aleksis/core/frontend/plugins/aleksis.js +++ b/aleksis/core/frontend/plugins/aleksis.js @@ -54,39 +54,39 @@ AleksisVue.install = function (Vue) { * Register all global components that shall be reusable by apps. */ Vue.$registerGlobalComponents = function () { - Vue.component( - "MessageBox", - () => import("../components/generic/MessageBox.vue"), - ); - Vue.component( - "SmallContainer", - () => import("../components/generic/SmallContainer.vue"), - ); - Vue.component( - "BackButton", - () => import("../components/generic/BackButton.vue"), - ); - Vue.component( - "AvatarClickbox", - () => import("../components/generic/AvatarClickbox.vue"), - ); - Vue.component( - "DetailView", - () => import("../components/generic/DetailView.vue"), - ); - Vue.component( - "ListView", - () => import("../components/generic/ListView.vue"), - ); - Vue.component( - "ButtonMenu", - () => import("../components/generic/ButtonMenu.vue"), - ); - Vue.component("ErrorPage", () => import("../components/app/ErrorPage.vue")); - Vue.component( - "CalendarWithControls", - () => import("../components/calendar/CalendarWithControls.vue"), - ); + const globalComponents = { + // General stuff + AvatarClickbox: "../components/generic/AvatarClickbox.vue", + CalendarWithControls: "../components/calendar/CalendarWithControls.vue", + ErrorPage: "../components/app/ErrorPage.vue", + MessageBox: "../components/generic/MessageBox.vue", + SmallContainer: "../components/generic/SmallContainer.vue", + + // Layout + DetailView: "../components/generic/DetailView.vue", + ListView: "../components/generic/ListView.vue", + + // Buttons: + BackButton: "../components/generic/BackButton.vue", + ButtonMenu: "../components/generic/ButtonMenu.vue", + CancelButton: "../components/generic/buttons/CancelButton.vue", + CreateButton: "../components/generic/buttons/CreateButton.vue", + DeleteButton: "../components/generic/buttons/DeleteButton.vue", + DialogCloseButton: "../components/generic/buttons/DialogCloseButton.vue", + EditButton: "../components/generic/buttons/EditButton.vue", + FabButton: "../components/generic/buttons/FabButton.vue", + FilterButton: "../components/generic/buttons/FilterButton.vue", + IconButton: "../components/generic/buttons/IconButton.vue", + PrimaryActionButton: + "../components/generic/buttons/PrimaryActionButton.vue", + SaveButton: "../components/generic/buttons/SaveButton.vue", + SecondaryActionButton: + "../components/generic/buttons/SecondaryActionButton.vue", + }; + + for (let [name, path] of Object.entries(globalComponents)) { + Vue.component(name, () => import(path)); + } }; /** diff --git a/aleksis/core/templates/core/vue_index.html b/aleksis/core/templates/core/vue_index.html index 19e192432ac050d51ca23ed3a13bead25099a346..5f510d39060110baf282c3c9e2823830b144bf9a 100644 --- a/aleksis/core/templates/core/vue_index.html +++ b/aleksis/core/templates/core/vue_index.html @@ -3,7 +3,7 @@ {% get_current_language as LANGUAGE_CODE %} <!DOCTYPE html> -<html> +<html lang="{{ LANGUAGE_CODE }}"> <head> <base href="{{ BASE_URL }}"> diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py index 0cc782008a7388a2664c939bb5f54d8df08b55dc..7660b52c1cd1cf66ec17d5300404277507bc85e8 100644 --- a/aleksis/core/util/pdf.py +++ b/aleksis/core/util/pdf.py @@ -40,16 +40,12 @@ def _generate_pdf_with_webdriver(temp_dir, pdf_path, html_url, lang): if settings.SELENIUM_URL is None: driver_manager = GeckoDriverManager() service = FirefoxService( - driver_manager.install(), - service_args=["--profile-root", temp_dir] + driver_manager.install(), service_args=["--profile-root", temp_dir] ) driver = webdriver.Firefox(service=service, options=driver_options) else: - driver = webdriver.Remote( - command_executor=settings.SELENIUM_URL, - options=driver_options - ) + driver = webdriver.Remote(command_executor=settings.SELENIUM_URL, options=driver_options) driver.get(html_url) print_options = PrintOptions() diff --git a/pyproject.toml b/pyproject.toml index f9fbbc216ae6b9531628c11e77dd443fc5a7bd17..3b7bcb3a1dbbb7332d75557fc88e51c90fcea95f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,7 @@ defusedxml = "^0.7.1" graphene-file-upload = "^1.3.0" django-countries = "^7.6.1" webdriver-manager = "^4.0.2" +color-contrast = "^0.1.1" [tool.poetry.extras] ldap = ["django-auth-ldap"]