Accessibility: add (translatable) aria labels, update to translation system, see #1181

This commit is contained in:
Pieter Vander Vennet 2023-12-12 19:18:50 +01:00
parent 825fd03adb
commit 8a7d8a43ce
12 changed files with 130 additions and 72 deletions

View file

@ -289,7 +289,7 @@
"generatedWith": "Generated with mapcomplete.org/{layoutid}",
"versionInfo": "v{version} - generated on {date}"
},
"pickLanguage": "Choose a language: ",
"pickLanguage": "Select language",
"poweredByOsm": "Powered by OpenStreetMap",
"questionBox": {
"answeredMultiple": "You answered {answered} questions",

View file

@ -2469,7 +2469,7 @@ button.link:hover {
fill: var(--foreground-color) !important;
}
label {
label:not(.neutral-label) {
/**
* Label should _contain_ the input element
*/
@ -2485,27 +2485,27 @@ label {
transition: all 250ms;
}
label:hover {
label:hover:not(.neutral-label) {
background-color: var(--catch-detail-color);
color: var(--catch-detail-foregroundcolor);
border: 2px solid var(--interactive-contrast)
}
label:not(.no-image-background) img {
label:not(.no-image-background):not(.neutral-label) img {
padding: 0.25rem;
border-radius: 0.25rem;
background: var(--low-interaction-background);
}
label svg path {
label:not(.neutral-label) svg path {
transition: all 250ms;
}
label:hover:not(.no-image-background) svg path {
label:hover:not(.no-image-background):not(.neutral-label) svg path {
fill: var(--catch-detail-foregroundcolor) !important;
}
label.checked {
label.checked:not(.neutral-label) {
border: 2px solid var(--foreground-color);
}

View file

@ -96,7 +96,12 @@ export abstract class Store<T> implements Readable<T> {
abstract map<J>(f: (t: T) => J): Store<J>
abstract map<J>(f: (t: T) => J, extraStoresToWatch: Store<any>[]): Store<J>
abstract map<J>(
f: (t: T) => J,
extraStoresToWatch: Store<any>[],
callbackDestroyFunction: (f: () => void) => void
): Store<J>
M
public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<any>[]
@ -329,9 +334,13 @@ export class ImmutableStore<T> extends Store<T> {
return ImmutableStore.pass
}
map<J>(f: (t: T) => J, extraStores: Store<any>[] = undefined): ImmutableStore<J> {
map<J>(
f: (t: T) => J,
extraStores: Store<any>[] = undefined,
ondestroyCallback?: (f: () => void) => void
): ImmutableStore<J> {
if (extraStores?.length > 0) {
return new MappedStore(this, f, extraStores, undefined, f(this.data))
return new MappedStore(this, f, extraStores, undefined, f(this.data), ondestroyCallback)
}
return new ImmutableStore<J>(f(this.data))
}
@ -463,7 +472,11 @@ class MappedStore<TIn, T> extends Store<T> {
return this._data
}
map<J>(f: (t: T) => J, extraStores: Store<any>[] = undefined): Store<J> {
map<J>(
f: (t: T) => J,
extraStores: Store<any>[] = undefined,
ondestroyCallback?: (f: () => void) => void
): Store<J> {
let stores: Store<any>[] = undefined
if (extraStores?.length > 0 || this._extraStores?.length > 0) {
stores = []
@ -483,7 +496,8 @@ class MappedStore<TIn, T> extends Store<T> {
f, // we could fuse the functions here (e.g. data => f(this._f(data), but this might result in _f being calculated multiple times, breaking things
stores,
this._callbacks,
f(this.data)
f(this.data),
ondestroyCallback
)
}

View file

@ -2,7 +2,6 @@
import { UIEventSource } from "../../Logic/UIEventSource.js"
export let value: UIEventSource<any>
let i: any = value.data
let htmlElement: HTMLSelectElement
function selectAppropriateValue() {
if (!htmlElement) {

View file

@ -3,36 +3,20 @@
* Properly renders a translation
*/
import { Translation } from "../i18n/Translation"
import { onDestroy } from "svelte"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import FromHtml from "./FromHtml.svelte"
import WeblateLink from "./WeblateLink.svelte"
import { Store } from "../../Logic/UIEventSource"
export let t: Translation
export let cls: string = ""
export let tags: Record<string, string> | undefined = undefined
// Text for the current language
let txt: string | undefined
let txt: Store<string | undefined> = t.current
$: onDestroy(
Locale.language.addCallbackAndRunD((l) => {
const translation = t?.textFor(l)
if (translation === undefined) {
return
}
if (tags) {
txt = Utils.SubstituteKeys(txt, tags)
} else {
txt = translation
}
})
)
</script>
{#if t}
<span class={cls}>
<FromHtml src={txt} />
{$txt}
<WeblateLink context={t.context} />
</span>
{/if}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { ImmutableStore, UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg.js"
@ -30,7 +30,20 @@
let inputElement: HTMLInputElement
let feedback: string = undefined
let placeholder = Translations.t.general.search.search.current
$:{
if(inputElement){
inputElement.placeholder = placeholder.data
}
}
onDestroy(placeholder.addCallbackAndRunD(placeholder => {
if(inputElement){
inputElement.placeholder = placeholder
}
}))
Hotkeys.RegisterHotkey({ ctrl: "F" }, Translations.t.hotkeyDocumentation.selectSearch, () => {
feedback = undefined
requestAnimationFrame(() => {
@ -111,7 +124,6 @@
bind:this={inputElement}
on:keypress={(keypr) => (keypr.key === "Enter" ? performSearch() : undefined)}
bind:value={searchContents}
placeholder={Translations.t.general.search.search}
/>
{/if}
</form>

View file

@ -41,6 +41,7 @@
on:click={() => {
expanded = true
}}
aria-expanded={expanded}
>
<Camera_plus class="mr-2 block h-8 w-8 p-1" />
<Tr t={t.seeNearby} />

View file

@ -1,16 +1,17 @@
<script lang="ts">
// Languages in the language itself
import native from "../../assets/language_native.json"
// Translated languages
import language_translations from "../../assets/language_translations.json"
// Languages in the language itself
import native from "../../assets/language_native.json"
// Translated languages
import language_translations from "../../assets/language_translations.json"
import { UIEventSource } from "../../Logic/UIEventSource"
import Locale from "../i18n/Locale"
import { LanguageIcon } from "@babeard/svelte-heroicons/solid"
import Dropdown from "../Base/Dropdown.svelte"
import { twMerge } from "tailwind-merge"
/**
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Locale from "../i18n/Locale"
import { LanguageIcon } from "@babeard/svelte-heroicons/solid"
import Dropdown from "../Base/Dropdown.svelte"
import { twMerge } from "tailwind-merge"
import Translations from "../i18n/Translations"
import { ariaLabel } from "../../Utils/ariaLabel"
/**
* Languages one can choose from
* Defaults to _all_ languages known by MapComplete
*/
@ -19,7 +20,7 @@
* EventStore to assign to, defaults to 'Locale.langauge'
*/
export let assignTo: UIEventSource<string> = Locale.language
export let preferredLanguages: UIEventSource<string[]> = undefined
export let preferredLanguages: Store<string[]> = undefined
let preferredFiltered: string[] = undefined
preferredLanguages?.addCallbackAndRunD((preferredLanguages) => {
let lng = navigator.language
@ -31,34 +32,40 @@
}
preferredFiltered = preferredLanguages?.filter((l) => availableLanguages.indexOf(l) >= 0)
})
export let clss : string = undefined
export let clss: string = undefined
let current = Locale.language
</script>
{#if availableLanguages?.length > 1}
<form class={twMerge("flex items-center max-w-full pr-4", clss)}>
<LanguageIcon class="h-4 w-4 mr-1 shrink-0" />
<Dropdown cls="max-w-full" value={assignTo}>
{#if preferredFiltered}
{#each preferredFiltered as language}
<label class="flex neutral-label" use:ariaLabel={Translations.t.general.pickLanguage}>
<LanguageIcon class="h-4 w-4 mr-1 shrink-0" aria-hidden="true" />
<Dropdown cls="max-w-full" value={assignTo}>
{#if preferredFiltered}
{#each preferredFiltered as language}
<option value={language} class="font-bold">
{native[language] ?? ""}
{#if language !== $current}
({language_translations[language]?.[$current] ?? language})
{/if}
</option>
{/each}
<option disabled />
{/if}
{#each availableLanguages.filter(l => l !== "_context") as language}
<option value={language} class="font-bold">
{native[language] ?? ""}
{#if language !== $current}
({language_translations[language]?.[$current] ?? language})
{#if language_translations[language]?.[$current] !== undefined}
({ language_translations[language]?.[$current] + " - " + language ?? language})
{:else}
({language})
{/if}
{/if}
</option>
{/each}
<option disabled />
{/if}
{#each availableLanguages as language}
<option value={language} class="font-bold">
{native[language] ?? ""}
{#if language !== $current}
({language_translations[language]?.[$current] + " - " + language ?? language})
{/if}
</option>
{/each}
</Dropdown>
</Dropdown>
</label>
</form>
{/if}

View file

@ -63,6 +63,10 @@ export default class Locale {
source = LocalStorageSource.Get("language", browserLanguage)
}
source.addCallbackAndRun((l) => {
document.documentElement.setAttribute("lang", l)
})
if (!Utils.runningFromConsole) {
// @ts-ignore
window.setLanguage = function (language: string) {

View file

@ -2,6 +2,7 @@ import Locale from "./Locale"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import LinkToWeblate from "../Base/LinkToWeblate"
import { Store } from "../../Logic/UIEventSource"
export class Translation extends BaseUIElement {
public static forcedLanguage = undefined
@ -9,6 +10,9 @@ export class Translation extends BaseUIElement {
public readonly translations: Record<string, string>
public readonly context?: string
private _current: Store<string>
private onDestroy: () => void
constructor(translations: string | Record<string, string>, context?: string) {
super()
if (translations === undefined) {
@ -66,6 +70,18 @@ export class Translation extends BaseUIElement {
return this.textFor(Translation.forcedLanguage ?? Locale.language.data)
}
get current(): Store<string> {
if (!this._current) {
this._current = Locale.language.map(
(l) => this.textFor(l),
[],
(f) => {
this.onDestroy = f
}
)
}
return this._current
}
static ExtractAllTranslationsFrom(
object: any,
context = ""
@ -108,6 +124,7 @@ export class Translation extends BaseUIElement {
Destroy() {
super.Destroy()
this.onDestroy()
this.isDestroyed = true
}

20
src/Utils/ariaLabel.ts Normal file
View file

@ -0,0 +1,20 @@
import { Translation } from "../UI/i18n/Translation"
export function ariaLabel(htmlElement: Element, t: Translation) {
let onDestroy: () => void = undefined
t.current.map(
(label) => {
console.log("Setting arialabel", label, "to", htmlElement)
htmlElement.setAttribute("aria-label", label)
},
[],
(f) => {
onDestroy = f
}
)
return {
destroy() {},
}
}

View file

@ -307,7 +307,7 @@ button.link:hover {
}
label {
label:not(.neutral-label) {
/**
* Label should _contain_ the input element
*/
@ -323,27 +323,27 @@ label {
transition: all 250ms;
}
label:hover {
label:hover:not(.neutral-label) {
background-color: var(--catch-detail-color);
color: var(--catch-detail-foregroundcolor);
border: 2px solid var(--interactive-contrast)
}
label:not(.no-image-background) img {
label:not(.no-image-background):not(.neutral-label) img {
padding: 0.25rem;
border-radius: 0.25rem;
background: var(--low-interaction-background);
}
label svg path {
label:not(.neutral-label) svg path {
transition: all 250ms;
}
label:hover:not(.no-image-background) svg path {
label:hover:not(.no-image-background):not(.neutral-label) svg path {
fill: var(--catch-detail-foregroundcolor) !important;
}
label.checked {
label.checked:not(.neutral-label) {
border: 2px solid var(--foreground-color);
}