forked from MapComplete/MapComplete
Merge branches
This commit is contained in:
commit
7eeac66471
554 changed files with 8193 additions and 7079 deletions
98
src/UI/Input/Checkboxes.ts
Normal file
98
src/UI/Input/Checkboxes.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { InputElement } from "./InputElement"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { Utils } from "../../Utils"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import InputElementMap from "./InputElementMap"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class CheckBox extends InputElementMap<number[], boolean> {
|
||||
constructor(el: BaseUIElement | string, defaultValue?: boolean) {
|
||||
super(
|
||||
new CheckBoxes([Translations.W(el)]),
|
||||
(x0, x1) => x0 === x1,
|
||||
(t) => t.length > 0,
|
||||
(x) => (x ? [0] : [])
|
||||
)
|
||||
if (defaultValue !== undefined) {
|
||||
this.GetValue().setData(defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of individual checkboxes
|
||||
* The value will contain the indexes of the selected checkboxes
|
||||
*/
|
||||
export default class CheckBoxes extends InputElement<number[]> {
|
||||
private static _nextId = 0
|
||||
private readonly value: UIEventSource<number[]>
|
||||
private readonly _elements: BaseUIElement[]
|
||||
|
||||
constructor(elements: BaseUIElement[], value = new UIEventSource<number[]>([])) {
|
||||
super()
|
||||
this.value = value
|
||||
this._elements = Utils.NoNull(elements)
|
||||
this.SetClass("flex flex-col")
|
||||
}
|
||||
|
||||
IsValid(ts: number[]): boolean {
|
||||
return ts !== undefined
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<number[]> {
|
||||
return this.value
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
const formTag = document.createElement("form")
|
||||
|
||||
const value = this.value
|
||||
const elements = this._elements
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
let inputI = elements[i]
|
||||
const input = document.createElement("input")
|
||||
const id = CheckBoxes._nextId
|
||||
CheckBoxes._nextId++
|
||||
input.id = "checkbox" + id
|
||||
|
||||
input.type = "checkbox"
|
||||
input.classList.add("p-1", "cursor-pointer", "m-3", "pl-3", "mr-0")
|
||||
|
||||
const label = document.createElement("label")
|
||||
label.htmlFor = input.id
|
||||
label.appendChild(input)
|
||||
label.appendChild(inputI.ConstructElement())
|
||||
label.classList.add("block", "w-full", "p-2", "cursor-pointer")
|
||||
|
||||
formTag.appendChild(label)
|
||||
|
||||
value.addCallbackAndRunD((selectedValues) => {
|
||||
input.checked = selectedValues.indexOf(i) >= 0
|
||||
|
||||
if (input.checked) {
|
||||
label.classList.add("checked")
|
||||
} else {
|
||||
label.classList.remove("checked")
|
||||
}
|
||||
})
|
||||
|
||||
input.onchange = () => {
|
||||
// Index = index in the list of already checked items
|
||||
const index = value.data.indexOf(i)
|
||||
if (input.checked && index < 0) {
|
||||
value.data.push(i)
|
||||
value.ping()
|
||||
} else if (index >= 0) {
|
||||
value.data.splice(index, 1)
|
||||
value.ping()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formTag
|
||||
}
|
||||
}
|
104
src/UI/Input/DropDown.ts
Normal file
104
src/UI/Input/DropDown.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { InputElement } from "./InputElement"
|
||||
import Translations from "../i18n/Translations"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class DropDown<T> extends InputElement<T> {
|
||||
private static _nextDropdownId = 0
|
||||
|
||||
private readonly _element: HTMLElement
|
||||
|
||||
private readonly _value: UIEventSource<T>
|
||||
private readonly _values: { value: T; shown: string | BaseUIElement }[]
|
||||
|
||||
/**
|
||||
*
|
||||
* const dropdown = new DropDown<number>("test",[{value: 42, shown: "the answer"}])
|
||||
* dropdown.GetValue().data // => 42
|
||||
*/
|
||||
constructor(
|
||||
label: string | BaseUIElement,
|
||||
values: { value: T; shown: string | BaseUIElement }[],
|
||||
value: UIEventSource<T> = undefined,
|
||||
options?: {
|
||||
select_class?: string
|
||||
}
|
||||
) {
|
||||
super()
|
||||
value = value ?? new UIEventSource<T>(values[0].value)
|
||||
this._value = value
|
||||
this._values = values
|
||||
if (values.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = DropDown._nextDropdownId
|
||||
DropDown._nextDropdownId++
|
||||
|
||||
const el = document.createElement("form")
|
||||
this._element = el
|
||||
el.id = "dropdown" + id
|
||||
|
||||
{
|
||||
const labelEl = Translations.W(label)?.ConstructElement()
|
||||
if (labelEl !== undefined) {
|
||||
const labelHtml = document.createElement("label")
|
||||
labelHtml.appendChild(labelEl)
|
||||
labelHtml.htmlFor = el.id
|
||||
el.appendChild(labelHtml)
|
||||
}
|
||||
}
|
||||
|
||||
options = options ?? {}
|
||||
options.select_class =
|
||||
options.select_class ?? "w-full bg-indigo-100 p-1 rounded hover:bg-indigo-200"
|
||||
|
||||
{
|
||||
const select = document.createElement("select")
|
||||
select.classList.add(...(options.select_class.split(" ") ?? []))
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const option = document.createElement("option")
|
||||
option.value = "" + i
|
||||
option.appendChild(Translations.W(values[i].shown).ConstructElement())
|
||||
select.appendChild(option)
|
||||
}
|
||||
el.appendChild(select)
|
||||
|
||||
select.onchange = () => {
|
||||
const index = select.selectedIndex
|
||||
value.setData(values[index].value)
|
||||
}
|
||||
|
||||
value.addCallbackAndRun((selected) => {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const value = values[i].value
|
||||
if (value === selected) {
|
||||
select.selectedIndex = i
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.onClick(() => {}) // by registering a click, the click event is consumed and doesn't bubble further to other elements, e.g. checkboxes
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<T> {
|
||||
return this._value
|
||||
}
|
||||
|
||||
IsValid(t: T): boolean {
|
||||
for (const value of this._values) {
|
||||
if (value.value === t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
return this._element
|
||||
}
|
||||
}
|
115
src/UI/Input/FileSelectorButton.ts
Normal file
115
src/UI/Input/FileSelectorButton.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import BaseUIElement from "../BaseUIElement"
|
||||
import {InputElement} from "./InputElement"
|
||||
import {UIEventSource} from "../../Logic/UIEventSource"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export default class FileSelectorButton extends InputElement<FileList> {
|
||||
private static _nextid = 0
|
||||
private readonly _value = new UIEventSource<FileList>(undefined)
|
||||
private readonly _label: BaseUIElement
|
||||
private readonly _acceptType: string
|
||||
private readonly allowMultiple: boolean
|
||||
private readonly _labelClasses: string
|
||||
|
||||
constructor(
|
||||
label: BaseUIElement,
|
||||
options?: {
|
||||
acceptType: "image/*" | string
|
||||
allowMultiple: true | boolean
|
||||
labelClasses?: string
|
||||
}
|
||||
) {
|
||||
super()
|
||||
this._label = label
|
||||
this._acceptType = options?.acceptType ?? "image/*"
|
||||
this._labelClasses = options?.labelClasses ?? ""
|
||||
this.SetClass("block cursor-pointer")
|
||||
label.SetClass("cursor-pointer")
|
||||
this.allowMultiple = options?.allowMultiple ?? true
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<FileList> {
|
||||
return this._value
|
||||
}
|
||||
|
||||
IsValid(t: FileList): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
const self = this
|
||||
const el = document.createElement("form")
|
||||
const label = document.createElement("label")
|
||||
label.appendChild(this._label.ConstructElement())
|
||||
label.classList.add(...this._labelClasses.split(" ").filter((t) => t !== ""))
|
||||
el.appendChild(label)
|
||||
|
||||
const actualInputElement = document.createElement("input")
|
||||
actualInputElement.style.cssText = "display:none"
|
||||
actualInputElement.type = "file"
|
||||
actualInputElement.accept = this._acceptType
|
||||
actualInputElement.name = "picField"
|
||||
actualInputElement.multiple = this.allowMultiple
|
||||
actualInputElement.id = "fileselector" + FileSelectorButton._nextid
|
||||
FileSelectorButton._nextid++
|
||||
|
||||
label.htmlFor = actualInputElement.id
|
||||
|
||||
actualInputElement.onchange = () => {
|
||||
if (actualInputElement.files !== null) {
|
||||
self._value.setData(actualInputElement.files)
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener("submit", (e) => {
|
||||
if (actualInputElement.files !== null) {
|
||||
self._value.setData(actualInputElement.files)
|
||||
}
|
||||
actualInputElement.classList.remove("glowing-shadow");
|
||||
|
||||
e.preventDefault()
|
||||
})
|
||||
|
||||
el.appendChild(actualInputElement)
|
||||
|
||||
function setDrawAttention(isOn: boolean){
|
||||
if(isOn){
|
||||
label.classList.add("glowing-shadow")
|
||||
|
||||
}else{
|
||||
label.classList.remove("glowing-shadow")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener("dragover", (event) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
setDrawAttention(true)
|
||||
// Style the drag-and-drop as a "copy file" operation.
|
||||
event.dataTransfer.dropEffect = "copy"
|
||||
|
||||
})
|
||||
|
||||
window.document.addEventListener("dragenter", () =>{
|
||||
setDrawAttention(true)
|
||||
})
|
||||
|
||||
window.document.addEventListener("dragend", () => {
|
||||
setDrawAttention(false)
|
||||
})
|
||||
|
||||
|
||||
el.addEventListener("drop", (event) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
label.classList.remove("glowing-shadow")
|
||||
const fileList = event.dataTransfer.files
|
||||
this._value.setData(fileList)
|
||||
})
|
||||
|
||||
return el
|
||||
}
|
||||
}
|
46
src/UI/Input/FixedInputElement.ts
Normal file
46
src/UI/Input/FixedInputElement.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { InputElement } from "./InputElement"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import Translations from "../i18n/Translations"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class FixedInputElement<T> extends InputElement<T> {
|
||||
private readonly value: UIEventSource<T>
|
||||
private readonly _comparator: (t0: T, t1: T) => boolean
|
||||
|
||||
private readonly _el: HTMLElement
|
||||
|
||||
constructor(
|
||||
rendering: BaseUIElement | string,
|
||||
value: T | UIEventSource<T>,
|
||||
comparator: (t0: T, t1: T) => boolean = undefined
|
||||
) {
|
||||
super()
|
||||
this._comparator = comparator ?? ((t0, t1) => t0 == t1)
|
||||
if (value instanceof UIEventSource) {
|
||||
this.value = value
|
||||
} else {
|
||||
this.value = new UIEventSource<T>(value)
|
||||
}
|
||||
|
||||
this._el = document.createElement("span")
|
||||
const e = Translations.W(rendering)?.ConstructElement()
|
||||
if (e) {
|
||||
this._el.appendChild(e)
|
||||
}
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<T> {
|
||||
return this.value
|
||||
}
|
||||
|
||||
IsValid(t: T): boolean {
|
||||
return this._comparator(t, this.value.data)
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
return this._el
|
||||
}
|
||||
}
|
18
src/UI/Input/InputElement.ts
Normal file
18
src/UI/Input/InputElement.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface ReadonlyInputElement<T> extends BaseUIElement {
|
||||
GetValue(): Store<T>
|
||||
IsValid(t: T): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export abstract class InputElement<T> extends BaseUIElement implements ReadonlyInputElement<any> {
|
||||
abstract GetValue(): UIEventSource<T>
|
||||
abstract IsValid(t: T): boolean
|
||||
}
|
61
src/UI/Input/InputElementMap.ts
Normal file
61
src/UI/Input/InputElementMap.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { InputElement } from "./InputElement"
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export default class InputElementMap<T, X> extends InputElement<X> {
|
||||
private readonly _inputElement: InputElement<T>
|
||||
private isSame: (x0: X, x1: X) => boolean
|
||||
private readonly fromX: (x: X) => T
|
||||
private readonly toX: (t: T) => X
|
||||
private readonly _value: UIEventSource<X>
|
||||
|
||||
constructor(
|
||||
inputElement: InputElement<T>,
|
||||
isSame: (x0: X, x1: X) => boolean,
|
||||
toX: (t: T) => X,
|
||||
fromX: (x: X) => T,
|
||||
extraSources: Store<any>[] = []
|
||||
) {
|
||||
super()
|
||||
this.isSame = isSame
|
||||
this.fromX = fromX
|
||||
this.toX = toX
|
||||
this._inputElement = inputElement
|
||||
const self = this
|
||||
this._value = inputElement.GetValue().sync(
|
||||
(t) => {
|
||||
const newX = toX(t)
|
||||
const currentX = self.GetValue()?.data
|
||||
if (isSame(currentX, newX)) {
|
||||
return currentX
|
||||
}
|
||||
return newX
|
||||
},
|
||||
extraSources,
|
||||
(x) => {
|
||||
return fromX(x)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<X> {
|
||||
return this._value
|
||||
}
|
||||
|
||||
IsValid(x: X): boolean {
|
||||
if (x === undefined) {
|
||||
return false
|
||||
}
|
||||
const t = this.fromX(x)
|
||||
if (t === undefined) {
|
||||
return false
|
||||
}
|
||||
return this._inputElement.IsValid(t)
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
return this._inputElement.ConstructElement()
|
||||
}
|
||||
}
|
1
src/UI/Input/README.md
Normal file
1
src/UI/Input/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
This is the old, deprecated directory. New, SVelte-based items go into `InputElement`
|
161
src/UI/Input/RadioButton.ts
Normal file
161
src/UI/Input/RadioButton.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
import { InputElement } from "./InputElement"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { Utils } from "../../Utils"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class RadioButton<T> extends InputElement<T> {
|
||||
private static _nextId = 0
|
||||
|
||||
private readonly value: UIEventSource<T>
|
||||
private _elements: InputElement<T>[]
|
||||
private _selectFirstAsDefault: boolean
|
||||
private _dontStyle: boolean
|
||||
|
||||
constructor(
|
||||
elements: InputElement<T>[],
|
||||
options?: {
|
||||
selectFirstAsDefault?: true | boolean
|
||||
dontStyle?: boolean
|
||||
value?: UIEventSource<T>
|
||||
}
|
||||
) {
|
||||
super()
|
||||
options = options ?? {}
|
||||
this._selectFirstAsDefault = options.selectFirstAsDefault ?? true
|
||||
this._elements = Utils.NoNull(elements)
|
||||
this.value = options?.value ?? new UIEventSource<T>(undefined)
|
||||
this._dontStyle = options.dontStyle ?? false
|
||||
}
|
||||
|
||||
IsValid(t: T): boolean {
|
||||
for (const inputElement of this._elements) {
|
||||
if (inputElement.IsValid(t)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<T> {
|
||||
return this.value
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
const elements = this._elements
|
||||
const selectFirstAsDefault = this._selectFirstAsDefault
|
||||
|
||||
const selectedElementIndex: UIEventSource<number> = new UIEventSource<number>(null)
|
||||
|
||||
const value = UIEventSource.flatten(
|
||||
selectedElementIndex.map((selectedIndex) => {
|
||||
if (selectedIndex !== undefined && selectedIndex !== null) {
|
||||
return elements[selectedIndex].GetValue()
|
||||
}
|
||||
}),
|
||||
elements.map((e) => e?.GetValue())
|
||||
)
|
||||
value.syncWith(this.value)
|
||||
|
||||
if (selectFirstAsDefault) {
|
||||
value.addCallbackAndRun((selected) => {
|
||||
if (selected === undefined) {
|
||||
for (const element of elements) {
|
||||
const v = element.GetValue().data
|
||||
if (v !== undefined) {
|
||||
value.setData(v)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
// If an element is clicked, the radio button corresponding with it should be selected as well
|
||||
elements[i]?.onClick(() => {
|
||||
selectedElementIndex.setData(i)
|
||||
})
|
||||
|
||||
elements[i].GetValue().addCallback(() => {
|
||||
selectedElementIndex.setData(i)
|
||||
})
|
||||
}
|
||||
|
||||
const groupId = "radiogroup" + RadioButton._nextId
|
||||
RadioButton._nextId++
|
||||
|
||||
const form = document.createElement("form")
|
||||
|
||||
const inputs = []
|
||||
const wrappers: HTMLElement[] = []
|
||||
|
||||
for (let i1 = 0; i1 < elements.length; i1++) {
|
||||
let element = elements[i1]
|
||||
const labelHtml = element.ConstructElement()
|
||||
if (labelHtml === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const input = document.createElement("input")
|
||||
input.id = "radio" + groupId + "-" + i1
|
||||
input.name = groupId
|
||||
input.type = "radio"
|
||||
input.classList.add("cursor-pointer", "p-1", "mr-2")
|
||||
|
||||
if (!this._dontStyle) {
|
||||
input.classList.add("p-1", "ml-2", "pl-2", "pr-0", "m-3", "mr-0")
|
||||
}
|
||||
input.onchange = () => {
|
||||
if (input.checked) {
|
||||
selectedElementIndex.setData(i1)
|
||||
}
|
||||
}
|
||||
|
||||
inputs.push(input)
|
||||
|
||||
const label = document.createElement("label")
|
||||
label.appendChild(labelHtml)
|
||||
label.htmlFor = input.id
|
||||
label.classList.add("flex", "w-full", "cursor-pointer")
|
||||
|
||||
if (!this._dontStyle) {
|
||||
labelHtml.classList.add("p-2")
|
||||
}
|
||||
|
||||
const block = document.createElement("div")
|
||||
block.appendChild(input)
|
||||
block.appendChild(label)
|
||||
block.classList.add("flex", "w-full")
|
||||
if (!this._dontStyle) {
|
||||
block.classList.add("m-1", "border", "border-gray-400")
|
||||
}
|
||||
block.style.borderRadius = "1.5rem"
|
||||
wrappers.push(block)
|
||||
|
||||
form.appendChild(block)
|
||||
}
|
||||
|
||||
value.addCallbackAndRun((selected: T) => {
|
||||
let somethingChecked = false
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
let input = inputs[i]
|
||||
input.checked = !somethingChecked && elements[i].IsValid(selected)
|
||||
somethingChecked = somethingChecked || input.checked
|
||||
|
||||
if (input.checked) {
|
||||
wrappers[i].classList.remove("border-gray-400")
|
||||
wrappers[i].classList.add("border-attention")
|
||||
} else {
|
||||
wrappers[i].classList.add("border-gray-400")
|
||||
wrappers[i].classList.remove("border-attention")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.SetClass("flex flex-col")
|
||||
|
||||
return form
|
||||
}
|
||||
}
|
304
src/UI/Input/SearchableMappingsSelector.ts
Normal file
304
src/UI/Input/SearchableMappingsSelector.ts
Normal file
|
@ -0,0 +1,304 @@
|
|||
import { UIElement } from "../UIElement"
|
||||
import { InputElement } from "./InputElement"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import Translations from "../i18n/Translations"
|
||||
import Locale from "../i18n/Locale"
|
||||
import Combine from "../Base/Combine"
|
||||
import { TextField } from "./TextField"
|
||||
import Svg from "../../Svg"
|
||||
import { VariableUiElement } from "../Base/VariableUIElement"
|
||||
|
||||
/**
|
||||
* A single 'pill' which can hide itself if the search criteria is not met
|
||||
*/
|
||||
class SelfHidingToggle extends UIElement implements InputElement<boolean> {
|
||||
public readonly _selected: UIEventSource<boolean>
|
||||
public readonly isShown: Store<boolean> = new UIEventSource<boolean>(true)
|
||||
public readonly matchesSearchCriteria: Store<boolean>
|
||||
public readonly forceSelected: UIEventSource<boolean>
|
||||
private readonly _shown: BaseUIElement
|
||||
private readonly _squared: boolean
|
||||
|
||||
public constructor(
|
||||
shown: string | BaseUIElement,
|
||||
mainTerm: Record<string, string>,
|
||||
search: Store<string>,
|
||||
options?: {
|
||||
searchTerms?: Record<string, string[]>
|
||||
selected?: UIEventSource<boolean>
|
||||
forceSelected?: UIEventSource<boolean>
|
||||
squared?: boolean
|
||||
/* Hide, if not selected*/
|
||||
hide?: Store<boolean>
|
||||
}
|
||||
) {
|
||||
super()
|
||||
this._shown = Translations.W(shown)
|
||||
this._squared = options?.squared ?? false
|
||||
const searchTerms: Record<string, string[]> = {}
|
||||
for (const lng in options?.searchTerms ?? []) {
|
||||
if (lng === "_context") {
|
||||
continue
|
||||
}
|
||||
searchTerms[lng] = options?.searchTerms[lng]?.map(SelfHidingToggle.clean)
|
||||
}
|
||||
for (const lng in mainTerm) {
|
||||
if (lng === "_context") {
|
||||
continue
|
||||
}
|
||||
const main = SelfHidingToggle.clean(mainTerm[lng])
|
||||
searchTerms[lng] = [main].concat(searchTerms[lng] ?? [])
|
||||
}
|
||||
const selected = (this._selected = options?.selected ?? new UIEventSource<boolean>(false))
|
||||
const forceSelected = (this.forceSelected =
|
||||
options?.forceSelected ?? new UIEventSource<boolean>(false))
|
||||
this.matchesSearchCriteria = search.map((s) => {
|
||||
if (s === undefined || s.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
s = s?.trim()?.toLowerCase()
|
||||
if (searchTerms[Locale.language.data]?.some((t) => t.indexOf(s) >= 0)) {
|
||||
return true
|
||||
}
|
||||
if (searchTerms["*"]?.some((t) => t.indexOf(s) >= 0)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
this.isShown = this.matchesSearchCriteria.map(
|
||||
(matchesSearch) => {
|
||||
if (selected.data && !forceSelected.data) {
|
||||
return true
|
||||
}
|
||||
if (options?.hide?.data) {
|
||||
return false
|
||||
}
|
||||
return matchesSearch
|
||||
},
|
||||
[selected, Locale.language, options?.hide]
|
||||
)
|
||||
|
||||
const self = this
|
||||
this.isShown.addCallbackAndRun((shown) => {
|
||||
if (shown) {
|
||||
self.RemoveClass("hidden")
|
||||
} else {
|
||||
self.SetClass("hidden")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private static clean(s: string): string {
|
||||
return s?.trim()?.toLowerCase()?.replace(/[-]/, "")
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<boolean> {
|
||||
return this._selected
|
||||
}
|
||||
|
||||
IsValid(t: boolean): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected InnerRender(): string | BaseUIElement {
|
||||
let el: BaseUIElement = this._shown
|
||||
const selected = this._selected
|
||||
|
||||
selected.addCallbackAndRun((selected) => {
|
||||
if (selected) {
|
||||
el.SetClass("border-4")
|
||||
el.RemoveClass("border")
|
||||
el.SetStyle("margin: 0")
|
||||
} else {
|
||||
el.SetStyle("margin: 3px")
|
||||
el.SetClass("border")
|
||||
el.RemoveClass("border-4")
|
||||
}
|
||||
})
|
||||
|
||||
const forcedSelection = this.forceSelected
|
||||
el.onClick(() => {
|
||||
if (forcedSelection.data) {
|
||||
selected.setData(true)
|
||||
} else {
|
||||
selected.setData(!selected.data)
|
||||
}
|
||||
})
|
||||
|
||||
if (!this._squared) {
|
||||
el.SetClass("rounded-full")
|
||||
}
|
||||
return el.SetClass("border border-black p-1 px-4")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The searchable mappings selector is a selector which shows various pills from which one (or more) options can be chosen.
|
||||
* A searchfield can be used to filter the values
|
||||
*/
|
||||
export class SearchablePillsSelector<T> extends Combine implements InputElement<T[]> {
|
||||
public readonly someMatchFound: Store<boolean>
|
||||
private readonly selectedElements: UIEventSource<T[]>
|
||||
|
||||
/**
|
||||
*
|
||||
* @param values: the values that can be selected
|
||||
* @param options
|
||||
*/
|
||||
constructor(
|
||||
values: {
|
||||
show: BaseUIElement
|
||||
value: T
|
||||
mainTerm: Record<string, string>
|
||||
searchTerms?: Record<string, string[]>
|
||||
/* If there are more then 200 elements, should this element still be shown? */
|
||||
hasPriority?: Store<boolean>
|
||||
}[],
|
||||
options?: {
|
||||
/*
|
||||
* If one single value can be selected (like a radio button) or if many values can be selected (like checkboxes)
|
||||
*/
|
||||
mode?: "select-one" | "select-many"
|
||||
/**
|
||||
* The values of the selected elements.
|
||||
* Use this to tie input elements together
|
||||
*/
|
||||
selectedElements?: UIEventSource<T[]>
|
||||
/**
|
||||
* The search bar. Use this to seed the search value or to tie to another value
|
||||
*/
|
||||
searchValue?: UIEventSource<string>
|
||||
/**
|
||||
* What is shown if the search yielded no results.
|
||||
* By default: a translated "no search results"
|
||||
*/
|
||||
onNoMatches?: BaseUIElement
|
||||
/**
|
||||
* An element that is shown if no search is entered
|
||||
* Default behaviour is to show all options
|
||||
*/
|
||||
onNoSearchMade?: BaseUIElement
|
||||
/**
|
||||
* Extra element to show if there are many (>200) possible mappings and when non-priority mappings are hidden
|
||||
*
|
||||
*/
|
||||
onManyElements?: BaseUIElement
|
||||
searchAreaClass?: string
|
||||
hideSearchBar?: false | boolean
|
||||
}
|
||||
) {
|
||||
const search = new TextField({ value: options?.searchValue })
|
||||
|
||||
const searchBar = options?.hideSearchBar
|
||||
? undefined
|
||||
: new Combine([
|
||||
Svg.search_svg().SetClass("w-8 normal-background"),
|
||||
search.SetClass("w-full"),
|
||||
]).SetClass("flex items-center border-2 border-black m-2")
|
||||
|
||||
const searchValue = search.GetValue().map((s) => s?.trim()?.toLowerCase())
|
||||
const selectedElements = options?.selectedElements ?? new UIEventSource<T[]>([])
|
||||
const mode = options?.mode ?? "select-one"
|
||||
const onEmpty = options?.onNoMatches ?? Translations.t.general.noMatchingMapping
|
||||
const forceHide = new UIEventSource(false)
|
||||
const mappedValues: {
|
||||
show: SelfHidingToggle
|
||||
mainTerm: Record<string, string>
|
||||
value: T
|
||||
}[] = values.map((v) => {
|
||||
const vIsSelected = new UIEventSource(false)
|
||||
|
||||
selectedElements.addCallbackAndRunD((selectedElements) => {
|
||||
vIsSelected.setData(selectedElements.some((t) => t === v.value))
|
||||
})
|
||||
|
||||
vIsSelected.addCallback((selected) => {
|
||||
if (selected) {
|
||||
if (mode === "select-one") {
|
||||
selectedElements.setData([v.value])
|
||||
} else if (!selectedElements.data.some((t) => t === v.value)) {
|
||||
selectedElements.data.push(v.value)
|
||||
selectedElements.ping()
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < selectedElements.data.length; i++) {
|
||||
const t = selectedElements.data[i]
|
||||
if (t == v.value) {
|
||||
selectedElements.data.splice(i, 1)
|
||||
selectedElements.ping()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const toggle = new SelfHidingToggle(v.show, v.mainTerm, searchValue, {
|
||||
searchTerms: v.searchTerms,
|
||||
selected: vIsSelected,
|
||||
squared: mode === "select-many",
|
||||
hide:
|
||||
v.hasPriority === undefined
|
||||
? forceHide
|
||||
: forceHide.map((fh) => fh && !v.hasPriority?.data, [v.hasPriority]),
|
||||
})
|
||||
|
||||
return {
|
||||
...v,
|
||||
show: toggle,
|
||||
}
|
||||
})
|
||||
|
||||
// The total number of elements that would be displayed based on the search criteria alone
|
||||
let totalShown: Store<number>
|
||||
totalShown = searchValue.map(
|
||||
(_) => mappedValues.filter((mv) => mv.show.matchesSearchCriteria.data).length
|
||||
)
|
||||
const tooMuchElementsCutoff = 40
|
||||
totalShown.addCallbackAndRunD((shown) => forceHide.setData(tooMuchElementsCutoff < shown))
|
||||
|
||||
super([
|
||||
searchBar,
|
||||
new VariableUiElement(
|
||||
Locale.language.map(
|
||||
(lng) => {
|
||||
if (
|
||||
options?.onNoSearchMade !== undefined &&
|
||||
(searchValue.data === undefined || searchValue.data.length === 0)
|
||||
) {
|
||||
return options?.onNoSearchMade
|
||||
}
|
||||
if (totalShown.data == 0) {
|
||||
return onEmpty
|
||||
}
|
||||
|
||||
mappedValues.sort((a, b) => (a.mainTerm[lng] < b.mainTerm[lng] ? -1 : 1))
|
||||
let pills = new Combine(mappedValues.map((e) => e.show))
|
||||
.SetClass("flex flex-wrap w-full content-start")
|
||||
.SetClass(options?.searchAreaClass ?? "")
|
||||
|
||||
if (totalShown.data >= tooMuchElementsCutoff) {
|
||||
pills = new Combine([
|
||||
options?.onManyElements ?? Translations.t.general.useSearch,
|
||||
pills,
|
||||
])
|
||||
}
|
||||
return pills
|
||||
},
|
||||
[totalShown, searchValue]
|
||||
)
|
||||
),
|
||||
])
|
||||
this.selectedElements = selectedElements
|
||||
this.someMatchFound = totalShown.map((t) => t > 0)
|
||||
}
|
||||
|
||||
public GetValue(): UIEventSource<T[]> {
|
||||
return this.selectedElements
|
||||
}
|
||||
|
||||
IsValid(t: T[]): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
62
src/UI/Input/Slider.ts
Normal file
62
src/UI/Input/Slider.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { InputElement } from "./InputElement"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export default class Slider extends InputElement<number> {
|
||||
private readonly _value: UIEventSource<number>
|
||||
private readonly min: number
|
||||
private readonly max: number
|
||||
private readonly step: number
|
||||
private readonly vertical: boolean
|
||||
|
||||
/**
|
||||
* Constructs a slider input element for natural numbers
|
||||
* @param min: the minimum value that is allowed, inclusive
|
||||
* @param max: the max value that is allowed, inclusive
|
||||
* @param options: value: injectable value; step: the step size of the slider
|
||||
*/
|
||||
constructor(
|
||||
min: number,
|
||||
max: number,
|
||||
options?: {
|
||||
value?: UIEventSource<number>
|
||||
step?: 1 | number
|
||||
vertical?: false | boolean
|
||||
}
|
||||
) {
|
||||
super()
|
||||
this.max = max
|
||||
this.min = min
|
||||
this._value = options?.value ?? new UIEventSource<number>(min)
|
||||
this.step = options?.step ?? 1
|
||||
this.vertical = options?.vertical ?? false
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<number> {
|
||||
return this._value
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
const el = document.createElement("input")
|
||||
el.type = "range"
|
||||
el.min = "" + this.min
|
||||
el.max = "" + this.max
|
||||
el.step = "" + this.step
|
||||
const valuestore = this._value
|
||||
el.oninput = () => {
|
||||
valuestore.setData(Number(el.value))
|
||||
}
|
||||
if (this.vertical) {
|
||||
el.classList.add("vertical")
|
||||
el.setAttribute("orient", "vertical") // firefox only workaround...
|
||||
}
|
||||
valuestore.addCallbackAndRunD((v) => (el.value = "" + valuestore.data))
|
||||
return el
|
||||
}
|
||||
|
||||
IsValid(t: number): boolean {
|
||||
return Math.round(t) == t && t >= this.min && t <= this.max
|
||||
}
|
||||
}
|
187
src/UI/Input/TextField.ts
Normal file
187
src/UI/Input/TextField.ts
Normal file
|
@ -0,0 +1,187 @@
|
|||
import { InputElement } from "./InputElement"
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import { Translation } from "../i18n/Translation"
|
||||
import Locale from "../i18n/Locale"
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
interface TextFieldOptions {
|
||||
placeholder?: string | Store<string> | Translation
|
||||
value?: UIEventSource<string>
|
||||
htmlType?: "area" | "text" | "time" | string
|
||||
inputMode?: string
|
||||
label?: BaseUIElement
|
||||
textAreaRows?: number
|
||||
inputStyle?: string
|
||||
isValid?: (s: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class TextField extends InputElement<string> {
|
||||
public readonly enterPressed = new UIEventSource<string>(undefined)
|
||||
private readonly value: UIEventSource<string>
|
||||
private _actualField: HTMLElement
|
||||
private readonly _isValid: (s: string) => boolean
|
||||
private readonly _rawValue: UIEventSource<string>
|
||||
private _isFocused = false
|
||||
private readonly _options: TextFieldOptions
|
||||
|
||||
constructor(options?: TextFieldOptions) {
|
||||
super()
|
||||
this._options = options ?? {}
|
||||
options = options ?? {}
|
||||
this.value = options?.value ?? new UIEventSource<string>(undefined)
|
||||
this._rawValue = new UIEventSource<string>("")
|
||||
this._isValid = options.isValid ?? ((_) => true)
|
||||
}
|
||||
|
||||
private static SetCursorPosition(textfield: HTMLElement, i: number) {
|
||||
if (textfield === undefined || textfield === null) {
|
||||
return
|
||||
}
|
||||
if (i === -1) {
|
||||
// @ts-ignore
|
||||
i = textfield.value.length
|
||||
}
|
||||
textfield.focus()
|
||||
// @ts-ignore
|
||||
textfield.setSelectionRange(i, i)
|
||||
}
|
||||
|
||||
GetValue(): UIEventSource<string> {
|
||||
return this.value
|
||||
}
|
||||
|
||||
IsValid(t: string): boolean {
|
||||
if (t === undefined || t === null) {
|
||||
return false
|
||||
}
|
||||
return this._isValid(t)
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
const options = this._options
|
||||
const self = this
|
||||
let placeholderStore: Store<string>
|
||||
let placeholder: string = ""
|
||||
if (options.placeholder) {
|
||||
if (typeof options.placeholder === "string") {
|
||||
placeholder = options.placeholder
|
||||
placeholderStore = undefined
|
||||
} else {
|
||||
if (
|
||||
options.placeholder instanceof Store &&
|
||||
options.placeholder["data"] !== undefined
|
||||
) {
|
||||
placeholderStore = options.placeholder
|
||||
} else if (
|
||||
options.placeholder instanceof Translation &&
|
||||
options.placeholder["translations"] !== undefined
|
||||
) {
|
||||
placeholderStore = <Store<string>>(
|
||||
Locale.language.map((l) => (<Translation>options.placeholder).textFor(l))
|
||||
)
|
||||
}
|
||||
placeholder = placeholderStore?.data ?? placeholder ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
this.SetClass("form-text-field")
|
||||
let inputEl: HTMLElement
|
||||
if (options.htmlType === "area") {
|
||||
this.SetClass("w-full box-border max-w-full")
|
||||
const el = document.createElement("textarea")
|
||||
el.placeholder = placeholder
|
||||
el.rows = options.textAreaRows
|
||||
el.cols = 50
|
||||
el.style.width = "100%"
|
||||
el.dir = "auto"
|
||||
inputEl = el
|
||||
if (placeholderStore) {
|
||||
placeholderStore.addCallbackAndRunD((placeholder) => (el.placeholder = placeholder))
|
||||
}
|
||||
} else {
|
||||
const el = document.createElement("input")
|
||||
el.type = options.htmlType ?? "text"
|
||||
el.inputMode = options.inputMode
|
||||
el.placeholder = placeholder
|
||||
el.style.cssText = options.inputStyle ?? "width: 100%;"
|
||||
el.dir = "auto"
|
||||
inputEl = el
|
||||
if (placeholderStore) {
|
||||
placeholderStore.addCallbackAndRunD((placeholder) => (el.placeholder = placeholder))
|
||||
}
|
||||
}
|
||||
|
||||
const form = document.createElement("form")
|
||||
form.appendChild(inputEl)
|
||||
form.onsubmit = () => false
|
||||
|
||||
if (options.label) {
|
||||
form.appendChild(options.label.ConstructElement())
|
||||
}
|
||||
|
||||
const field = inputEl
|
||||
|
||||
this.value.addCallbackAndRunD((value) => {
|
||||
// We leave the textfield as is in the case of undefined or null (handled by addCallbackAndRunD) - make sure we do not erase it!
|
||||
field["value"] = value
|
||||
})
|
||||
|
||||
field.oninput = () => {
|
||||
// How much characters are on the right, not including spaces?
|
||||
// @ts-ignore
|
||||
const endDistance = field.value.substring(field.selectionEnd).replace(/ /g, "").length
|
||||
// @ts-ignore
|
||||
let val: string = field.value
|
||||
self._rawValue.setData(val)
|
||||
if (!self.IsValid(val)) {
|
||||
self.value.setData(undefined)
|
||||
} else {
|
||||
self.value.setData(val)
|
||||
}
|
||||
// Setting the value might cause the value to be set again. We keep the distance _to the end_ stable, as phone number formatting might cause the start to change
|
||||
// See https://github.com/pietervdvn/MapComplete/issues/103
|
||||
// We reread the field value - it might have changed!
|
||||
|
||||
// @ts-ignore
|
||||
val = field.value
|
||||
let newCursorPos = val.length - endDistance
|
||||
while (
|
||||
newCursorPos >= 0 &&
|
||||
// We count the number of _actual_ characters (non-space characters) on the right of the new value
|
||||
// This count should become bigger then the end distance
|
||||
val.substr(newCursorPos).replace(/ /g, "").length < endDistance
|
||||
) {
|
||||
newCursorPos--
|
||||
}
|
||||
TextField.SetCursorPosition(field, newCursorPos)
|
||||
}
|
||||
|
||||
field.addEventListener("keyup", function (event) {
|
||||
if (event.key === "Enter") {
|
||||
// @ts-ignore
|
||||
self.enterPressed.setData(field.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (this._isFocused) {
|
||||
field.focus()
|
||||
}
|
||||
|
||||
this._actualField = field
|
||||
return form
|
||||
}
|
||||
|
||||
public focus() {
|
||||
if (this._actualField === undefined) {
|
||||
this._isFocused = true
|
||||
} else {
|
||||
this._actualField.focus()
|
||||
}
|
||||
}
|
||||
}
|
52
src/UI/Input/Toggle.ts
Normal file
52
src/UI/Input/Toggle.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import { VariableUiElement } from "../Base/VariableUIElement"
|
||||
import Lazy from "../Base/Lazy"
|
||||
|
||||
/**
|
||||
* The 'Toggle' is a UIElement showing either one of two elements, depending on the state.
|
||||
* It can be used to implement e.g. checkboxes or collapsible elements
|
||||
*/
|
||||
export default class Toggle extends VariableUiElement {
|
||||
public readonly isEnabled: Store<boolean>
|
||||
|
||||
constructor(
|
||||
showEnabled: string | BaseUIElement,
|
||||
showDisabled: string | BaseUIElement,
|
||||
isEnabled: Store<boolean>
|
||||
) {
|
||||
super(isEnabled?.map((isEnabled) => (isEnabled ? showEnabled : showDisabled)))
|
||||
this.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
public static If(condition: Store<boolean>, constructor: () => BaseUIElement): BaseUIElement {
|
||||
if (constructor === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return new Toggle(new Lazy(constructor), undefined, condition)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `Toggle`, but will swap on click
|
||||
*/
|
||||
export class ClickableToggle extends Toggle {
|
||||
public declare readonly isEnabled: UIEventSource<boolean>
|
||||
|
||||
constructor(
|
||||
showEnabled: string | BaseUIElement,
|
||||
showDisabled: string | BaseUIElement,
|
||||
isEnabled: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||
) {
|
||||
super(showEnabled, showDisabled, isEnabled)
|
||||
this.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
public ToggleOnClick(): ClickableToggle {
|
||||
const self = this
|
||||
this.onClick(() => {
|
||||
self.isEnabled.setData(!self.isEnabled.data)
|
||||
})
|
||||
return this
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue