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}", "generatedWith": "Generated with mapcomplete.org/{layoutid}",
"versionInfo": "v{version} - generated on {date}" "versionInfo": "v{version} - generated on {date}"
}, },
"pickLanguage": "Choose a language: ", "pickLanguage": "Select language",
"poweredByOsm": "Powered by OpenStreetMap", "poweredByOsm": "Powered by OpenStreetMap",
"questionBox": { "questionBox": {
"answeredMultiple": "You answered {answered} questions", "answeredMultiple": "You answered {answered} questions",

View file

@ -2469,7 +2469,7 @@ button.link:hover {
fill: var(--foreground-color) !important; fill: var(--foreground-color) !important;
} }
label { label:not(.neutral-label) {
/** /**
* Label should _contain_ the input element * Label should _contain_ the input element
*/ */
@ -2485,27 +2485,27 @@ label {
transition: all 250ms; transition: all 250ms;
} }
label:hover { label:hover:not(.neutral-label) {
background-color: var(--catch-detail-color); background-color: var(--catch-detail-color);
color: var(--catch-detail-foregroundcolor); color: var(--catch-detail-foregroundcolor);
border: 2px solid var(--interactive-contrast) border: 2px solid var(--interactive-contrast)
} }
label:not(.no-image-background) img { label:not(.no-image-background):not(.neutral-label) img {
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--low-interaction-background); background: var(--low-interaction-background);
} }
label svg path { label:not(.neutral-label) svg path {
transition: all 250ms; 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; fill: var(--catch-detail-foregroundcolor) !important;
} }
label.checked { label.checked:not(.neutral-label) {
border: 2px solid var(--foreground-color); 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): Store<J>
abstract map<J>(f: (t: T) => J, extraStoresToWatch: Store<any>[]): 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>( public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J, f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<any>[] extraStoresToWatch?: Store<any>[]
@ -329,9 +334,13 @@ export class ImmutableStore<T> extends Store<T> {
return ImmutableStore.pass 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) { 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)) return new ImmutableStore<J>(f(this.data))
} }
@ -463,7 +472,11 @@ class MappedStore<TIn, T> extends Store<T> {
return this._data 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 let stores: Store<any>[] = undefined
if (extraStores?.length > 0 || this._extraStores?.length > 0) { if (extraStores?.length > 0 || this._extraStores?.length > 0) {
stores = [] 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 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, stores,
this._callbacks, this._callbacks,
f(this.data) f(this.data),
ondestroyCallback
) )
} }

View file

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

View file

@ -3,36 +3,20 @@
* Properly renders a translation * Properly renders a translation
*/ */
import { Translation } from "../i18n/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 WeblateLink from "./WeblateLink.svelte"
import { Store } from "../../Logic/UIEventSource"
export let t: Translation export let t: Translation
export let cls: string = "" export let cls: string = ""
export let tags: Record<string, string> | undefined = undefined
// Text for the current language // 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> </script>
{#if t} {#if t}
<span class={cls}> <span class={cls}>
<FromHtml src={txt} /> {$txt}
<WeblateLink context={t.context} /> <WeblateLink context={t.context} />
</span> </span>
{/if} {/if}

View file

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

View file

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

View file

@ -4,12 +4,13 @@
// Translated languages // Translated languages
import language_translations from "../../assets/language_translations.json" import language_translations from "../../assets/language_translations.json"
import { UIEventSource } from "../../Logic/UIEventSource" import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Locale from "../i18n/Locale" import Locale from "../i18n/Locale"
import { LanguageIcon } from "@babeard/svelte-heroicons/solid" import { LanguageIcon } from "@babeard/svelte-heroicons/solid"
import Dropdown from "../Base/Dropdown.svelte" import Dropdown from "../Base/Dropdown.svelte"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import Translations from "../i18n/Translations"
import { ariaLabel } from "../../Utils/ariaLabel"
/** /**
* Languages one can choose from * Languages one can choose from
* Defaults to _all_ languages known by MapComplete * Defaults to _all_ languages known by MapComplete
@ -19,7 +20,7 @@
* EventStore to assign to, defaults to 'Locale.langauge' * EventStore to assign to, defaults to 'Locale.langauge'
*/ */
export let assignTo: UIEventSource<string> = Locale.language export let assignTo: UIEventSource<string> = Locale.language
export let preferredLanguages: UIEventSource<string[]> = undefined export let preferredLanguages: Store<string[]> = undefined
let preferredFiltered: string[] = undefined let preferredFiltered: string[] = undefined
preferredLanguages?.addCallbackAndRunD((preferredLanguages) => { preferredLanguages?.addCallbackAndRunD((preferredLanguages) => {
let lng = navigator.language let lng = navigator.language
@ -37,7 +38,8 @@
{#if availableLanguages?.length > 1} {#if availableLanguages?.length > 1}
<form class={twMerge("flex items-center max-w-full pr-4", clss)}> <form class={twMerge("flex items-center max-w-full pr-4", clss)}>
<LanguageIcon class="h-4 w-4 mr-1 shrink-0" /> <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}> <Dropdown cls="max-w-full" value={assignTo}>
{#if preferredFiltered} {#if preferredFiltered}
{#each preferredFiltered as language} {#each preferredFiltered as language}
@ -51,14 +53,19 @@
<option disabled /> <option disabled />
{/if} {/if}
{#each availableLanguages as language} {#each availableLanguages.filter(l => l !== "_context") as language}
<option value={language} class="font-bold"> <option value={language} class="font-bold">
{native[language] ?? ""} {native[language] ?? ""}
{#if language !== $current} {#if language !== $current}
{#if language_translations[language]?.[$current] !== undefined}
({ language_translations[language]?.[$current] + " - " + language ?? language}) ({ language_translations[language]?.[$current] + " - " + language ?? language})
{:else}
({language})
{/if}
{/if} {/if}
</option> </option>
{/each} {/each}
</Dropdown> </Dropdown>
</label>
</form> </form>
{/if} {/if}

View file

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

View file

@ -2,6 +2,7 @@ import Locale from "./Locale"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement" import BaseUIElement from "../BaseUIElement"
import LinkToWeblate from "../Base/LinkToWeblate" import LinkToWeblate from "../Base/LinkToWeblate"
import { Store } from "../../Logic/UIEventSource"
export class Translation extends BaseUIElement { export class Translation extends BaseUIElement {
public static forcedLanguage = undefined public static forcedLanguage = undefined
@ -9,6 +10,9 @@ export class Translation extends BaseUIElement {
public readonly translations: Record<string, string> public readonly translations: Record<string, string>
public readonly context?: string public readonly context?: string
private _current: Store<string>
private onDestroy: () => void
constructor(translations: string | Record<string, string>, context?: string) { constructor(translations: string | Record<string, string>, context?: string) {
super() super()
if (translations === undefined) { if (translations === undefined) {
@ -66,6 +70,18 @@ export class Translation extends BaseUIElement {
return this.textFor(Translation.forcedLanguage ?? Locale.language.data) 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( static ExtractAllTranslationsFrom(
object: any, object: any,
context = "" context = ""
@ -108,6 +124,7 @@ export class Translation extends BaseUIElement {
Destroy() { Destroy() {
super.Destroy() super.Destroy()
this.onDestroy()
this.isDestroyed = true 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 * Label should _contain_ the input element
*/ */
@ -323,27 +323,27 @@ label {
transition: all 250ms; transition: all 250ms;
} }
label:hover { label:hover:not(.neutral-label) {
background-color: var(--catch-detail-color); background-color: var(--catch-detail-color);
color: var(--catch-detail-foregroundcolor); color: var(--catch-detail-foregroundcolor);
border: 2px solid var(--interactive-contrast) border: 2px solid var(--interactive-contrast)
} }
label:not(.no-image-background) img { label:not(.no-image-background):not(.neutral-label) img {
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--low-interaction-background); background: var(--low-interaction-background);
} }
label svg path { label:not(.neutral-label) svg path {
transition: all 250ms; 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; fill: var(--catch-detail-foregroundcolor) !important;
} }
label.checked { label.checked:not(.neutral-label) {
border: 2px solid var(--foreground-color); border: 2px solid var(--foreground-color);
} }