Accessibility: add (translatable) aria labels, update to translation system, see #1181
This commit is contained in:
parent
825fd03adb
commit
8a7d8a43ce
12 changed files with 130 additions and 72 deletions
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
|
@ -31,6 +31,19 @@
|
|||
|
||||
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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -4,12 +4,13 @@
|
|||
// Translated languages
|
||||
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 { 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,13 +32,14 @@
|
|||
}
|
||||
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" />
|
||||
<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}
|
||||
|
@ -51,14 +53,19 @@
|
|||
<option disabled />
|
||||
{/if}
|
||||
|
||||
{#each availableLanguages as language}
|
||||
{#each availableLanguages.filter(l => l !== "_context") as language}
|
||||
<option value={language} class="font-bold">
|
||||
{native[language] ?? ""}
|
||||
{#if language !== $current}
|
||||
({language_translations[language]?.[$current] + " - " + language ?? language})
|
||||
{#if language_translations[language]?.[$current] !== undefined}
|
||||
({ language_translations[language]?.[$current] + " - " + language ?? language})
|
||||
{:else}
|
||||
({language})
|
||||
{/if}
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
</label>
|
||||
</form>
|
||||
{/if}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
20
src/Utils/ariaLabel.ts
Normal 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() {},
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue