UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

368 lines (336 loc) 9.1 kB
import { LitElement, html, css, nothing } from "lit"; import { classMap } from "lit/directives/class-map.js"; import { map } from "lit/directives/map.js"; import { fa, listenForMenuClosure } from "../../utils/widgets"; import { faSvg } from "../styles"; import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons/faCircleExclamation"; import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons/faMagnifyingGlass"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark"; /** * Search Bar Element displays an interactive search widget. * @class Panoramax.components.ui.SearchBar * @element pnx-search-bar * @extends [lit.LitElement](https://lit.dev/docs/api/LitElement/) * @fires Panoramax.components.ui.SearchBar#result * @example * ```html * <pnx-search-bar * id="my-search-bar" * placeholder="Search something..." * .searcher=${mysearchfct} * ._parent=${viewer} * reduceable="false" * reduced="false" * size="xxl" @result=${e => console.log(e.detail)} * > * <!-- Optional icon for display on left-side of search bar --> * </pnx-search-bar> * ``` */ export default class SearchBar extends LitElement { /** @private */ static styles = [ faSvg, css` /* Container */ .sb { display: flex; align-items: center; justify-content: space-between; gap: 5px; border: 1px solid var(--widget-border-div); border-radius: 5px; background-color: var(--widget-bg); color: var(--widget-font); height: 30px; border-radius: 20px; position: relative; padding: 0 0 0 10px; width: fit-content; max-width: 100%; box-sizing: border-box; font-family: var(--font-family); } .sb.sb-xxl { height: 40px; line-height: 40px; } .sb.sb-reduceable { width: 360px; } .sb.sb-reduceable.sb-reduced { width: fit-content; padding: 0; gap: 0; } /* Text field */ .sb input { background: none; border: none !important; outline: none; height: 20px; width: 100%; font-family: var(--font-family); } .sb.sb-xxl input { font-size: 1.1em; } .sb.sb-reduceable.sb-reduced input { display: none; } /* Status icon */ .sb-icon { cursor: pointer; height: 30px; line-height: 30px; width: 30px; min-width: 30px; text-align: center; } .sb.sb-xxl .sb-icon { height: 40px; line-height: 40px; width: 40px; min-width: 40px; } .sb-icon svg { pointer-events: none; width: 14px; height: 14px; } /* Search results */ .sb-results { position: absolute; top: 35px; list-style: none; margin: 0; padding: 0; max-width: calc(100% - 20px); border: 1px solid var(--widget-border-div); border-radius: 10px; background-color: var(--widget-bg); color: var(--widget-font); z-index: 130; font-size: 1.05em; line-height: normal; font-family: var(--font-family); } .sb.sb-xxl .sb-results { top: 45px; } .sb-result, .sb-empty { display: block; padding: 5px 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; border-radius: 0; } .sb-result:hover { background-color: var(--widget-bg-hover); } .sb-result:first-child { border-top-right-radius: 10px; border-top-left-radius: 10px; padding-top: 15px; } .sb-result:last-child { border-bottom-right-radius: 10px; border-bottom-left-radius: 10px; padding-bottom: 15px; } ` ]; /** * Component properties. * @memberof Panoramax.components.ui.SearchBar# * @type {Object} * @property {string} [id] The ID attribute set on component and prefix for input as well * @property {string} [placeholder] Default text to display on empty field * @property {boolean} [reduceable=false] Can the search bar be collapsed (for mobile view) * @property {boolean} [reduced=false] Is the search bar currently collapsed ? * @property {string} [value] The default input value * @property {string} [size=md] The component sizing (md, xxl) * @property {function} [searcher] Search callback, function that takes as parameter the input text value, and resolves on list of results ({title, subtitle} and any other data you'd like to get on validation) * @property {boolean} [no-menu-closure=false] Set to true to ignore menu closure events */ static properties = { id: {type: String}, placeholder: {type: String}, reduceable: {type: Boolean, reflect: true}, reduced: {type: Boolean, reflect: true}, value: {type: String}, size: {type: String}, _icon: {state: true}, _results: {state: true}, searcher: {type: Function}, "no-menu-closure": {type: Boolean}, }; constructor() { super(); // State properties this.reduceable = false; this.reduced = false; this.placeholder = nothing; this.size = "md"; this._icon = "search"; this._results = null; this["no-menu-closure"] = false; // Other properties this._throttler = null; delete this._lastSearch; } /** @private */ connectedCallback() { super.connectedCallback(); if(!this["no-menu-closure"]) { listenForMenuClosure(this, this.reset.bind(this)); } } /** @private */ attributeChangedCallback(name, _old, value) { super.attributeChangedCallback(name, _old, value); if(name == "value" && this._icon == "search") { this._icon = "empty"; } } /** @private */ _onIconClick() { if(["empty", "warn"].includes(this._icon)) { this.reset(); } if(this.reduceable && this._icon == "search") { this.reduced = !this.reduced; } } /** @private */ _onInputChange(e) { this.value = e.target.value; this._throttledSearch(); } /** @private */ _onResultClick(item) { /** * Event for search result clicked * @event Panoramax.components.ui.SearchBar#result * @type {CustomEvent} * @property {object|null} detail The data associated to clicked item (format based on searcher function results), or null on reset */ this.dispatchEvent(new CustomEvent("result", {bubbles: true, composed: true, detail: item})); this._results = null; if(this._throttler) { clearTimeout(this._throttler); this._throttler = null; } if(!this.reduceable && item) { this.value = item?.title; this._icon = "empty"; } else { this.value = ""; this._icon = "search"; if(this.reduceable) { this.reduced = true; } } } /** * Limit search calls to every 500ms * @private */ _throttledSearch() { if(this._throttler) { clearTimeout(this._throttler); delete this._throttler; } this._throttler = setTimeout(this._search.bind(this), 500); } /** * Perform real search * @private */ _search() { if(!this.value || this.value.length == 0) { this.reset(); return; } if(!this.searcher) { console.warn("No search handler defined"); return; } this._icon = "loading"; this._results = null; this.searcher(this.value).then(data => { if(this._icon !== "loading") { return; } this._icon = "empty"; if(!data || data.length == 0) { this._results = []; } else if(data === true) { this._results = null; } else { this._results = data; if(!this["no-menu-closure"]) { this._parent?.dispatchEvent(new CustomEvent("menu-opened", { detail: { menu: this }})); } } }).catch(e => { console.error(e); this._icon = "warn"; }); } /** * Empty results list and reset search bar content. */ reset() { this._onResultClick(null); } /** @private */ render() { const classes = { sb: true, "sb-xxl": this.size === "xxl", "sb-reduceable": this.reduceable, "sb-reduced": this.reduced, }; return html`<div id=${this.id} class=${classMap(classes)} part="container" > <slot name="pre" class=${classMap({"sb-reduced": this.reduced})}></slot> <input id="${this.id}-input" type="text" placeholder=${this.placeholder} autocomplete="off" @change=${this._onInputChange.bind(this)} @keypress=${this._onInputChange.bind(this)} @paste=${this._onInputChange.bind(this)} @input=${this._onInputChange.bind(this)} .value=${this.value || ""} part="input" /> <span class="sb-icon" @click=${this._onIconClick.bind(this)} > ${this._icon == "search" ? fa(faMagnifyingGlass) : nothing} ${this._icon == "loading" ? fa(faCircleNotch, { classes: ["fa-spin"] }) : nothing} ${this._icon == "empty" ? fa(faXmark) : nothing} ${this._icon == "warn" ? fa(faCircleExclamation) : nothing} </span> ${!this.reduced && this._results ? html` <div class="sb-results"> ${this._results.length === 0 ? html` <div class="sb-empty">${this._parent?._t?.pnx.search_empty}</div> ` : nothing} ${map(this._results, i => html` <div class="sb-result" @click=${() => this._onResultClick(i)}> ${i.title} ${i.subtitle && i.subtitle != "" ? html`<br /><small>${i.subtitle}</small>` : nothing} </div> `)} </div> ` : nothing} </div>`; } } customElements.define("pnx-search-bar", SearchBar);