Merge branches

This commit is contained in:
Pieter Vander Vennet 2023-07-17 01:43:53 +02:00
commit 7eeac66471
554 changed files with 8193 additions and 7079 deletions

View 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
View 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
}
}

View 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
}
}

View 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
}
}

View 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
}

View 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
View 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
View 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
}
}

View 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
View 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
View 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
View 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
}
}