forked from MapComplete/MapComplete
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}",
|
"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",
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
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
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue