mapcomplete/UI/Input/SearchableMappingsSelector.ts
2022-11-02 14:44:06 +01:00

304 lines
11 KiB
TypeScript

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