forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			304 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			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
 | |
|     }
 | |
| }
 |