A11y: screen navigation improvements, see #1181

This commit is contained in:
Pieter Vander Vennet 2023-12-15 01:46:01 +01:00
parent 66369ef0b4
commit af4d9bb2bf
25 changed files with 483 additions and 325 deletions

View file

@ -336,6 +336,7 @@
"searchShort": "Search…", "searchShort": "Search…",
"searching": "Searching…" "searching": "Searching…"
}, },
"searchAnswer": "Search an option…",
"share": "Share", "share": "Share",
"sharescreen": { "sharescreen": {
"copiedToClipboard": "Link copied to clipboard", "copiedToClipboard": "Link copied to clipboard",
@ -382,6 +383,20 @@
"uploadingChanges": "Uploading changes…", "uploadingChanges": "Uploading changes…",
"useSearch": "Use the search above to see presets", "useSearch": "Use the search above to see presets",
"useSearchForMore": "Use the search function to search within {total} more values…", "useSearchForMore": "Use the search function to search within {total} more values…",
"visualFeedback": {
"closestFeaturesAre": "Closest features are:",
"east": "Moving east",
"in": "Zooming in",
"islocked": "View locked to your GPS-location, moving disabled. Press the geolocation button to unlock.",
"locked": "View is now locked to your GPS-location, moving disabled.",
"navigation": "Use arrow keys to move the map, press space to select the closest feature",
"noCloseFeatures": "No features in view",
"north": "Moving north",
"out": "Zooming out",
"south": "Moving south",
"unlocked": "Moving enabled.",
"west": "Moving west"
},
"waitingForGeopermission": "Waiting for your permission to use the geolocation…", "waitingForGeopermission": "Waiting for your permission to use the geolocation…",
"waitingForLocation": "Searching your current location…", "waitingForLocation": "Searching your current location…",
"weekdays": { "weekdays": {
@ -449,6 +464,7 @@
"dontDelete": "Cancel", "dontDelete": "Cancel",
"isDeleted": "Deleted", "isDeleted": "Deleted",
"nearby": { "nearby": {
"close": "Collapse panel with nearby images",
"link": "This picture shows the object", "link": "This picture shows the object",
"noNearbyImages": "No nearby images were found", "noNearbyImages": "No nearby images were found",
"seeNearby": "Browse and link nearby pictures", "seeNearby": "Browse and link nearby pictures",

View file

@ -3400,6 +3400,18 @@
"question": "How wide is the gap between the cycleway and the road?", "question": "How wide is the gap between the cycleway and the road?",
"render": "The buffer besides this cycleway is {cycleway:buffer} m" "render": "The buffer besides this cycleway is {cycleway:buffer} m"
}, },
"incline": {
"mappings": {
"0": {
"then": "There is (probably) no incline here"
},
"1": {
"then": "This road has a slope"
}
},
"question": "Does {title()} have an incline?",
"render": "This road has an slope of {incline}"
},
"is lit?": { "is lit?": {
"mappings": { "mappings": {
"0": { "0": {

View file

@ -1342,6 +1342,10 @@ video {
resize: both; resize: both;
} }
.list-none {
list-style-type: none;
}
.appearance-none { .appearance-none {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
@ -2229,10 +2233,6 @@ body {
font-family: "Helvetica Neue", Arial, sans-serif; font-family: "Helvetica Neue", Arial, sans-serif;
} }
.focusable {
/* Not a 'real' class, but rather an indication to FloatOver and ModalRight to, when they open, grab the focus */
}
svg, svg,
img { img {
box-sizing: content-box; box-sizing: content-box;

View file

@ -267,7 +267,7 @@ export default class UserRelatedState {
/** /**
* Initialize the 'amended preferences'. * Initialize the 'amended preferences'.
* This is inherently a dirty and chaotic method, as it shoves many properties into this EventSourcd * This is inherently a dirty and chaotic method, as it shoves many properties into this EventSource
* */ * */
private initAmendedPrefs( private initAmendedPrefs(
layout?: LayoutConfig, layout?: LayoutConfig,

View file

@ -1,7 +1,11 @@
import { Store, UIEventSource } from "../Logic/UIEventSource" import { Store, UIEventSource } from "../Logic/UIEventSource"
import { BBox } from "../Logic/BBox" import { BBox } from "../Logic/BBox"
import { RasterLayerPolygon } from "./RasterLayers" import { RasterLayerPolygon } from "./RasterLayers"
import { B } from "vitest/dist/types-aac763a5"
export interface KeyNavigationEvent {
date: Date
key: "north" | "east" | "south" | "west" | "in" | "out" | "islocked" | "locked" | "unlocked"
}
export interface MapProperties { export interface MapProperties {
readonly location: UIEventSource<{ lon: number; lat: number }> readonly location: UIEventSource<{ lon: number; lat: number }>
readonly zoom: UIEventSource<number> readonly zoom: UIEventSource<number>
@ -14,7 +18,13 @@ export interface MapProperties {
readonly allowRotating: UIEventSource<true | boolean> readonly allowRotating: UIEventSource<true | boolean>
readonly lastClickLocation: Store<{ lon: number; lat: number }> readonly lastClickLocation: Store<{ lon: number; lat: number }>
readonly allowZooming: UIEventSource<true | boolean> readonly allowZooming: UIEventSource<true | boolean>
readonly lastKeyNavigation: UIEventSource<number>
/**
* Triggered when the user navigated by using the keyboard.
* The callback might return 'true' if it wants to be unregistered
* @param f
*/
onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean): () => void
} }
export interface ExportableMap { export interface ExportableMap {

View file

@ -128,6 +128,11 @@ export default class ThemeViewState implements SpecialVisualizationState {
* All 'level'-tags that are available with the current features * All 'level'-tags that are available with the current features
*/ */
readonly floors: Store<string[]> readonly floors: Store<string[]>
/**
* If true, the user interface will toggle some extra aids for people using screenreaders and keyboard navigation
* Triggered by navigating the map with arrows or by pressing 'space' or 'enter'
*/
public readonly visualFeedback: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly newPointDialog: FilteredLayer private readonly newPointDialog: FilteredLayer
constructor(layout: LayoutConfig) { constructor(layout: LayoutConfig) {
@ -372,6 +377,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
public focusOnMap() { public focusOnMap() {
if (this.map.data) { if (this.map.data) {
this.map.data.getCanvas().focus() this.map.data.getCanvas().focus()
console.log("Focused on map")
return return
} }
this.map.addCallbackAndRunD((map) => { this.map.addCallbackAndRunD((map) => {
@ -437,6 +443,13 @@ export default class ThemeViewState implements SpecialVisualizationState {
* Various small methods that need to be called * Various small methods that need to be called
*/ */
private miscSetup() { private miscSetup() {
this.mapProperties.onKeyNavigationEvent((keyEvent) => {
if (["north", "east", "south", "west"].indexOf(keyEvent.key) >= 0) {
this.visualFeedback.setData(true)
return true // Our job is done, unregister
}
})
this.userRelatedState.markLayoutAsVisited(this.layout) this.userRelatedState.markLayoutAsVisited(this.layout)
this.selectedElement.addCallbackAndRunD((feature) => { this.selectedElement.addCallbackAndRunD((feature) => {
@ -460,7 +473,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
* @private * @private
*/ */
private selectClosestAtCenter(i: number = 0) { private selectClosestAtCenter(i: number = 0) {
this.mapProperties.lastKeyNavigation.setData(Date.now() / 1000) this.visualFeedback.setData(true)
const toSelect = this.closestFeatures.features.data[i] const toSelect = this.closestFeatures.features.data[i]
if (!toSelect) { if (!toSelect) {
return return
@ -495,23 +508,20 @@ export default class ThemeViewState implements SpecialVisualizationState {
} }
) )
this.mapProperties.lastKeyNavigation.addCallbackAndRunD((_) => {
Hotkeys.RegisterHotkey( Hotkeys.RegisterHotkey(
{ {
nomod: " ", nomod: " ",
onUp: true, onUp: true,
}, },
Translations.t.hotkeyDocumentation.selectItem, Translations.t.hotkeyDocumentation.selectItem,
() => this.selectClosestAtCenter(0) () => {
) if (this.selectedElement.data !== undefined) {
Hotkeys.RegisterHotkey( return false
{ }
nomod: "Spacebar", this.selectClosestAtCenter(0)
onUp: true, }
},
Translations.t.hotkeyDocumentation.selectItem,
() => this.selectClosestAtCenter(0)
) )
for (let i = 1; i < 9; i++) { for (let i = 1; i < 9; i++) {
Hotkeys.RegisterHotkey( Hotkeys.RegisterHotkey(
{ {
@ -522,8 +532,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
() => this.selectClosestAtCenter(i - 1) () => this.selectClosestAtCenter(i - 1)
) )
} }
return true // unregister
})
this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => { this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => {
if (!enable) { if (!enable) {

View file

@ -11,12 +11,6 @@
export let extraClasses = "p-4 md:p-6"; export let extraClasses = "p-4 md:p-6";
let mainContent: HTMLElement;
onMount(() => {
requestAnimationFrame(() => {
Utils.focusOnFocusableChild(mainContent);
});
});
</script> </script>
@ -31,7 +25,7 @@
use:trapFocus use:trapFocus
style="z-index: 21" style="z-index: 21"
> >
<div bind:this={mainContent} class="content normal-background" on:click|stopPropagation={() => {}}> <div class="content normal-background" on:click|stopPropagation={() => {}}>
<div class="h-full rounded-xl"> <div class="h-full rounded-xl">
<slot /> <slot />
</div> </div>

View file

@ -22,16 +22,13 @@ export default class Hotkeys {
}[] }[]
>([]) >([])
private static textElementSelected(event: KeyboardEvent): boolean { /**
if (event.ctrlKey || event.altKey) { * Register a hotkey
// This is an event with a modifier-key, lets not ignore it * @param key
return false * @param documentation
} * @param action the function to run. It might return 'false', indicating that it didn't do anything and gives control back to the default flow
if (event.key === "Escape") { * @constructor
return false // Another not-printable character that should not be ignored */
}
return ["input", "textarea"].includes(document?.activeElement?.tagName?.toLowerCase())
}
public static RegisterHotkey( public static RegisterHotkey(
key: ( key: (
| { | {
@ -50,7 +47,7 @@ export default class Hotkeys {
onUp?: boolean onUp?: boolean
}, },
documentation: string | Translation, documentation: string | Translation,
action: () => void action: () => void | false
) { ) {
const type = key["onUp"] ? "keyup" : "keypress" const type = key["onUp"] ? "keyup" : "keypress"
let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"] let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"]
@ -69,9 +66,10 @@ export default class Hotkeys {
if (key["ctrl"] !== undefined) { if (key["ctrl"] !== undefined) {
document.addEventListener("keydown", function (event) { document.addEventListener("keydown", function (event) {
if (event.ctrlKey && event.key === keycode) { if (event.ctrlKey && event.key === keycode) {
action() if (action() !== false) {
event.preventDefault() event.preventDefault()
} }
}
}) })
} else if (key["shift"] !== undefined) { } else if (key["shift"] !== undefined) {
document.addEventListener(type, function (event) { document.addEventListener(type, function (event) {
@ -80,16 +78,18 @@ export default class Hotkeys {
return return
} }
if (event.shiftKey && event.key === keycode) { if (event.shiftKey && event.key === keycode) {
action() if (action() !== false) {
event.preventDefault() event.preventDefault()
} }
}
}) })
} else if (key["alt"] !== undefined) { } else if (key["alt"] !== undefined) {
document.addEventListener(type, function (event) { document.addEventListener(type, function (event) {
if (event.altKey && event.key === keycode) { if (event.altKey && event.key === keycode) {
action() if (action() !== false) {
event.preventDefault() event.preventDefault()
} }
}
}) })
} else if (key["nomod"] !== undefined) { } else if (key["nomod"] !== undefined) {
document.addEventListener(type, function (event) { document.addEventListener(type, function (event) {
@ -98,9 +98,11 @@ export default class Hotkeys {
return return
} }
if (event.key === keycode) { if (event.key === keycode) {
action() const result = action()
if (result !== false) {
event.preventDefault() event.preventDefault()
} }
}
}) })
} }
} }
@ -113,6 +115,9 @@ export default class Hotkeys {
if (keycode.length == 1) { if (keycode.length == 1) {
keycode = keycode.toUpperCase() keycode = keycode.toUpperCase()
} }
if (keycode === " ") {
keycode = "Spacebar"
}
modifiers.push(keycode) modifiers.push(keycode)
return <[string, string | Translation]>[modifiers.join("+"), documentation] return <[string, string | Translation]>[modifiers.join("+"), documentation]
}) })
@ -139,4 +144,15 @@ export default class Hotkeys {
static generateDocumentationDynamic(): BaseUIElement { static generateDocumentationDynamic(): BaseUIElement {
return new VariableUiElement(Hotkeys._docs.map((_) => Hotkeys.generateDocumentation())) return new VariableUiElement(Hotkeys._docs.map((_) => Hotkeys.generateDocumentation()))
} }
private static textElementSelected(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.altKey) {
// This is an event with a modifier-key, lets not ignore it
return false
}
if (event.key === "Escape") {
return false // Another not-printable character that should not be ignored
}
return ["input", "textarea"].includes(document?.activeElement?.tagName?.toLowerCase())
}
} }

View file

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import Loading from "./Loading.svelte" import Loading from "./Loading.svelte"
import type { OsmServiceState } from "../../Logic/Osm/OsmConnection" import type { OsmServiceState } from "../../Logic/Osm/OsmConnection"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Translation } from "../i18n/Translation" import { Translation } from "../i18n/Translation"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import Tr from "./Tr.svelte" import Tr from "./Tr.svelte"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { ImmutableStore, UIEventSource } from "../../Logic/UIEventSource" import { ImmutableStore, UIEventSource } from "../../Logic/UIEventSource"
import Invalid from "../../assets/svg/Invalid.svelte" import Invalid from "../../assets/svg/Invalid.svelte"
@ -35,10 +35,12 @@
<Loading /> <Loading />
</slot> </slot>
{:else if $loadingStatus === "error"} {:else if $loadingStatus === "error"}
<slot name="error">
<div class="alert max-w-64 flex items-center"> <div class="alert max-w-64 flex items-center">
<Invalid class="m-2 h-8 w-8 shrink-0" /> <Invalid class="m-2 h-8 w-8 shrink-0" />
<Tr t={offlineModes[$apiState]} /> <Tr t={offlineModes[$apiState]} />
</div> </div>
</slot>
{:else if $loadingStatus === "logged-in"} {:else if $loadingStatus === "logged-in"}
<slot /> <slot />
{:else if $loadingStatus === "not-attempted"} {:else if $loadingStatus === "not-attempted"}

View file

@ -1,30 +1,28 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from "svelte"; import { createEventDispatcher } from "svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"; import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Utils } from "../../Utils"; import { trapFocus } from "trap-focus-svelte"
import { trapFocus } from 'trap-focus-svelte' import { Utils } from "../../Utils"
/** /**
* The slotted element will be shown on the right side * The slotted element will be shown on the right side
*/ */
const dispatch = createEventDispatcher<{ close }>(); const dispatch = createEventDispatcher<{ close }>()
let mainContent: HTMLElement; let mainContent: HTMLElement
onMount(() => {
window.setTimeout(
() => Utils.focusOnFocusableChild(mainContent), 250
);
});
</script> </script>
<div <div
autofocus
bind:this={mainContent} bind:this={mainContent}
use:trapFocus class="absolute top-0 right-0 h-screen w-full overflow-y-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12 normal-background flex flex-col"
class="absolute top-0 right-0 h-screen w-full overflow-y-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12" role="dialog"
tabindex="-1"
aria-modal="true"
style="max-width: 100vw; max-height: 100vh" style="max-width: 100vw; max-height: 100vh"
use:trapFocus
> >
<div class="normal-background m-0 flex flex-col">
<slot name="close-button"> <slot name="close-button">
<button <button
class="absolute right-10 top-10 h-8 w-8 cursor-pointer" class="absolute right-10 top-10 h-8 w-8 cursor-pointer"
@ -33,6 +31,7 @@
<XCircleIcon /> <XCircleIcon />
</button> </button>
</slot> </slot>
<div role="document" >
<slot /> <slot />
</div> </div>
</div> </div>

View file

@ -30,7 +30,7 @@
} }
</script> </script>
<div class="tabbedgroup flex h-full w-full focusable"> <div class="tabbedgroup flex h-full w-full">
<TabGroup <TabGroup
class="flex h-full w-full flex-col" class="flex h-full w-full flex-col"
defaultIndex={1} defaultIndex={1}

View file

@ -11,6 +11,7 @@
import { GeoIndexedStoreForLayer } from "../../Logic/FeatureSource/Actors/GeoIndexedStore" import { GeoIndexedStoreForLayer } from "../../Logic/FeatureSource/Actors/GeoIndexedStore"
import { createEventDispatcher, onDestroy } from "svelte" import { createEventDispatcher, onDestroy } from "svelte"
import { placeholder } from "../../Utils/placeholder" import { placeholder } from "../../Utils/placeholder"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
export let bounds: UIEventSource<BBox> export let bounds: UIEventSource<BBox>
@ -117,7 +118,5 @@
/> />
{/if} {/if}
</form> </form>
<div class="h-6 w-6 self-end" on:click={performSearch}> <SearchIcon class="h-6 w-6 self-end" aria-hidden="true" on:click={performSearch}/>
<ToSvelte construct={Svg.search_svg} />
</div>
</div> </div>

View file

@ -37,7 +37,7 @@
<Tr t={Translations.t.general.returnToTheMap} /> <Tr t={Translations.t.general.returnToTheMap} />
</button> </button>
{:else} {:else}
<div class="flex h-full w-full flex-col gap-y-2 overflow-y-auto p-1 px-2 focusable" tabindex="-1"> <div class="flex h-full w-full flex-col gap-y-2 overflow-y-auto p-1 px-2" tabindex="-1">
{#each $knownTagRenderings as config (config.id)} {#each $knownTagRenderings as config (config.id)}
<TagRenderingEditable <TagRenderingEditable
{tags} {tags}

View file

@ -1,23 +1,26 @@
<script lang="ts"> <script lang="ts">
import type { Feature } from "geojson" import type { Feature } from "geojson"
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import SelectedElementTitle from "./SelectedElementTitle.svelte"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte" import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
export let feature: Feature export let feature: Feature
export let i: number = undefined
let id = feature.properties.id let id = feature.properties.id
let tags = state.featureProperties.getStore(id) let tags = state.featureProperties.getStore(id)
let layer: LayerConfig = state.layout.getMatchingLayer(tags.data) let layer: LayerConfig = state.layout.getMatchingLayer(tags.data)
function select(){ function select() {
state.selectedElement.setData(undefined) state.selectedElement.setData(undefined)
state.selectedLayer.setData(layer) state.selectedLayer.setData(layer)
state.selectedElement.setData(feature) state.selectedElement.setData(feature)
} }
</script> </script>
<div on:click={() => select()} class="cursor-pointer"> <button class="cursor-pointer small" on:click={() => select()}>
<TagRenderingAnswer config={layer.title} selectedElement={feature} {state} {tags} {layer} /> {#if i !== undefined}
</div> <span class="font-bold">{i + 1}.</span>
{/if}
<TagRenderingAnswer config={layer.title} {layer} selectedElement={feature} {state} {tags} />
</button>

View file

@ -0,0 +1,41 @@
<script lang="ts">
/**
* The visual feedback panel gives visual (and auditive) feedback on the main map view
*/
import Translations from "../i18n/Translations"
import ThemeViewState from "../../Models/ThemeViewState"
import Summary from "./Summary.svelte"
import Tr from "../Base/Tr.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { KeyNavigationEvent } from "../../Models/MapProperties"
export let state: ThemeViewState
let centerFeatures = state.closestFeatures.features
let lastAction: UIEventSource<KeyNavigationEvent> = new UIEventSource<KeyNavigationEvent>(undefined)
state.mapProperties.onKeyNavigationEvent((event) => {
lastAction.setData(event)
})
lastAction.stabilized(750).addCallbackAndRunD(_ => lastAction.setData(undefined))
</script>
<div aria-live="assertive" class=" interactive p-1" role="alert">
{#if $lastAction !== undefined}
<Tr t={Translations.t.general.visualFeedback[$lastAction.key]} />
{:else if $centerFeatures.length === 0}
<Tr t={Translations.t.general.visualFeedback.noCloseFeatures} />
{:else}
<div class="pointer-events-auto">
<Tr t={Translations.t.general.visualFeedback.closestFeaturesAre} />
<ol class="list-none">
{#each $centerFeatures as feat, i (feat.properties.id)}
<li class="flex">
<Summary {state} feature={feat} {i}/>
</li>
{/each}
</ol>
</div>
{/if}
</div>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Store } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature" import type { OsmTags } from "../../Models/OsmFeature"
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch" import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch"
@ -15,7 +15,7 @@
import AttributedImage from "./AttributedImage.svelte" import AttributedImage from "./AttributedImage.svelte"
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte" import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
export let tags: Store<OsmTags> export let tags: UIEventSource<OsmTags>
export let lon: number export let lon: number
export let lat: number export let lat: number
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
@ -32,10 +32,10 @@
url: image.thumbUrl ?? image.pictureUrl, url: image.thumbUrl ?? image.pictureUrl,
provider: AllImageProviders.byName(image.provider), provider: AllImageProviders.byName(image.provider),
date: new Date(image.date), date: new Date(image.date),
id: Object.values(image.osmTags)[0] id: Object.values(image.osmTags)[0],
} }
let distance = Math.round( let distance = Math.round(
GeoOperations.distanceBetween([image.coordinates.lng, image.coordinates.lat], c) GeoOperations.distanceBetween([image.coordinates.lng, image.coordinates.lat], c),
) )
$: { $: {
@ -65,7 +65,7 @@
<div class="flex w-fit shrink-0 flex-col"> <div class="flex w-fit shrink-0 flex-col">
<div on:click={() => state.previewedImage.setData(providedImage)}> <div on:click={() => state.previewedImage.setData(providedImage)}>
<AttributedImage image={providedImage} imgClass="max-h-64 w-auto"/> <AttributedImage image={providedImage} imgClass="max-h-64 w-auto" />
</div> </div>
{#if linkable} {#if linkable}
<label> <label>

View file

@ -10,6 +10,7 @@
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"; import { XCircleIcon } from "@babeard/svelte-heroicons/solid";
import Camera_plus from "../../assets/svg/Camera_plus.svelte"; import Camera_plus from "../../assets/svg/Camera_plus.svelte";
import LoginToggle from "../Base/LoginToggle.svelte"; import LoginToggle from "../Base/LoginToggle.svelte";
import { ariaLabel } from "../../Utils/ariaLabel"
export let tags: Store<OsmTags>; export let tags: Store<OsmTags>;
export let state: SpecialVisualizationState; export let state: SpecialVisualizationState;
@ -26,9 +27,11 @@
<LoginToggle {state}> <LoginToggle {state}>
{#if expanded} {#if expanded}
<NearbyImages {tags} {state} {lon} {lat} {feature} {linkable}> <NearbyImages {tags} {state} {lon} {lat} {feature} {linkable} {layer}>
<button slot="corner" <button slot="corner"
class="h-6 w-6 cursor-pointer no-image-background p-0 border-none" class="h-6 w-6 cursor-pointer no-image-background p-0 border-none"
use:ariaLabel={t.close}
on:click={() => { on:click={() => {
expanded = false expanded = false
}}> }}>

View file

@ -39,7 +39,6 @@
function handleOrientation(event) { function handleOrientation(event) {
console.debug("Got gyro measurement")
gotMeasurement.setData(true) gotMeasurement.setData(true)
// IF the phone is lying flat, then: // IF the phone is lying flat, then:
// alpha is the compass direction (but not absolute) // alpha is the compass direction (but not absolute)

View file

@ -4,11 +4,12 @@ import { Map as MlMap, SourceSpecification } from "maplibre-gl"
import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers" import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { BBox } from "../../Logic/BBox" import { BBox } from "../../Logic/BBox"
import { ExportableMap, MapProperties } from "../../Models/MapProperties" import { ExportableMap, KeyNavigationEvent, MapProperties } from "../../Models/MapProperties"
import SvelteUIElement from "../Base/SvelteUIElement" import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "./MaplibreMap.svelte" import MaplibreMap from "./MaplibreMap.svelte"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties" import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import * as htmltoimage from "html-to-image" import * as htmltoimage from "html-to-image"
import { ALL } from "node:dns"
/** /**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties` * The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -40,12 +41,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
readonly lastClickLocation: Store<undefined | { lon: number; lat: number }> readonly lastClickLocation: Store<undefined | { lon: number; lat: number }>
readonly minzoom: UIEventSource<number> readonly minzoom: UIEventSource<number>
readonly maxzoom: UIEventSource<number> readonly maxzoom: UIEventSource<number>
/** /**
* When was the last navigation by arrow keys? * Functions that are called when one of those actions has happened
* If set, this is a hint to use arrow compatibility * @private
* Number of _seconds_ since epoch
*/ */
readonly lastKeyNavigation: UIEventSource<number> = new UIEventSource<number>(undefined) private _onKeyNavigation: ((event: KeyNavigationEvent) => void | boolean)[] = []
private readonly _maplibreMap: Store<MLMap> private readonly _maplibreMap: Store<MLMap>
/** /**
* Used for internal bookkeeping (to remove a rasterLayer when done loading) * Used for internal bookkeeping (to remove a rasterLayer when done loading)
@ -132,13 +134,32 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
handleClick(e) handleClick(e)
}) })
map.getContainer().addEventListener("keydown", (event) => { map.getContainer().addEventListener("keydown", (event) => {
if ( let locked: "islocked" = undefined
event.key === "ArrowRight" || if (!this.allowMoving.data) {
event.key === "ArrowLeft" || locked = "islocked"
event.key === "ArrowUp" || }
event.key === "ArrowDown" switch (event.key) {
) { case "ArrowUp":
this.lastKeyNavigation.setData(Date.now() / 1000) this.pingKeycodeEvent(locked ?? "north")
break
case "ArrowRight":
this.pingKeycodeEvent(locked ?? "east")
break
case "ArrowDown":
this.pingKeycodeEvent(locked ?? "south")
break
case "ArrowLeft":
this.pingKeycodeEvent(locked ?? "west")
break
case "+":
this.pingKeycodeEvent("in")
break
case "=":
this.pingKeycodeEvent("in")
break
case "-":
this.pingKeycodeEvent("out")
break
} }
}) })
}) })
@ -154,7 +175,10 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}) })
this.zoom.addCallbackAndRunD((z) => self.SetZoom(z)) this.zoom.addCallbackAndRunD((z) => self.SetZoom(z))
this.maxbounds.addCallbackAndRun((bbox) => self.setMaxBounds(bbox)) this.maxbounds.addCallbackAndRun((bbox) => self.setMaxBounds(bbox))
this.allowMoving.addCallbackAndRun((allowMoving) => self.setAllowMoving(allowMoving)) this.allowMoving.addCallbackAndRun((allowMoving) => {
self.setAllowMoving(allowMoving)
self.pingKeycodeEvent(allowMoving ? "unlocked" : "locked")
})
this.allowRotating.addCallbackAndRunD((allowRotating) => this.allowRotating.addCallbackAndRunD((allowRotating) =>
self.setAllowRotating(allowRotating) self.setAllowRotating(allowRotating)
) )
@ -240,6 +264,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}) })
} }
public onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean) {
this._onKeyNavigation.push(f)
return () => {
this._onKeyNavigation.splice(this._onKeyNavigation.indexOf(f), 1)
}
}
public async exportAsPng( public async exportAsPng(
rescaleIcons: number = 1, rescaleIcons: number = 1,
progress: UIEventSource<{ current: number; total: number }> = undefined progress: UIEventSource<{ current: number; total: number }> = undefined
@ -268,6 +299,24 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
return await MapLibreAdaptor.toBlob(drawOn) return await MapLibreAdaptor.toBlob(drawOn)
} }
private pingKeycodeEvent(
key: "north" | "east" | "south" | "west" | "in" | "out" | "islocked" | "locked" | "unlocked"
) {
const event = {
date: new Date(),
key: key,
}
for (let i = 0; i < this._onKeyNavigation.length; i++) {
const f = this._onKeyNavigation[i]
const unregister = f(event)
if (unregister === true) {
this._onKeyNavigation.splice(i, 1)
i--
}
}
}
/** /**
* Exports the background map and lines to PNG. * Exports the background map and lines to PNG.
* Markers are _not_ rendered * Markers are _not_ rendered
@ -373,7 +422,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
for (const label of labels) { for (const label of labels) {
if (isDisplayed(label)) { if (isDisplayed(label)) {
console.log("Exporting label", label)
await this.drawElement(drawOn, <HTMLElement>label, rescaleIcons, pixelRatio) await this.drawElement(drawOn, <HTMLElement>label, rescaleIcons, pixelRatio)
} }
} }
@ -565,16 +613,17 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
map.rotateTo(0, { duration: 0 }) map.rotateTo(0, { duration: 0 })
map.setPitch(0) map.setPitch(0)
map.dragRotate.disable() map.dragRotate.disable()
map.keyboard.disableRotation()
map.touchZoomRotate.disableRotation() map.touchZoomRotate.disableRotation()
} else { } else {
map.dragRotate.enable() map.dragRotate.enable()
map.keyboard.enableRotation()
map.touchZoomRotate.enableRotation() map.touchZoomRotate.enableRotation()
} }
} }
private setAllowMoving(allow: true | boolean | undefined) { private setAllowMoving(allow: true | boolean | undefined) {
const map = this._maplibreMap.data const map = this._maplibreMap.data
console.log("Setting 'allowMoving' to", allow)
if (!map) { if (!map) {
return return
} }

View file

@ -6,6 +6,8 @@
import { get, writable } from "svelte/store" import { get, writable } from "svelte/store"
import { AvailableRasterLayers } from "../../Models/RasterLayers" import { AvailableRasterLayers } from "../../Models/RasterLayers"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { ariaLabel } from "../../Utils/ariaLabel"
import Translations from "../i18n/Translations"
/** /**
* The 'MaplibreMap' maps various event sources onto MapLibre. * The 'MaplibreMap' maps various event sources onto MapLibre.
@ -19,7 +21,6 @@
let container: HTMLElement let container: HTMLElement
export let attribution = false
export let center: { lng: number; lat: number } | Readable<{ lng: number; lat: number }> = export let center: { lng: number; lat: number } | Readable<{ lng: number; lat: number }> =
writable({ lng: 0, lat: 0 }) writable({ lng: 0, lat: 0 })
export let zoom: Readable<number> = writable(1) export let zoom: Readable<number> = writable(1)
@ -49,6 +50,9 @@
}) })
_map.on("load", function() { _map.on("load", function() {
_map.resize() _map.resize()
const canvas = _map.getCanvas()
ariaLabel(canvas, Translations.t.general.visualFeedback.navigation)
canvas.role="application"
}) })
map.set(_map) map.set(_map)
}) })
@ -57,6 +61,8 @@
if (_map) _map.remove() if (_map) _map.remove()
map = null map = null
}) })
</script> </script>
<svelte:head> <svelte:head>

View file

@ -1,41 +1,42 @@
<script lang="ts"> <script lang="ts">
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"; import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"
import { Store, UIEventSource } from "../../../Logic/UIEventSource"; import { Store, UIEventSource } from "../../../Logic/UIEventSource"
import type { Feature } from "geojson"; import type { Feature } from "geojson"
import type { SpecialVisualizationState } from "../../SpecialVisualization"; import type { SpecialVisualizationState } from "../../SpecialVisualization"
import TagRenderingAnswer from "./TagRenderingAnswer.svelte"; import TagRenderingAnswer from "./TagRenderingAnswer.svelte"
import { PencilAltIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"; import { PencilAltIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
import TagRenderingQuestion from "./TagRenderingQuestion.svelte"; import TagRenderingQuestion from "./TagRenderingQuestion.svelte"
import { onDestroy } from "svelte"; import { onDestroy } from "svelte"
import Tr from "../../Base/Tr.svelte"; import Tr from "../../Base/Tr.svelte"
import Translations from "../../i18n/Translations.js"; import Translations from "../../i18n/Translations.js"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { Utils } from "../../../Utils"; import { Utils } from "../../../Utils"
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge"
import { ariaLabel } from "../../../Utils/ariaLabel"
export let config: TagRenderingConfig; export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>; export let tags: UIEventSource<Record<string, string>>
export let selectedElement: Feature | undefined; export let selectedElement: Feature | undefined
export let state: SpecialVisualizationState; export let state: SpecialVisualizationState
export let layer: LayerConfig = undefined; export let layer: LayerConfig = undefined
export let editingEnabled: Store<boolean> | undefined = state?.featureSwitchUserbadge; export let editingEnabled: Store<boolean> | undefined = state?.featureSwitchUserbadge
export let highlightedRendering: UIEventSource<string> = undefined; export let highlightedRendering: UIEventSource<string> = undefined
export let clss; export let clss
/** /**
* Indicates if this tagRendering currently shows the attribute or asks the question to _change_ the property * Indicates if this tagRendering currently shows the attribute or asks the question to _change_ the property
*/ */
export let editMode = !config.IsKnown(tags.data); // || showQuestionIfUnknown; export let editMode = !config.IsKnown(tags.data) // || showQuestionIfUnknown;
if (tags) { if (tags) {
onDestroy( onDestroy(
tags.addCallbackD((tags) => { tags.addCallbackD((tags) => {
editMode = !config.IsKnown(tags); editMode = !config.IsKnown(tags)
}) }),
); )
} }
let htmlElem: HTMLDivElement; let htmlElem: HTMLDivElement
$: { $: {
if (editMode && htmlElem !== undefined && config.IsKnown(tags)) { if (editMode && htmlElem !== undefined && config.IsKnown(tags)) {
// EditMode switched to true yet the answer is already known, so the person wants to make a change // EditMode switched to true yet the answer is already known, so the person wants to make a change
@ -43,43 +44,44 @@
// Some delay is applied to give Svelte the time to render the _question_ // Some delay is applied to give Svelte the time to render the _question_
window.setTimeout(() => { window.setTimeout(() => {
Utils.scrollIntoView(<any>htmlElem); Utils.scrollIntoView(<any>htmlElem)
}, 50); }, 50)
} }
} }
const _htmlElement = new UIEventSource<HTMLElement>(undefined); const _htmlElement = new UIEventSource<HTMLElement>(undefined)
$: _htmlElement.setData(htmlElem); $: _htmlElement.setData(htmlElem)
function setHighlighting() { function setHighlighting() {
if (highlightedRendering === undefined) { if (highlightedRendering === undefined) {
return; return
} }
if (htmlElem === undefined) { if (htmlElem === undefined) {
return; return
} }
const highlighted = highlightedRendering.data; const highlighted = highlightedRendering.data
if (config.id === highlighted) { if (config.id === highlighted) {
htmlElem.classList.add("glowing-shadow"); htmlElem.classList.add("glowing-shadow")
htmlElem.tabIndex = "-1"; htmlElem.tabIndex = -1
console.log("Scrolling to", htmlElem); console.log("Scrolling to", htmlElem)
htmlElem.scrollIntoView({ behavior: "smooth" }); htmlElem.scrollIntoView({ behavior: "smooth" })
Utils.focusOnFocusableChild(htmlElem); Utils.focusOnFocusableChild(htmlElem)
} else { } else {
htmlElem.classList.remove("glowing-shadow"); htmlElem.classList.remove("glowing-shadow")
} }
} }
if (highlightedRendering) { if (highlightedRendering) {
onDestroy(highlightedRendering?.addCallbackAndRun(() => setHighlighting())); onDestroy(highlightedRendering?.addCallbackAndRun(() => setHighlighting()))
onDestroy(_htmlElement.addCallbackAndRun(() => setHighlighting())); onDestroy(_htmlElement.addCallbackAndRun(() => setHighlighting()))
} }
let answerId = "answer-"+Utils.randomString(5)
</script> </script>
<div bind:this={htmlElem} class={twMerge(clss, "tr-"+config.id)}> <div bind:this={htmlElem} class={twMerge(clss, "tr-"+config.id)}>
{#if config.question && (!editingEnabled || $editingEnabled)} {#if config.question && (!editingEnabled || $editingEnabled)}
{#if editMode} {#if editMode}
<TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer}> <TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer} on:saved={() => editMode = false}>
<button <button
slot="cancel" slot="cancel"
class="secondary" class="secondary"
@ -91,6 +93,7 @@
</button> </button>
<button slot="upper-right" <button slot="upper-right"
class="h-8 w-8 cursor-pointer border-none p-0" class="h-8 w-8 cursor-pointer border-none p-0"
use:ariaLabel={Translations.t.general.cancel}
on:click={() => { on:click={() => {
editMode = false editMode = false
}}> }}>
@ -99,12 +102,15 @@
</TagRenderingQuestion> </TagRenderingQuestion>
{:else} {:else}
<div class="low-interaction flex items-center justify-between overflow-hidden rounded px-2"> <div class="low-interaction flex items-center justify-between overflow-hidden rounded px-2">
<div id={answerId}>
<TagRenderingAnswer {config} {tags} {selectedElement} {state} {layer} /> <TagRenderingAnswer {config} {tags} {selectedElement} {state} {layer} />
</div>
<button <button
on:click={() => { on:click={() => {
editMode = true editMode = true
}} }}
class="secondary h-8 w-8 shrink-0 self-start rounded-full p-1" class="secondary h-8 w-8 shrink-0 self-start rounded-full p-1"
aria-labelledby={answerId}
> >
<PencilAltIcon /> <PencilAltIcon />
</button> </button>

View file

@ -1,59 +1,60 @@
<script lang="ts"> <script lang="ts">
import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource"; import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource"
import type { SpecialVisualizationState } from "../../SpecialVisualization"; import type { SpecialVisualizationState } from "../../SpecialVisualization"
import Tr from "../../Base/Tr.svelte"; import Tr from "../../Base/Tr.svelte"
import type { Feature } from "geojson"; import type { Feature } from "geojson"
import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig"; import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig"
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"; import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"
import { TagsFilter } from "../../../Logic/Tags/TagsFilter"; import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
import FreeformInput from "./FreeformInput.svelte"; import FreeformInput from "./FreeformInput.svelte"
import Translations from "../../i18n/Translations.js"; import Translations from "../../i18n/Translations.js"
import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction"; import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction"
import { createEventDispatcher, onDestroy } from "svelte"; import { createEventDispatcher, onDestroy } from "svelte"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import SpecialTranslation from "./SpecialTranslation.svelte"; import SpecialTranslation from "./SpecialTranslation.svelte"
import TagHint from "../TagHint.svelte"; import TagHint from "../TagHint.svelte"
import LoginToggle from "../../Base/LoginToggle.svelte"; import LoginToggle from "../../Base/LoginToggle.svelte"
import SubtleButton from "../../Base/SubtleButton.svelte"; import SubtleButton from "../../Base/SubtleButton.svelte"
import Loading from "../../Base/Loading.svelte"; import Loading from "../../Base/Loading.svelte"
import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte"; import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte"
import { Translation } from "../../i18n/Translation"; import { Translation } from "../../i18n/Translation"
import Constants from "../../../Models/Constants"; import Constants from "../../../Models/Constants"
import { Unit } from "../../../Models/Unit"; import { Unit } from "../../../Models/Unit"
import UserRelatedState from "../../../Logic/State/UserRelatedState"; import UserRelatedState from "../../../Logic/State/UserRelatedState"
import { twJoin } from "tailwind-merge"; import { twJoin } from "tailwind-merge"
import { TagUtils } from "../../../Logic/Tags/TagUtils"; import { TagUtils } from "../../../Logic/Tags/TagUtils"
import Search from "../../../assets/svg/Search.svelte"; import Search from "../../../assets/svg/Search.svelte"
import Login from "../../../assets/svg/Login.svelte"; import Login from "../../../assets/svg/Login.svelte"
import { placeholder } from "../../../Utils/placeholder"
export let config: TagRenderingConfig; export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>; export let tags: UIEventSource<Record<string, string>>
export let selectedElement: Feature; export let selectedElement: Feature
export let state: SpecialVisualizationState; export let state: SpecialVisualizationState
export let layer: LayerConfig | undefined; export let layer: LayerConfig | undefined
export let selectedTags: TagsFilter = undefined; export let selectedTags: TagsFilter = undefined
let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined); let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined)
let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key)); let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key))
// Will be bound if a freeform is available // Will be bound if a freeform is available
let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key]); let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key])
let selectedMapping: number = undefined; let selectedMapping: number = undefined
/** /**
* A list of booleans, used if multiAnswer is set * A list of booleans, used if multiAnswer is set
*/ */
let checkedMappings: boolean[]; let checkedMappings: boolean[]
let mappings: Mapping[] = config?.mappings; let mappings: Mapping[] = config?.mappings
let searchTerm: UIEventSource<string> = new UIEventSource(""); let searchTerm: UIEventSource<string> = new UIEventSource("")
let dispatch = createEventDispatcher<{ let dispatch = createEventDispatcher<{
saved: { saved: {
config: TagRenderingConfig config: TagRenderingConfig
applied: TagsFilter applied: TagsFilter
} }
}>(); }>()
/** /**
* Prepares and fills the checkedMappings * Prepares and fills the checkedMappings
@ -61,12 +62,12 @@
function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) { function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) {
mappings = confg.mappings?.filter((m) => { mappings = confg.mappings?.filter((m) => {
if (typeof m.hideInAnswer === "boolean") { if (typeof m.hideInAnswer === "boolean") {
return !m.hideInAnswer; return !m.hideInAnswer
} }
return !m.hideInAnswer.matchesProperties(tgs); return !m.hideInAnswer.matchesProperties(tgs)
}); })
// We received a new config -> reinit // We received a new config -> reinit
unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key)); unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key))
if ( if (
confg.mappings?.length > 0 && confg.mappings?.length > 0 &&
@ -74,55 +75,55 @@
(checkedMappings === undefined || (checkedMappings === undefined ||
checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0)) checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0))
) { ) {
const seenFreeforms = []; const seenFreeforms = []
// Initial setup of the mappings // Initial setup of the mappings
checkedMappings = [ checkedMappings = [
...confg.mappings.map((mapping) => { ...confg.mappings.map((mapping) => {
if(mapping.hideInAnswer === true){ if (mapping.hideInAnswer === true) {
return false return false
} }
const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs); const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs)
if (matches && confg.freeform) { if (matches && confg.freeform) {
const newProps = TagUtils.changeAsProperties(mapping.if.asChange({})); const newProps = TagUtils.changeAsProperties(mapping.if.asChange({}))
seenFreeforms.push(newProps[confg.freeform.key]); seenFreeforms.push(newProps[confg.freeform.key])
} }
return matches; return matches
}) }),
]; ]
if (tgs !== undefined && confg.freeform) { if (tgs !== undefined && confg.freeform) {
const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? []; const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? []
for (const seenFreeform of seenFreeforms) { for (const seenFreeform of seenFreeforms) {
if (!seenFreeform) { if (!seenFreeform) {
continue; continue
} }
const index = unseenFreeformValues.indexOf(seenFreeform); const index = unseenFreeformValues.indexOf(seenFreeform)
if (index < 0) { if (index < 0) {
continue; continue
} }
unseenFreeformValues.splice(index, 1); unseenFreeformValues.splice(index, 1)
} }
// TODO this has _to much_ values // TODO this has _to much_ values
freeformInput.setData(unseenFreeformValues.join(";")); freeformInput.setData(unseenFreeformValues.join(";"))
checkedMappings.push(unseenFreeformValues.length > 0); checkedMappings.push(unseenFreeformValues.length > 0)
} }
} }
if (confg.freeform?.key) { if (confg.freeform?.key) {
if (!confg.multiAnswer) { if (!confg.multiAnswer) {
// Somehow, setting multi-answer freeform values is broken if this is not set // Somehow, setting multi-answer freeform values is broken if this is not set
freeformInput.setData(tgs[confg.freeform.key]); freeformInput.setData(tgs[confg.freeform.key])
} }
} else { } else {
freeformInput.setData(undefined); freeformInput.setData(undefined)
} }
feedback.setData(undefined); feedback.setData(undefined)
} }
$: { $: {
// Even though 'config' is not declared as a store, Svelte uses it as one to update the component // Even though 'config' is not declared as a store, Svelte uses it as one to update the component
// We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes // We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes
initialize($tags, config); initialize($tags, config)
} }
$: { $: {
@ -131,54 +132,55 @@
$freeformInput, $freeformInput,
selectedMapping, selectedMapping,
checkedMappings, checkedMappings,
tags.data tags.data,
); )
} catch (e) { } catch (e) {
console.error("Could not calculate changeSpecification:", e); console.error("Could not calculate changeSpecification:", e)
selectedTags = undefined; selectedTags = undefined
} }
} }
function onSave() { function onSave() {
if (selectedTags === undefined) { if (selectedTags === undefined) {
return; return
} }
if (layer === undefined || (layer?.source === null && layer.id !== "favourite")) { if (layer === undefined || (layer?.source === null && layer.id !== "favourite")) {
/** /**
* This is a special, priviliged layer. * This is a special, priviliged layer.
* We simply apply the tags onto the records * We simply apply the tags onto the records
*/ */
const kv = selectedTags.asChange(tags.data); const kv = selectedTags.asChange(tags.data)
for (const { k, v } of kv) { for (const { k, v } of kv) {
if (v === undefined || v === "") { if (v === undefined) {
delete tags.data[k]; // Note: we _only_ delete if it is undefined. We _leave_ the empty string and assign it, so that data consumers get correct information
delete tags.data[k]
} else { } else {
tags.data[k] = v tags.data[k] = v
} }
feedback.setData(undefined); feedback.setData(undefined)
} }
tags.ping() tags.ping()
return; return
} }
dispatch("saved", { config, applied: selectedTags }); dispatch("saved", { config, applied: selectedTags })
const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, { const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, {
theme: tags.data["_orig_theme"] ?? state.layout.id, theme: tags.data["_orig_theme"] ?? state.layout.id,
changeType: "answer" changeType: "answer",
}); })
freeformInput.setData(undefined); freeformInput.setData(undefined)
selectedMapping = undefined; selectedMapping = undefined
selectedTags = undefined; selectedTags = undefined
change change
.CreateChangeDescriptions() .CreateChangeDescriptions()
.then((changes) => state.changes.applyChanges(changes)) .then((changes) => state.changes.applyChanges(changes))
.catch(console.error); .catch(console.error)
} }
function onInputKeypress(e: Event) { function onInputKeypress(e: KeyboardEvent) {
if (e.key === "Enter") { if (e.key === "Enter") {
onSave(); onSave()
} }
} }
@ -188,28 +190,28 @@
$freeformInput, $freeformInput,
selectedMapping, selectedMapping,
checkedMappings, checkedMappings,
tags.data tags.data,
); )
} catch (e) { } catch (e) {
console.error("Could not calculate changeSpecification:", e); console.error("Could not calculate changeSpecification:", e)
selectedTags = undefined; selectedTags = undefined
} }
} }
let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false); let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false)
let featureSwitchIsDebugging = let featureSwitchIsDebugging =
state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false); state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false)
let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined); let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined)
let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0; let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0
let question = config.question; let question = config.question
$: question = config.question; $: question = config.question
if (state?.osmConnection) { if (state?.osmConnection) {
onDestroy( onDestroy(
state.osmConnection?.userDetails?.addCallbackAndRun((ud) => { state.osmConnection?.userDetails?.addCallbackAndRun((ud) => {
numberOfCs = ud.csCount; numberOfCs = ud.csCount
}) }),
); )
} }
</script> </script>
@ -219,7 +221,7 @@
style="max-height: 75vh" style="max-height: 75vh"
> >
<div class="sticky top-0 interactive pt-1 flex justify-between" style="z-index: 11"> <div class="sticky top-0 interactive pt-1 flex justify-between" style="z-index: 11">
<span class="font-bold"> <span class="font-bold" aria-live="assertive">
<SpecialTranslation t={question} {tags} {state} {layer} feature={selectedElement} /> <SpecialTranslation t={question} {tags} {state} {layer} feature={selectedElement} />
</span> </span>
<slot name="upper-right" /> <slot name="upper-right" />
@ -238,9 +240,10 @@
{/if} {/if}
{#if config.mappings?.length >= 8} {#if config.mappings?.length >= 8}
<div class="sticky flex w-full"> <div class="sticky flex w-full" aria-hidden="true">
<Search class="h-6 w-6" /> <Search class="h-6 w-6" />
<input type="text" bind:value={$searchTerm} class="w-full" /> <input type="text" bind:value={$searchTerm} class="w-full"
use:placeholder={Translations.t.general.searchAnswer} />
</div> </div>
{/if} {/if}
@ -351,7 +354,7 @@
<Tr t={Translations.t.general.loginToStart} slot="message" /> <Tr t={Translations.t.general.loginToStart} slot="message" />
</SubtleButton> </SubtleButton>
{#if $feedback !== undefined} {#if $feedback !== undefined}
<div class="alert"> <div class="alert" aria-live="assertive" role="alert">
<Tr t={$feedback} /> <Tr t={$feedback} />
</div> </div>
{/if} {/if}

View file

@ -48,7 +48,6 @@
import UploadingImageCounter from "./Image/UploadingImageCounter.svelte" import UploadingImageCounter from "./Image/UploadingImageCounter.svelte"
import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte" import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte"
import Cross from "../assets/svg/Cross.svelte" import Cross from "../assets/svg/Cross.svelte"
import Summary from "./BigComponents/Summary.svelte"
import LanguagePicker from "./InputElement/LanguagePicker.svelte" import LanguagePicker from "./InputElement/LanguagePicker.svelte"
import Mastodon from "../assets/svg/Mastodon.svelte" import Mastodon from "../assets/svg/Mastodon.svelte"
import Bug from "../assets/svg/Bug.svelte" import Bug from "../assets/svg/Bug.svelte"
@ -64,7 +63,7 @@
import Share from "../assets/svg/Share.svelte" import Share from "../assets/svg/Share.svelte"
import Favourites from "./Favourites/Favourites.svelte" import Favourites from "./Favourites/Favourites.svelte"
import ImageOperations from "./Image/ImageOperations.svelte" import ImageOperations from "./Image/ImageOperations.svelte"
import { ariaLabel } from "../Utils/ariaLabel" import VisualFeedbackPanel from "./BigComponents/VisualFeedbackPanel.svelte"
export let state: ThemeViewState export let state: ThemeViewState
let layout = state.layout let layout = state.layout
@ -92,9 +91,7 @@
let currentZoom = state.mapProperties.zoom let currentZoom = state.mapProperties.zoom
let showCrosshair = state.userRelatedState.showCrosshair let showCrosshair = state.userRelatedState.showCrosshair
let arrowKeysWereUsed = state.mapProperties.lastKeyNavigation let arrowKeysWereUsed = state.visualFeedback
let centerFeatures = state.closestFeatures.features
let mapproperties: MapProperties = state.mapProperties let mapproperties: MapProperties = state.mapProperties
let featureSwitches: FeatureSwitchState = state.featureSwitches let featureSwitches: FeatureSwitchState = state.featureSwitches
@ -114,10 +111,10 @@
function forwardEventToMap(e: KeyboardEvent) { function forwardEventToMap(e: KeyboardEvent) {
const mlmap = state.map.data const mlmap = state.map.data
if(!mlmap){ if (!mlmap) {
return return
} }
if(!mlmap.keyboard.isEnabled()){ if (!mlmap.keyboard.isEnabled()) {
return return
} }
const animation = mlmap.keyboard?.keydown(e) const animation = mlmap.keyboard?.keydown(e)
@ -135,9 +132,9 @@
<div class="pointer-events-auto float-right mt-1 px-1 max-[480px]:w-full sm:m-2"> <div class="pointer-events-auto float-right mt-1 px-1 max-[480px]:w-full sm:m-2">
<Geosearch <Geosearch
bounds={state.mapProperties.bounds} bounds={state.mapProperties.bounds}
on:searchCompleted={() => {state.map?.data?.getCanvas()?.focus()}}
perLayer={state.perLayer} perLayer={state.perLayer}
selectedElement={state.selectedElement} selectedElement={state.selectedElement}
on:searchCompleted={() => {state.map?.data?.getCanvas()?.focus()}}
/> />
</div> </div>
</If> </If>
@ -152,9 +149,9 @@
</div> </div>
</MapControlButton> </MapControlButton>
<MapControlButton <MapControlButton
arialabel={Translations.t.general.labels.menu}
on:click={() => state.guistate.menuIsOpened.setData(true)} on:click={() => state.guistate.menuIsOpened.setData(true)}
on:keydown={forwardEventToMap} on:keydown={forwardEventToMap}
arialabel={Translations.t.general.labels.menu}
> >
<MenuIcon class="h-8 w-8 cursor-pointer" /> <MenuIcon class="h-8 w-8 cursor-pointer" />
</MapControlButton> </MapControlButton>
@ -211,8 +208,9 @@
<div class="flex"> <div class="flex">
<!-- bottom left elements --> <!-- bottom left elements -->
<If condition={state.featureSwitches.featureSwitchFilter}> <If condition={state.featureSwitches.featureSwitchFilter}>
<MapControlButton on:click={() => state.guistate.openFilterView()} on:keydown={forwardEventToMap} <MapControlButton arialabel={Translations.t.general.labels.filter}
arialabel={Translations.t.general.labels.filter} on:click={() => state.guistate.openFilterView()}
on:keydown={forwardEventToMap}
> >
<Filter class="h-6 w-6" /> <Filter class="h-6 w-6" />
</MapControlButton> </MapControlButton>
@ -231,17 +229,11 @@
</a> </a>
</div> </div>
</div> </div>
<If condition={state.visualFeedback}>
<VisualFeedbackPanel {state} />
</If>
{#if $arrowKeysWereUsed !== undefined && $centerFeatures?.length > 0}
<div class="pointer-events-auto interactive p-1">
{#each $centerFeatures as feat, i (feat.properties.id)}
<div class="flex">
<b>{i + 1}.</b>
<Summary {state} feature={feat} />
</div>
{/each}
</div>
{/if}
<div class="flex flex-col items-end"> <div class="flex flex-col items-end">
<!-- bottom right elements --> <!-- bottom right elements -->
<If condition={state.floors.map((f) => f.length > 1)}> <If condition={state.floors.map((f) => f.length > 1)}>
@ -253,20 +245,22 @@
/> />
</div> </div>
</If> </If>
<MapControlButton on:click={() => mapproperties.zoom.update((z) => z + 1)} <MapControlButton arialabel={Translations.t.general.labels.zoomIn}
on:click={() => mapproperties.zoom.update((z) => z + 1)}
on:keydown={forwardEventToMap} on:keydown={forwardEventToMap}
arialabel={Translations.t.general.labels.zoomIn}
> >
<Plus class="h-8 w-8" /> <Plus class="h-8 w-8" />
</MapControlButton> </MapControlButton>
<MapControlButton on:click={() => mapproperties.zoom.update((z) => z - 1)} on:keydown={forwardEventToMap} <MapControlButton arialabel={Translations.t.general.labels.zoomOut}
arialabel={Translations.t.general.labels.zoomOut} on:click={() => mapproperties.zoom.update((z) => z - 1)}
on:keydown={forwardEventToMap}
> >
<Min class="h-8 w-8" /> <Min class="h-8 w-8" />
</MapControlButton> </MapControlButton>
<If condition={featureSwitches.featureSwitchGeolocation}> <If condition={featureSwitches.featureSwitchGeolocation}>
<MapControlButton on:keydown={forwardEventToMap} on:click={() => geolocationControl.handleClick()} <MapControlButton arialabel={Translations.t.general.labels.jumpToLocation}
arialabel={Translations.t.general.labels.jumpToLocation} on:click={() => geolocationControl.handleClick()}
on:keydown={forwardEventToMap}
> >
<ToSvelte <ToSvelte
construct={geolocationControl.SetClass("block w-8 h-8")} construct={geolocationControl.SetClass("block w-8 h-8")}
@ -277,14 +271,16 @@
</div> </div>
</div> </div>
<LoginToggle ignoreLoading={true} {state}> <LoginToggle ignoreLoading={true} {state}>
{#if ($showCrosshair === "yes" && $currentZoom >= 17) || $showCrosshair === "always" || $arrowKeysWereUsed !== undefined} {#if ($showCrosshair === "yes" && $currentZoom >= 17) || $showCrosshair === "always" || $arrowKeysWereUsed}
<div <div
class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center justify-center" class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center justify-center"
> >
<Cross class="h-4 w-4" /> <Cross class="h-4 w-4" />
</div> </div>
{/if} {/if}
<svelte:fragment slot="error" /> <!-- Add in an empty container to remove errors -->
</LoginToggle> </LoginToggle>
<If condition={state.previewedImage.map(i => i!==undefined)}> <If condition={state.previewedImage.map(i => i!==undefined)}>
@ -322,7 +318,7 @@
selectedElement.setData(undefined) selectedElement.setData(undefined)
}} }}
> >
<div class="h-full w-full flex focusable"> <div class="h-full w-full flex">
<SelectedElementView {state} layer={$selectedLayer} selectedElement={$selectedElement} /> <SelectedElementView {state} layer={$selectedLayer} selectedElement={$selectedElement} />
</div> </div>
</FloatOver> </FloatOver>
@ -410,7 +406,7 @@
state.guistate.backgroundLayerSelectionIsOpened.setData(false) state.guistate.backgroundLayerSelectionIsOpened.setData(false)
}} }}
> >
<div class="h-full p-2 focusable"> <div class="h-full p-2">
<RasterLayerOverview <RasterLayerOverview
{availableLayers} {availableLayers}
map={state.map} map={state.map}

View file

@ -69,10 +69,6 @@ body {
color: var(--foreground-color); color: var(--foreground-color);
font-family: "Helvetica Neue", Arial, sans-serif; font-family: "Helvetica Neue", Arial, sans-serif;
} }
.focusable {
/* Not a 'real' class, but rather an indication to FloatOver and ModalRight to, when they open, grab the focus */
}
svg, svg,
img { img {

View file

@ -37,7 +37,7 @@
<!-- THEME-SPECIFIC-END--> <!-- THEME-SPECIFIC-END-->
</head> </head>
<body> <body tabindex="-1">
<div class="h-screen" id="maindiv"> <div class="h-screen" id="maindiv">
@ -62,7 +62,7 @@
<div class="flex justify-between items-start w-full"> <div class="flex justify-between items-start w-full">
<!-- IMAGE-START --> <!-- IMAGE-START -->
<img class="p-8 h-32 w-32 self-start" src="./assets/svg/add.svg"> <img aria-hidden="true" class="p-8 h-32 w-32 self-start" src="./assets/svg/add.svg">
<!-- IMAGE-END --> <!-- IMAGE-END -->
<div class="h-min subtle"> <div class="h-min subtle">
Version Version
@ -72,7 +72,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="belowmap" class="absolute top-0 left-0 -z-10">Below</div> <div aria-hidden="true" id="belowmap" class="absolute top-0 left-0 -z-10">Below</div>
<script src="./src/UI/RemoveOtherLanguages.js"></script> <script src="./src/UI/RemoveOtherLanguages.js"></script>
<script async src="./src/InstallServiceWorker.ts" type="module"></script> <script async src="./src/InstallServiceWorker.ts" type="module"></script>
<script defer src="./src/index.ts" type="module"></script> <script defer src="./src/index.ts" type="module"></script>