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 { public readonly _selected: UIEventSource public readonly isShown: Store = new UIEventSource(true) public readonly matchesSearchCriteria: Store public readonly forceSelected: UIEventSource private readonly _shown: BaseUIElement private readonly _squared: boolean public constructor( shown: string | BaseUIElement, mainTerm: Record, search: Store, options?: { searchTerms?: Record selected?: UIEventSource forceSelected?: UIEventSource squared?: boolean /* Hide, if not selected*/ hide?: Store } ) { super() this._shown = Translations.W(shown) this._squared = options?.squared ?? false const searchTerms: Record = {} 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(false)) const forceSelected = (this.forceSelected = options?.forceSelected ?? new UIEventSource(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 { 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 extends Combine implements InputElement { public readonly someMatchFound: Store private readonly selectedElements: UIEventSource /** * * @param values: the values that can be selected * @param options */ constructor( values: { show: BaseUIElement value: T mainTerm: Record searchTerms?: Record /* If there are more then 200 elements, should this element still be shown? */ hasPriority?: Store }[], 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 /** * The search bar. Use this to seed the search value or to tie to another value */ searchValue?: UIEventSource /** * 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([]) 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 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 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 { return this.selectedElements } IsValid(t: T[]): boolean { return true } }