@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
368 lines (336 loc) • 9.1 kB
JavaScript
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 ;
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);