forked from MapComplete/MapComplete
Merge branches
This commit is contained in:
commit
7eeac66471
554 changed files with 8193 additions and 7079 deletions
10
src/UI/InputElement/Helpers/ColorInput.svelte
Normal file
10
src/UI/InputElement/Helpers/ColorInput.svelte
Normal file
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Simple wrapper around the HTML-color field.
|
||||
*/
|
||||
import { UIEventSource } from "../../../Logic/UIEventSource"
|
||||
|
||||
export let value: UIEventSource<undefined | string>
|
||||
</script>
|
||||
|
||||
<input bind:value={$value} type="color" />
|
10
src/UI/InputElement/Helpers/DateInput.svelte
Normal file
10
src/UI/InputElement/Helpers/DateInput.svelte
Normal file
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Simple wrapper around the HTML-date field.
|
||||
*/
|
||||
import { UIEventSource } from "../../../Logic/UIEventSource"
|
||||
|
||||
export let value: UIEventSource<undefined | string>
|
||||
</script>
|
||||
|
||||
<input bind:value={$value} type="date" />
|
72
src/UI/InputElement/Helpers/DirectionInput.svelte
Normal file
72
src/UI/InputElement/Helpers/DirectionInput.svelte
Normal file
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import { UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import type { MapProperties } from "../../../Models/MapProperties"
|
||||
import { Map as MlMap } from "maplibre-gl"
|
||||
import { MapLibreAdaptor } from "../../Map/MapLibreAdaptor"
|
||||
import MaplibreMap from "../../Map/MaplibreMap.svelte"
|
||||
import ToSvelte from "../../Base/ToSvelte.svelte"
|
||||
import Svg from "../../../Svg.js"
|
||||
|
||||
/**
|
||||
* A visualisation to pick a direction on a map background.
|
||||
*/
|
||||
export let value: UIEventSource<undefined | string>
|
||||
export let mapProperties: Partial<MapProperties> & {
|
||||
readonly location: UIEventSource<{ lon: number; lat: number }>
|
||||
}
|
||||
let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
|
||||
let mla = new MapLibreAdaptor(map, mapProperties)
|
||||
mla.allowMoving.setData(false)
|
||||
mla.allowZooming.setData(false)
|
||||
let directionElem: HTMLElement | undefined
|
||||
$: value.addCallbackAndRunD((degrees) => {
|
||||
if (directionElem === undefined) {
|
||||
return
|
||||
}
|
||||
directionElem.style.rotate = degrees + "deg"
|
||||
})
|
||||
|
||||
let mainElem: HTMLElement
|
||||
function onPosChange(x: number, y: number) {
|
||||
const rect = mainElem.getBoundingClientRect()
|
||||
const dx = -(rect.left + rect.right) / 2 + x
|
||||
const dy = (rect.top + rect.bottom) / 2 - y
|
||||
const angle = (180 * Math.atan2(dy, dx)) / Math.PI
|
||||
const angleGeo = Math.floor((450 - angle) % 360)
|
||||
value.setData("" + angleGeo)
|
||||
}
|
||||
|
||||
let isDown = false
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={mainElem}
|
||||
class="relative h-48 w-48 cursor-pointer overflow-hidden"
|
||||
on:click={(e) => onPosChange(e.x, e.y)}
|
||||
on:mousedown={(e) => {
|
||||
isDown = true
|
||||
onPosChange(e.clientX, e.clientY)
|
||||
}}
|
||||
on:mousemove={(e) => {
|
||||
if (isDown) {
|
||||
onPosChange(e.clientX, e.clientY)
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
on:mouseup={() => {
|
||||
isDown = false
|
||||
}}
|
||||
on:touchmove={(e) => {
|
||||
onPosChange(e.touches[0].clientX, e.touches[0].clientY)
|
||||
e.preventDefault()
|
||||
}}
|
||||
on:touchstart={(e) => onPosChange(e.touches[0].clientX, e.touches[0].clientY)}
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full w-full cursor-pointer">
|
||||
<MaplibreMap {map} attribution={false} />
|
||||
</div>
|
||||
|
||||
<div bind:this={directionElem} class="absolute top-0 left-0 h-full w-full">
|
||||
<ToSvelte construct={Svg.direction_stroke_svg} />
|
||||
</div>
|
||||
</div>
|
153
src/UI/InputElement/Helpers/FloorSelector.svelte
Normal file
153
src/UI/InputElement/Helpers/FloorSelector.svelte
Normal file
|
@ -0,0 +1,153 @@
|
|||
<script lang="ts">
|
||||
import { twJoin } from "tailwind-merge"
|
||||
import { Store, Stores, UIEventSource } from "../../../Logic/UIEventSource"
|
||||
|
||||
/**
|
||||
* Given the available floors, shows an elevator to pick a single one
|
||||
*
|
||||
* This is but the input element, the logic of handling the filter is in 'LevelSelector'
|
||||
*/
|
||||
export let floors: Store<string[]>
|
||||
export let value: UIEventSource<string>
|
||||
|
||||
const HEIGHT = 40
|
||||
|
||||
let initialIndex = Math.max(0, floors?.data?.findIndex((f) => f === value?.data) ?? 0)
|
||||
let index: UIEventSource<number> = new UIEventSource<number>(initialIndex)
|
||||
let forceIndex: number | undefined = undefined
|
||||
let top = Math.max(0, initialIndex) * HEIGHT
|
||||
let elevator: HTMLImageElement
|
||||
|
||||
let mouseDown = false
|
||||
|
||||
let container: HTMLElement
|
||||
|
||||
$: {
|
||||
if (top > 0 || forceIndex !== undefined) {
|
||||
index.setData(closestFloorIndex())
|
||||
value.setData(floors.data[forceIndex ?? closestFloorIndex()])
|
||||
}
|
||||
}
|
||||
|
||||
function unclick() {
|
||||
mouseDown = false
|
||||
}
|
||||
|
||||
function click() {
|
||||
mouseDown = true
|
||||
}
|
||||
|
||||
function closestFloorIndex() {
|
||||
return Math.min(floors.data.length - 1, Math.max(0, Math.round(top / HEIGHT)))
|
||||
}
|
||||
|
||||
function onMove(e: { movementY: number }) {
|
||||
if (mouseDown) {
|
||||
forceIndex = undefined
|
||||
const containerY = container.clientTop
|
||||
const containerMax = containerY + (floors.data.length - 1) * HEIGHT
|
||||
top = Math.min(Math.max(0, top + e.movementY), containerMax)
|
||||
}
|
||||
}
|
||||
|
||||
let momentum = 0
|
||||
|
||||
function stabilize() {
|
||||
// Automatically move the elevator to the closes floor
|
||||
if (mouseDown) {
|
||||
return
|
||||
}
|
||||
const target = (forceIndex ?? index.data) * HEIGHT
|
||||
let diff = target - top
|
||||
if (diff > 1) {
|
||||
diff /= 3
|
||||
}
|
||||
const sign = Math.sign(diff)
|
||||
momentum = momentum + sign
|
||||
let diffR = Math.min(Math.abs(momentum), forceIndex !== undefined ? 9 : 3, Math.abs(diff))
|
||||
momentum = Math.sign(momentum) * Math.min(diffR, Math.abs(momentum))
|
||||
top += sign * diffR
|
||||
if (index.data === forceIndex) {
|
||||
forceIndex = undefined
|
||||
}
|
||||
top = Math.max(top, 0)
|
||||
}
|
||||
|
||||
Stores.Chronic(50).addCallback((_) => stabilize())
|
||||
floors.addCallback((floors) => {
|
||||
forceIndex = floors.findIndex((s) => s === value.data)
|
||||
})
|
||||
|
||||
let image: HTMLImageElement
|
||||
$: {
|
||||
if (image) {
|
||||
let lastY = 0
|
||||
image.ontouchstart = (e: TouchEvent) => {
|
||||
mouseDown = true
|
||||
lastY = e.changedTouches[0].clientY
|
||||
}
|
||||
image.ontouchmove = (e) => {
|
||||
const y = e.changedTouches[0].clientY
|
||||
console.log(y)
|
||||
const movementY = y - lastY
|
||||
lastY = y
|
||||
onMove({ movementY })
|
||||
}
|
||||
image.ontouchend = unclick
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="relative"
|
||||
style={`height: calc(${HEIGHT}px * ${$floors.length}); width: 96px`}
|
||||
>
|
||||
<div class="absolute right-0 h-full w-min">
|
||||
{#each $floors as floor, i}
|
||||
<button
|
||||
style={`height: ${HEIGHT}px; width: ${HEIGHT}px`}
|
||||
class={twJoin(
|
||||
"content-box m-0 flex items-center justify-center border-2 border-gray-300",
|
||||
i === (forceIndex ?? $index) && "selected"
|
||||
)}
|
||||
on:click={() => {
|
||||
forceIndex = i
|
||||
}}
|
||||
>
|
||||
{floor}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div style={`width: ${HEIGHT}px`}>
|
||||
<img
|
||||
bind:this={image}
|
||||
class="draggable"
|
||||
draggable="false"
|
||||
on:mousedown={click}
|
||||
src="./assets/svg/elevator.svg"
|
||||
style={`top: ${top}px;`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svelte:window on:mousemove={onMove} on:mouseup={unclick} />
|
||||
|
||||
<style>
|
||||
.draggable {
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
position: absolute;
|
||||
user-drag: none;
|
||||
|
||||
height: 72px;
|
||||
margin-top: -15px;
|
||||
margin-bottom: -15px;
|
||||
margin-left: -18px;
|
||||
-webkit-user-drag: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
</style>
|
96
src/UI/InputElement/Helpers/LocationInput.svelte
Normal file
96
src/UI/InputElement/Helpers/LocationInput.svelte
Normal file
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts">
|
||||
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import type { MapProperties } from "../../../Models/MapProperties"
|
||||
import { Map as MlMap } from "maplibre-gl"
|
||||
import { MapLibreAdaptor } from "../../Map/MapLibreAdaptor"
|
||||
import MaplibreMap from "../../Map/MaplibreMap.svelte"
|
||||
import DragInvitation from "../../Base/DragInvitation.svelte"
|
||||
import { GeoOperations } from "../../../Logic/GeoOperations"
|
||||
import ShowDataLayer from "../../Map/ShowDataLayer"
|
||||
import * as boundsdisplay from "../../../../assets/layers/range/range.json"
|
||||
import StaticFeatureSource from "../../../Logic/FeatureSource/Sources/StaticFeatureSource"
|
||||
import * as turf from "@turf/turf"
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
|
||||
import { createEventDispatcher, onDestroy } from "svelte"
|
||||
|
||||
/**
|
||||
* A visualisation to pick a location on a map background
|
||||
*/
|
||||
export let value: UIEventSource<{ lon: number; lat: number }>
|
||||
export let initialCoordinate: { lon: number; lat: number }
|
||||
initialCoordinate = initialCoordinate ?? value.data
|
||||
export let maxDistanceInMeters: number = undefined
|
||||
export let mapProperties: Partial<MapProperties> & {
|
||||
readonly location: UIEventSource<{ lon: number; lat: number }>
|
||||
} = undefined
|
||||
/**
|
||||
* Called when setup is done, can be used to add more layers to the map
|
||||
*/
|
||||
export let onCreated: (
|
||||
value: Store<{
|
||||
lon: number
|
||||
lat: number
|
||||
}>,
|
||||
map: Store<MlMap>,
|
||||
mapProperties: MapProperties
|
||||
) => void = undefined
|
||||
|
||||
const dispatch = createEventDispatcher<{ click: { lon: number; lat: number } }>()
|
||||
|
||||
export let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
|
||||
let mla = new MapLibreAdaptor(map, mapProperties)
|
||||
mla.lastClickLocation.addCallbackAndRunD((lastClick) => {
|
||||
dispatch("click", lastClick)
|
||||
})
|
||||
mapProperties.location.syncWith(value)
|
||||
if (onCreated) {
|
||||
onCreated(value, map, mla)
|
||||
}
|
||||
|
||||
let rangeIsShown = false
|
||||
if (maxDistanceInMeters) {
|
||||
onDestroy(
|
||||
mla.location.addCallbackD((newLocation) => {
|
||||
const l = [newLocation.lon, newLocation.lat]
|
||||
const c: [number, number] = [initialCoordinate.lon, initialCoordinate.lat]
|
||||
const d = GeoOperations.distanceBetween(l, c)
|
||||
console.log("distance is", d, l, c)
|
||||
if (d <= maxDistanceInMeters) {
|
||||
return
|
||||
}
|
||||
// This is too far away - let's move back
|
||||
const correctLocation = GeoOperations.along(c, l, maxDistanceInMeters - 10)
|
||||
window.setTimeout(() => {
|
||||
mla.location.setData({ lon: correctLocation[0], lat: correctLocation[1] })
|
||||
}, 25)
|
||||
|
||||
if (!rangeIsShown) {
|
||||
new ShowDataLayer(map, {
|
||||
layer: new LayerConfig(boundsdisplay),
|
||||
features: new StaticFeatureSource([
|
||||
turf.circle(c, maxDistanceInMeters, {
|
||||
units: "meters",
|
||||
properties: { range: "yes", id: "0" },
|
||||
}),
|
||||
]),
|
||||
})
|
||||
rangeIsShown = true
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-32 relative h-full cursor-pointer overflow-hidden">
|
||||
<div class="absolute top-0 left-0 h-full w-full cursor-pointer">
|
||||
<MaplibreMap center={{ lng: initialCoordinate.lon, lat: initialCoordinate.lat }} {map} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center p-8 opacity-50"
|
||||
>
|
||||
<img class="h-full max-h-24" src="./assets/svg/move-arrows.svg" />
|
||||
</div>
|
||||
|
||||
<DragInvitation hideSignal={mla.location} />
|
||||
</div>
|
33
src/UI/InputElement/InputHelper.svelte
Normal file
33
src/UI/InputElement/InputHelper.svelte
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Constructs an input helper element for the given type.
|
||||
* Note that all values are stringified
|
||||
*/
|
||||
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import type { ValidatorType } from "./Validators"
|
||||
import InputHelpers from "./InputHelpers"
|
||||
import ToSvelte from "../Base/ToSvelte.svelte"
|
||||
import type { Feature } from "geojson"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import { VariableUiElement } from "../Base/VariableUIElement"
|
||||
|
||||
export let type: ValidatorType
|
||||
export let value: UIEventSource<string>
|
||||
|
||||
export let feature: Feature
|
||||
export let args: (string | number | boolean)[] = undefined
|
||||
|
||||
let properties = { feature, args: args ?? [] }
|
||||
let construct = new UIEventSource<(value, extraProperties) => BaseUIElement>(undefined)
|
||||
$: {
|
||||
construct.setData(InputHelpers.AvailableInputHelpers[type])
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if construct !== undefined}
|
||||
<ToSvelte
|
||||
construct={() =>
|
||||
new VariableUiElement(construct.mapD((construct) => construct(value, properties)))}
|
||||
/>
|
||||
{/if}
|
151
src/UI/InputElement/InputHelpers.ts
Normal file
151
src/UI/InputElement/InputHelpers.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { ValidatorType } from "./Validators"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import SvelteUIElement from "../Base/SvelteUIElement"
|
||||
import DirectionInput from "./Helpers/DirectionInput.svelte"
|
||||
import { MapProperties } from "../../Models/MapProperties"
|
||||
import DateInput from "./Helpers/DateInput.svelte"
|
||||
import ColorInput from "./Helpers/ColorInput.svelte"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import OpeningHoursInput from "../OpeningHours/OpeningHoursInput"
|
||||
import WikidataSearchBox from "../Wikipedia/WikidataSearchBox"
|
||||
import Wikidata from "../../Logic/Web/Wikidata"
|
||||
import { Utils } from "../../Utils"
|
||||
import Locale from "../i18n/Locale"
|
||||
import { Feature } from "geojson"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
|
||||
export interface InputHelperProperties {
|
||||
/**
|
||||
* Extra arguments which might be used by the helper component
|
||||
*/
|
||||
args?: (string | number | boolean)[]
|
||||
|
||||
/**
|
||||
* Used for map-based helpers, such as 'direction'
|
||||
*/
|
||||
mapProperties?: Partial<MapProperties> & {
|
||||
readonly location: UIEventSource<{ lon: number; lat: number }>
|
||||
}
|
||||
/**
|
||||
* The feature that this question is about
|
||||
* Used by the wikidata-input to read properties, which in turn is used to read the name to pre-populate the text field.
|
||||
* Additionally, used for direction input to set the default location if no mapProperties with location are given
|
||||
*/
|
||||
feature?: Feature
|
||||
}
|
||||
|
||||
export default class InputHelpers {
|
||||
public static readonly AvailableInputHelpers: Readonly<
|
||||
Partial<
|
||||
Record<
|
||||
ValidatorType,
|
||||
(
|
||||
value: UIEventSource<string>,
|
||||
extraProperties?: InputHelperProperties
|
||||
) => BaseUIElement
|
||||
>
|
||||
>
|
||||
> = {
|
||||
direction: (value, properties) =>
|
||||
new SvelteUIElement(DirectionInput, {
|
||||
value,
|
||||
mapProperties: InputHelpers.constructMapProperties(properties),
|
||||
}),
|
||||
date: (value) => new SvelteUIElement(DateInput, { value }),
|
||||
color: (value) => new SvelteUIElement(ColorInput, { value }),
|
||||
opening_hours: (value) => new OpeningHoursInput(value),
|
||||
wikidata: InputHelpers.constructWikidataHelper,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Constructs a mapProperties-object for the given properties.
|
||||
* Assumes that the first helper-args contains the desired zoom-level
|
||||
* @param properties
|
||||
* @private
|
||||
*/
|
||||
private static constructMapProperties(
|
||||
properties: InputHelperProperties
|
||||
): Partial<MapProperties> {
|
||||
let location = properties?.mapProperties?.location
|
||||
if (!location) {
|
||||
const [lon, lat] = GeoOperations.centerpointCoordinates(properties.feature)
|
||||
location = new UIEventSource<{ lon: number; lat: number }>({ lon, lat })
|
||||
}
|
||||
let mapProperties: Partial<MapProperties> = properties?.mapProperties ?? { location }
|
||||
if (!mapProperties.location) {
|
||||
mapProperties = { ...mapProperties, location }
|
||||
}
|
||||
let zoom = 17
|
||||
if (properties?.args?.[0] !== undefined) {
|
||||
zoom = Number(properties.args[0])
|
||||
if (isNaN(zoom)) {
|
||||
throw "Invalid zoom level for argument at 'length'-input"
|
||||
}
|
||||
}
|
||||
if (!mapProperties.zoom) {
|
||||
mapProperties = { ...mapProperties, zoom: new UIEventSource<number>(zoom) }
|
||||
}
|
||||
return mapProperties
|
||||
}
|
||||
private static constructWikidataHelper(
|
||||
value: UIEventSource<string>,
|
||||
props: InputHelperProperties
|
||||
) {
|
||||
const inputHelperOptions = props
|
||||
const args = inputHelperOptions.args ?? []
|
||||
const searchKey = <string>args[0] ?? "name"
|
||||
|
||||
const searchFor = <string>(
|
||||
(inputHelperOptions.feature?.properties[searchKey]?.toLowerCase() ?? "")
|
||||
)
|
||||
|
||||
let searchForValue: UIEventSource<string> = new UIEventSource(searchFor)
|
||||
const options: any = args[1]
|
||||
if (searchFor !== undefined && options !== undefined) {
|
||||
const prefixes = <string[] | Record<string, string[]>>options["removePrefixes"] ?? []
|
||||
const postfixes = <string[] | Record<string, string[]>>options["removePostfixes"] ?? []
|
||||
const defaultValueCandidate = Locale.language.map((lg) => {
|
||||
const prefixesUnrwapped: RegExp[] = (
|
||||
Array.isArray(prefixes) ? prefixes : prefixes[lg] ?? []
|
||||
).map((s) => new RegExp("^" + s, "i"))
|
||||
const postfixesUnwrapped: RegExp[] = (
|
||||
Array.isArray(postfixes) ? postfixes : postfixes[lg] ?? []
|
||||
).map((s) => new RegExp(s + "$", "i"))
|
||||
let clipped = searchFor
|
||||
|
||||
for (const postfix of postfixesUnwrapped) {
|
||||
const match = searchFor.match(postfix)
|
||||
if (match !== null) {
|
||||
clipped = searchFor.substring(0, searchFor.length - match[0].length)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of prefixesUnrwapped) {
|
||||
const match = searchFor.match(prefix)
|
||||
if (match !== null) {
|
||||
clipped = searchFor.substring(match[0].length)
|
||||
break
|
||||
}
|
||||
}
|
||||
return clipped
|
||||
})
|
||||
|
||||
defaultValueCandidate.addCallbackAndRun((clipped) => searchForValue.setData(clipped))
|
||||
}
|
||||
|
||||
let instanceOf: number[] = Utils.NoNull(
|
||||
(options?.instanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
|
||||
)
|
||||
let notInstanceOf: number[] = Utils.NoNull(
|
||||
(options?.notInstanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
|
||||
)
|
||||
|
||||
return new WikidataSearchBox({
|
||||
value,
|
||||
searchText: searchForValue,
|
||||
instanceOf,
|
||||
notInstanceOf,
|
||||
})
|
||||
}
|
||||
}
|
116
src/UI/InputElement/ValidatedInput.svelte
Normal file
116
src/UI/InputElement/ValidatedInput.svelte
Normal file
|
@ -0,0 +1,116 @@
|
|||
<script lang="ts">
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import type { ValidatorType } from "./Validators"
|
||||
import Validators from "./Validators"
|
||||
import { ExclamationIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { Translation } from "../i18n/Translation"
|
||||
import { createEventDispatcher, onDestroy } from "svelte"
|
||||
import { Validator } from "./Validator"
|
||||
import { Unit } from "../../Models/Unit"
|
||||
import UnitInput from "../Popup/UnitInput.svelte"
|
||||
|
||||
export let type: ValidatorType
|
||||
export let feedback: UIEventSource<Translation> | undefined = undefined
|
||||
export let getCountry: () => string | undefined
|
||||
export let placeholder: string | Translation | undefined
|
||||
export let unit: Unit = undefined
|
||||
|
||||
export let value: UIEventSource<string>
|
||||
/**
|
||||
* Internal state bound to the input element.
|
||||
*
|
||||
* This is only copied to 'value' when appropriate so that no invalid values leak outside;
|
||||
* Additionally, the unit is added when copying
|
||||
*/
|
||||
let _value = new UIEventSource(value.data ?? "")
|
||||
|
||||
let validator: Validator = Validators.get(type ?? "string")
|
||||
let selectedUnit: UIEventSource<string> = new UIEventSource<string>(undefined)
|
||||
let _placeholder = placeholder ?? validator?.getPlaceholder() ?? type
|
||||
|
||||
function initValueAndDenom() {
|
||||
if (unit && value.data) {
|
||||
const [v, denom] = unit?.findDenomination(value.data, getCountry)
|
||||
if (denom) {
|
||||
_value.setData(v)
|
||||
selectedUnit.setData(denom.canonical)
|
||||
} else {
|
||||
_value.setData(value.data ?? "")
|
||||
}
|
||||
} else {
|
||||
_value.setData(value.data ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
initValueAndDenom()
|
||||
|
||||
$: {
|
||||
// The type changed -> reset some values
|
||||
validator = Validators.get(type ?? "string")
|
||||
_placeholder = placeholder ?? validator?.getPlaceholder() ?? type
|
||||
feedback = feedback?.setData(validator?.getFeedback(_value.data, getCountry))
|
||||
|
||||
initValueAndDenom()
|
||||
}
|
||||
|
||||
function setValues() {
|
||||
// Update the value stores
|
||||
const v = _value.data
|
||||
if (!validator.isValid(v, getCountry) || v === "") {
|
||||
value.setData(undefined)
|
||||
feedback?.setData(validator.getFeedback(v, getCountry))
|
||||
return
|
||||
}
|
||||
|
||||
if (unit && isNaN(Number(v))) {
|
||||
value.setData(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
feedback?.setData(undefined)
|
||||
value.setData(v + (selectedUnit.data ?? ""))
|
||||
}
|
||||
|
||||
onDestroy(_value.addCallbackAndRun((_) => setValues()))
|
||||
onDestroy(selectedUnit.addCallback((_) => setValues()))
|
||||
if (validator === undefined) {
|
||||
throw "Not a valid type for a validator:" + type
|
||||
}
|
||||
|
||||
const isValid = _value.map((v) => validator.isValid(v, getCountry))
|
||||
|
||||
let htmlElem: HTMLInputElement
|
||||
|
||||
let dispatch = createEventDispatcher<{ selected }>()
|
||||
$: {
|
||||
if (htmlElem !== undefined) {
|
||||
htmlElem.onfocus = () => dispatch("selected")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if validator.textArea}
|
||||
<textarea
|
||||
class="w-full"
|
||||
bind:value={$_value}
|
||||
inputmode={validator.inputmode ?? "text"}
|
||||
placeholder={_placeholder}
|
||||
/>
|
||||
{:else}
|
||||
<span class="inline-flex">
|
||||
<input
|
||||
bind:this={htmlElem}
|
||||
bind:value={$_value}
|
||||
class="w-full"
|
||||
inputmode={validator.inputmode ?? "text"}
|
||||
placeholder={_placeholder}
|
||||
/>
|
||||
{#if !$isValid}
|
||||
<ExclamationIcon class="-ml-6 h-6 w-6" />
|
||||
{/if}
|
||||
|
||||
{#if unit !== undefined}
|
||||
<UnitInput {unit} {selectedUnit} textValue={_value} upstreamValue={value} />
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
74
src/UI/InputElement/Validator.ts
Normal file
74
src/UI/InputElement/Validator.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import BaseUIElement from "../BaseUIElement"
|
||||
import { Translation } from "../i18n/Translation"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
||||
/**
|
||||
* A 'TextFieldValidator' contains various methods to check and cleanup an entered value or to give feedback.
|
||||
* They also double as an index of supported types for textfields in MapComplete
|
||||
*/
|
||||
export abstract class Validator {
|
||||
public readonly name: string
|
||||
/*
|
||||
* An explanation for the theme builder.
|
||||
* This can indicate which special input element is used, ...
|
||||
* */
|
||||
public readonly explanation: string
|
||||
/**
|
||||
* What HTML-inputmode to use
|
||||
*/
|
||||
public readonly inputmode?: string
|
||||
public readonly textArea: boolean
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
explanation: string | BaseUIElement,
|
||||
inputmode?: string,
|
||||
textArea?: false | boolean
|
||||
) {
|
||||
this.name = name
|
||||
this.inputmode = inputmode
|
||||
this.textArea = textArea ?? false
|
||||
if (this.name.endsWith("textfield")) {
|
||||
this.name = this.name.substr(0, this.name.length - "TextField".length)
|
||||
}
|
||||
if (this.name.endsWith("textfielddef")) {
|
||||
this.name = this.name.substr(0, this.name.length - "TextFieldDef".length)
|
||||
}
|
||||
if (typeof explanation === "string") {
|
||||
this.explanation = explanation
|
||||
} else {
|
||||
this.explanation = explanation.AsMarkdown()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a piece of feedback. By default, validation.<type> will be used, resulting in a generic 'not a valid <type>'.
|
||||
* However, inheritors might overwrite this to give more specific feedback
|
||||
*
|
||||
* Returns 'undefined' if the element is valid
|
||||
*/
|
||||
public getFeedback(s: string, _?: () => string): Translation | undefined {
|
||||
if (this.isValid(s)) {
|
||||
return undefined
|
||||
}
|
||||
const tr = Translations.t.validation[this.name]
|
||||
if (tr !== undefined) {
|
||||
return tr["feedback"]
|
||||
}
|
||||
}
|
||||
|
||||
public getPlaceholder() {
|
||||
return Translations.t.validation[this.name].description
|
||||
}
|
||||
|
||||
public isValid(_: string, __?: () => string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformats for the human
|
||||
*/
|
||||
public reformat(s: string, _?: () => string): string {
|
||||
return s
|
||||
}
|
||||
}
|
86
src/UI/InputElement/Validators.ts
Normal file
86
src/UI/InputElement/Validators.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { Validator } from "./Validator"
|
||||
import StringValidator from "./Validators/StringValidator"
|
||||
import TextValidator from "./Validators/TextValidator"
|
||||
import DateValidator from "./Validators/DateValidator"
|
||||
import NatValidator from "./Validators/NatValidator"
|
||||
import IntValidator from "./Validators/IntValidator"
|
||||
import LengthValidator from "./Validators/LengthValidator"
|
||||
import DirectionValidator from "./Validators/DirectionValidator"
|
||||
import WikidataValidator from "./Validators/WikidataValidator"
|
||||
import PNatValidator from "./Validators/PNatValidator"
|
||||
import FloatValidator from "./Validators/FloatValidator"
|
||||
import PFloatValidator from "./Validators/PFloatValidator"
|
||||
import EmailValidator from "./Validators/EmailValidator"
|
||||
import UrlValidator from "./Validators/UrlValidator"
|
||||
import PhoneValidator from "./Validators/PhoneValidator"
|
||||
import OpeningHoursValidator from "./Validators/OpeningHoursValidator"
|
||||
import ColorValidator from "./Validators/ColorValidator"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import Combine from "../Base/Combine"
|
||||
import Title from "../Base/Title"
|
||||
|
||||
export type ValidatorType = (typeof Validators.availableTypes)[number]
|
||||
|
||||
export default class Validators {
|
||||
public static readonly availableTypes = [
|
||||
"string",
|
||||
"text",
|
||||
"date",
|
||||
"nat",
|
||||
"int",
|
||||
"distance",
|
||||
"direction",
|
||||
"wikidata",
|
||||
"pnat",
|
||||
"float",
|
||||
"pfloat",
|
||||
"email",
|
||||
"url",
|
||||
"phone",
|
||||
"opening_hours",
|
||||
"color",
|
||||
] as const
|
||||
|
||||
public static readonly AllValidators: ReadonlyArray<Validator> = [
|
||||
new StringValidator(),
|
||||
new TextValidator(),
|
||||
new DateValidator(),
|
||||
new NatValidator(),
|
||||
new IntValidator(),
|
||||
new LengthValidator(),
|
||||
new DirectionValidator(),
|
||||
new WikidataValidator(),
|
||||
new PNatValidator(),
|
||||
new FloatValidator(),
|
||||
new PFloatValidator(),
|
||||
new EmailValidator(),
|
||||
new UrlValidator(),
|
||||
new PhoneValidator(),
|
||||
new OpeningHoursValidator(),
|
||||
new ColorValidator(),
|
||||
]
|
||||
|
||||
private static _byType = Validators._byTypeConstructor()
|
||||
|
||||
private static _byTypeConstructor(): Map<ValidatorType, Validator> {
|
||||
const map = new Map<ValidatorType, Validator>()
|
||||
for (const validator of Validators.AllValidators) {
|
||||
map.set(<ValidatorType>validator.name, validator)
|
||||
}
|
||||
return map
|
||||
}
|
||||
public static HelpText(): BaseUIElement {
|
||||
const explanations: BaseUIElement[] = Validators.AllValidators.map((type) =>
|
||||
new Combine([new Title(type.name, 3), type.explanation]).SetClass("flex flex-col")
|
||||
)
|
||||
return new Combine([
|
||||
new Title("Available types for text fields", 1),
|
||||
"The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them",
|
||||
...explanations,
|
||||
]).SetClass("flex flex-col")
|
||||
}
|
||||
|
||||
static get(type: ValidatorType): Validator {
|
||||
return Validators._byType.get(type)
|
||||
}
|
||||
}
|
7
src/UI/InputElement/Validators/ColorValidator.ts
Normal file
7
src/UI/InputElement/Validators/ColorValidator.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Validator } from "../Validator"
|
||||
|
||||
export default class ColorValidator extends Validator {
|
||||
constructor() {
|
||||
super("color", "Shows a color picker")
|
||||
}
|
||||
}
|
28
src/UI/InputElement/Validators/DateValidator.ts
Normal file
28
src/UI/InputElement/Validators/DateValidator.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Validator } from "../Validator"
|
||||
|
||||
export default class DateValidator extends Validator {
|
||||
constructor() {
|
||||
super("date", "A date with date picker")
|
||||
}
|
||||
|
||||
isValid(str: string): boolean {
|
||||
return !isNaN(new Date(str).getTime())
|
||||
}
|
||||
|
||||
reformat(str: string) {
|
||||
console.log("Reformatting", str)
|
||||
if (!this.isValid(str)) {
|
||||
// The date is invalid - we return the string as is
|
||||
return str
|
||||
}
|
||||
const d = new Date(str)
|
||||
let month = "" + (d.getMonth() + 1)
|
||||
let day = "" + d.getDate()
|
||||
const year = d.getFullYear()
|
||||
|
||||
if (month.length < 2) month = "0" + month
|
||||
if (day.length < 2) day = "0" + day
|
||||
|
||||
return [year, month, day].join("-")
|
||||
}
|
||||
}
|
29
src/UI/InputElement/Validators/DirectionValidator.ts
Normal file
29
src/UI/InputElement/Validators/DirectionValidator.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import IntValidator from "./IntValidator"
|
||||
|
||||
export default class DirectionValidator extends IntValidator {
|
||||
constructor() {
|
||||
super(
|
||||
"direction",
|
||||
[
|
||||
"A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl).",
|
||||
"### Input helper",
|
||||
"This element has an input helper showing a map and 'viewport' indicating the direction. By default, this map is zoomed to zoomlevel 17, but this can be changed with the first argument",
|
||||
].join("\n\n")
|
||||
)
|
||||
}
|
||||
|
||||
isValid(str): boolean {
|
||||
if (str.endsWith("°")) {
|
||||
str = str.substring(0, str.length - 1)
|
||||
}
|
||||
return super.isValid(str)
|
||||
}
|
||||
|
||||
reformat(str): string {
|
||||
if (str.endsWith("°")) {
|
||||
str = str.substring(0, str.length - 1)
|
||||
}
|
||||
const n = Number(str) % 360
|
||||
return "" + n
|
||||
}
|
||||
}
|
40
src/UI/InputElement/Validators/EmailValidator.ts
Normal file
40
src/UI/InputElement/Validators/EmailValidator.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Translation } from "../../i18n/Translation.js"
|
||||
import Translations from "../../i18n/Translations.js"
|
||||
import * as emailValidatorLibrary from "email-validator"
|
||||
import { Validator } from "../Validator"
|
||||
|
||||
export default class EmailValidator extends Validator {
|
||||
constructor() {
|
||||
super("email", "An email adress", "email")
|
||||
}
|
||||
|
||||
isValid = (str) => {
|
||||
if (str === undefined) {
|
||||
return false
|
||||
}
|
||||
str = str.trim()
|
||||
if (str.startsWith("mailto:")) {
|
||||
str = str.substring("mailto:".length)
|
||||
}
|
||||
return emailValidatorLibrary.validate(str)
|
||||
}
|
||||
|
||||
reformat = (str) => {
|
||||
if (str === undefined) {
|
||||
return undefined
|
||||
}
|
||||
str = str.trim()
|
||||
if (str.startsWith("mailto:")) {
|
||||
str = str.substring("mailto:".length)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
getFeedback(s: string): Translation {
|
||||
if (s.indexOf("@") < 0) {
|
||||
return Translations.t.validation.email.noAt
|
||||
}
|
||||
|
||||
return super.getFeedback(s)
|
||||
}
|
||||
}
|
27
src/UI/InputElement/Validators/FloatValidator.ts
Normal file
27
src/UI/InputElement/Validators/FloatValidator.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Validator } from "../Validator"
|
||||
|
||||
export default class FloatValidator extends Validator {
|
||||
inputmode = "decimal"
|
||||
|
||||
constructor(name?: string, explanation?: string) {
|
||||
super(name ?? "float", explanation ?? "A decimal number", "decimal")
|
||||
}
|
||||
|
||||
isValid(str) {
|
||||
return !isNaN(Number(str)) && !str.endsWith(".") && !str.endsWith(",")
|
||||
}
|
||||
|
||||
reformat(str): string {
|
||||
return "" + Number(str)
|
||||
}
|
||||
|
||||
getFeedback(s: string): Translation {
|
||||
if (isNaN(Number(s))) {
|
||||
return Translations.t.validation.nat.notANumber
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
29
src/UI/InputElement/Validators/IntValidator.ts
Normal file
29
src/UI/InputElement/Validators/IntValidator.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Validator } from "../Validator"
|
||||
|
||||
export default class IntValidator extends Validator {
|
||||
constructor(name?: string, explanation?: string) {
|
||||
super(
|
||||
name ?? "int",
|
||||
explanation ?? "A whole number, either positive, negative or zero",
|
||||
"numeric"
|
||||
)
|
||||
}
|
||||
|
||||
isValid(str): boolean {
|
||||
str = "" + str
|
||||
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str))
|
||||
}
|
||||
|
||||
getFeedback(s: string): Translation {
|
||||
const n = Number(s)
|
||||
if (isNaN(n)) {
|
||||
return Translations.t.validation.nat.notANumber
|
||||
}
|
||||
if (Math.floor(n) !== n) {
|
||||
return Translations.t.validation.nat.mustBeWhole
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
16
src/UI/InputElement/Validators/LengthValidator.ts
Normal file
16
src/UI/InputElement/Validators/LengthValidator.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Validator } from "../Validator"
|
||||
|
||||
export default class LengthValidator extends Validator {
|
||||
constructor() {
|
||||
super(
|
||||
"distance",
|
||||
'A geographical distance in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `["21", "map,photo"]',
|
||||
"decimal"
|
||||
)
|
||||
}
|
||||
|
||||
isValid = (str) => {
|
||||
const t = Number(str)
|
||||
return !isNaN(t)
|
||||
}
|
||||
}
|
30
src/UI/InputElement/Validators/NatValidator.ts
Normal file
30
src/UI/InputElement/Validators/NatValidator.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import IntValidator from "./IntValidator"
|
||||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
|
||||
export default class NatValidator extends IntValidator {
|
||||
constructor(name?: string, explanation?: string) {
|
||||
super(name ?? "nat", explanation ?? "A whole, positive number or zero")
|
||||
}
|
||||
|
||||
isValid(str): boolean {
|
||||
if (str === undefined) {
|
||||
return false
|
||||
}
|
||||
str = "" + str
|
||||
|
||||
return str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0
|
||||
}
|
||||
|
||||
getFeedback(s: string): Translation {
|
||||
const spr = super.getFeedback(s)
|
||||
if (spr !== undefined) {
|
||||
return spr
|
||||
}
|
||||
const n = Number(s)
|
||||
if (n < 0) {
|
||||
return Translations.t.validation.nat.mustBePositive
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
54
src/UI/InputElement/Validators/OpeningHoursValidator.ts
Normal file
54
src/UI/InputElement/Validators/OpeningHoursValidator.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import Combine from "../../Base/Combine"
|
||||
import Title from "../../Base/Title"
|
||||
import Table from "../../Base/Table"
|
||||
import { Validator } from "../Validator"
|
||||
|
||||
export default class OpeningHoursValidator extends Validator {
|
||||
constructor() {
|
||||
super(
|
||||
"opening_hours",
|
||||
new Combine([
|
||||
"Has extra elements to easily input when a POI is opened.",
|
||||
new Title("Helper arguments"),
|
||||
new Table(
|
||||
["name", "doc"],
|
||||
[
|
||||
[
|
||||
"options",
|
||||
new Combine([
|
||||
"A JSON-object of type `{ prefix: string, postfix: string }`. ",
|
||||
new Table(
|
||||
["subarg", "doc"],
|
||||
[
|
||||
[
|
||||
"prefix",
|
||||
"Piece of text that will always be added to the front of the generated opening hours. If the OSM-data does not start with this, it will fail to parse.",
|
||||
],
|
||||
[
|
||||
"postfix",
|
||||
"Piece of text that will always be added to the end of the generated opening hours",
|
||||
],
|
||||
]
|
||||
),
|
||||
]),
|
||||
],
|
||||
]
|
||||
),
|
||||
new Title("Example usage"),
|
||||
"To add a conditional (based on time) access restriction:\n\n```\n" +
|
||||
`
|
||||
"freeform": {
|
||||
"key": "access:conditional",
|
||||
"type": "opening_hours",
|
||||
"helperArgs": [
|
||||
{
|
||||
"prefix":"no @ (",
|
||||
"postfix":")"
|
||||
}
|
||||
]
|
||||
}` +
|
||||
"\n```\n\n*Don't forget to pass the prefix and postfix in the rendering as well*: `{opening_hours_table(opening_hours,yes @ &LPARENS, &RPARENS )`",
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
23
src/UI/InputElement/Validators/PFloatValidator.ts
Normal file
23
src/UI/InputElement/Validators/PFloatValidator.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Validator } from "../Validator"
|
||||
|
||||
export default class PFloatValidator extends Validator {
|
||||
constructor() {
|
||||
super("pfloat", "A positive decimal number or zero")
|
||||
}
|
||||
|
||||
isValid = (str) =>
|
||||
!isNaN(Number(str)) && Number(str) >= 0 && !str.endsWith(".") && !str.endsWith(",")
|
||||
|
||||
getFeedback(s: string): Translation {
|
||||
const spr = super.getFeedback(s)
|
||||
if (spr !== undefined) {
|
||||
return spr
|
||||
}
|
||||
if (Number(s) < 0) {
|
||||
return Translations.t.validation.nat.mustBePositive
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
27
src/UI/InputElement/Validators/PNatValidator.ts
Normal file
27
src/UI/InputElement/Validators/PNatValidator.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import NatValidator from "./NatValidator"
|
||||
|
||||
export default class PNatValidator extends NatValidator {
|
||||
constructor() {
|
||||
super("pnat", "A strict positive number")
|
||||
}
|
||||
|
||||
getFeedback(s: string): Translation {
|
||||
const spr = super.getFeedback(s)
|
||||
if (spr !== undefined) {
|
||||
return spr
|
||||
}
|
||||
if (Number(s) === 0) {
|
||||
return Translations.t.validation.pnat.noZero
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
isValid = (str) => {
|
||||
if (!super.isValid(str)) {
|
||||
return false
|
||||
}
|
||||
return Number(str) > 0
|
||||
}
|
||||
}
|
54
src/UI/InputElement/Validators/PhoneValidator.ts
Normal file
54
src/UI/InputElement/Validators/PhoneValidator.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { parsePhoneNumberFromString } from "libphonenumber-js"
|
||||
import { Validator } from "../Validator"
|
||||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
|
||||
export default class PhoneValidator extends Validator {
|
||||
constructor() {
|
||||
super("phone", "A phone number", "tel")
|
||||
}
|
||||
|
||||
getFeedback(s: string, requestCountry?: () => string): Translation {
|
||||
if (this.isValid(s, requestCountry)) {
|
||||
return undefined
|
||||
}
|
||||
const tr = Translations.t.validation.phone
|
||||
const generic = tr.feedback
|
||||
if (requestCountry) {
|
||||
const country = requestCountry()
|
||||
if (country) {
|
||||
return tr.feedbackCountry.Subs({ country })
|
||||
}
|
||||
}
|
||||
|
||||
return generic
|
||||
}
|
||||
|
||||
public isValid(str, country: () => string): boolean {
|
||||
if (str === undefined) {
|
||||
return false
|
||||
}
|
||||
if (str.startsWith("tel:")) {
|
||||
str = str.substring("tel:".length)
|
||||
}
|
||||
let countryCode = undefined
|
||||
if (country !== undefined) {
|
||||
countryCode = country()?.toUpperCase()
|
||||
}
|
||||
return parsePhoneNumberFromString(str, countryCode)?.isValid() ?? false
|
||||
}
|
||||
|
||||
public reformat(str, country: () => string) {
|
||||
if (str.startsWith("tel:")) {
|
||||
str = str.substring("tel:".length)
|
||||
}
|
||||
let countryCode = undefined
|
||||
if (country) {
|
||||
countryCode = country()
|
||||
}
|
||||
return parsePhoneNumberFromString(
|
||||
str,
|
||||
countryCode?.toUpperCase() as any
|
||||
)?.formatInternational()
|
||||
}
|
||||
}
|
7
src/UI/InputElement/Validators/StringValidator.ts
Normal file
7
src/UI/InputElement/Validators/StringValidator.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Validator } from "../Validator"
|
||||
|
||||
export default class StringValidator extends Validator {
|
||||
constructor() {
|
||||
super("string", "A simple piece of text")
|
||||
}
|
||||
}
|
12
src/UI/InputElement/Validators/TextValidator.ts
Normal file
12
src/UI/InputElement/Validators/TextValidator.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { Validator } from "../Validator"
|
||||
|
||||
export default class TextValidator extends Validator {
|
||||
constructor() {
|
||||
super(
|
||||
"text",
|
||||
"A longer piece of text. Uses an textArea instead of a textField",
|
||||
"text",
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
71
src/UI/InputElement/Validators/UrlValidator.ts
Normal file
71
src/UI/InputElement/Validators/UrlValidator.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { Validator } from "../Validator"
|
||||
|
||||
export default class UrlValidator extends Validator {
|
||||
constructor() {
|
||||
super(
|
||||
"url",
|
||||
"The validatedTextField will format URLs to always be valid and have a https://-header (even though the 'https'-part will be hidden from the user. Furthermore, some tracking parameters will be removed",
|
||||
"url"
|
||||
)
|
||||
}
|
||||
reformat(str: string): string {
|
||||
try {
|
||||
let url: URL
|
||||
// str = str.toLowerCase() // URLS are case sensitive. Lowercasing them might break some URLS. See #763
|
||||
if (
|
||||
!str.startsWith("http://") &&
|
||||
!str.startsWith("https://") &&
|
||||
!str.startsWith("http:")
|
||||
) {
|
||||
url = new URL("https://" + str)
|
||||
} else {
|
||||
url = new URL(str)
|
||||
}
|
||||
const blacklistedTrackingParams = [
|
||||
"fbclid", // Oh god, how I hate the fbclid. Let it burn, burn in hell!
|
||||
"gclid",
|
||||
"cmpid",
|
||||
"agid",
|
||||
"utm",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"campaignid",
|
||||
"campaign",
|
||||
"AdGroupId",
|
||||
"AdGroup",
|
||||
"TargetId",
|
||||
"msclkid",
|
||||
]
|
||||
for (const dontLike of blacklistedTrackingParams) {
|
||||
url.searchParams.delete(dontLike.toLowerCase())
|
||||
}
|
||||
let cleaned = url.toString()
|
||||
if (cleaned.endsWith("/") && !str.endsWith("/")) {
|
||||
// Do not add a trailing '/' if it wasn't typed originally
|
||||
cleaned = cleaned.substr(0, cleaned.length - 1)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
isValid(str: string): boolean {
|
||||
try {
|
||||
if (
|
||||
!str.startsWith("http://") &&
|
||||
!str.startsWith("https://") &&
|
||||
!str.startsWith("http:")
|
||||
) {
|
||||
str = "https://" + str
|
||||
}
|
||||
const url = new URL(str)
|
||||
const dotIndex = url.host.indexOf(".")
|
||||
return dotIndex > 0 && url.host[url.host.length - 1] !== "."
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
34
src/UI/InputElement/Validators/WikidataValidator.ts
Normal file
34
src/UI/InputElement/Validators/WikidataValidator.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import Combine from "../../Base/Combine"
|
||||
import Wikidata from "../../../Logic/Web/Wikidata"
|
||||
import WikidataSearchBox from "../../Wikipedia/WikidataSearchBox"
|
||||
import { Validator } from "../Validator"
|
||||
|
||||
export default class WikidataValidator extends Validator {
|
||||
constructor() {
|
||||
super("wikidata", new Combine(["A wikidata identifier, e.g. Q42.", WikidataSearchBox.docs]))
|
||||
}
|
||||
|
||||
public isValid(str): boolean {
|
||||
if (str === undefined) {
|
||||
return false
|
||||
}
|
||||
if (str.length <= 2) {
|
||||
return false
|
||||
}
|
||||
return !str.split(";").some((str) => Wikidata.ExtractKey(str) === undefined)
|
||||
}
|
||||
|
||||
public reformat(str) {
|
||||
if (str === undefined) {
|
||||
return undefined
|
||||
}
|
||||
let out = str
|
||||
.split(";")
|
||||
.map((str) => Wikidata.ExtractKey(str))
|
||||
.join("; ")
|
||||
if (str.endsWith(";")) {
|
||||
out = out + ";"
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue