Skip to content

Instantly share code, notes, and snippets.

@straversi
Last active March 21, 2022 18:59
Show Gist options
  • Save straversi/33d772d4aad2e2fb63741e96c963c71c to your computer and use it in GitHub Desktop.
Save straversi/33d772d4aad2e2fb63741e96c963c71c to your computer and use it in GitHub Desktop.
// First, install one dependency:
// npm install lit
//
// Then, include this file on your page:
// import './path/to/change-search-bar'
//
// Then, you can use the <change-search-bar> component like any
// HTML element:
// <change-search-bar></change-search-bar>
//
// You can style the component normally with CSS:
// change-search-bar {
// width: 100%;
// }
//
// This component emits an event whenever a search result is
// selected. You can handle it like this:
// document.addEventListener('select-nonprofit', e => {
// const selectedNonprofit = e.detail;
// // Do something with selectedNonprofit
// });
import { html, LitElement, css, nothing, svg } from "lit";
import { customElement, property, state, query } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
/**
* Information about a Nonprofit to show in search results.
*/
export interface Nonprofit {
id: string;
name: string;
description: string;
ein: string;
category: string;
icon_url: string;
crypto: {
solana_address: string;
ethereum_address: string;
};
}
/**
* A search bar for nonprofits.
*/
@customElement("change-search-bar")
export class ChangeSearchBar extends LitElement {
/** Results for the current search query + filters. */
@state()
private searchResults: Nonprofit[] = [];
@state()
loading = false;
/** The search input box. */
@query("input[type=text]") searchInput!: HTMLInputElement;
/** Used for cancelling search API requests */
searchTimeout: number | undefined;
constructor() {
super();
}
render() {
return html`
<!-- Search box -->
<div id="search-area">
${searchIcon()}
<input
type="text"
placeholder="Nonprofit name or EIN"
name="nonprofit-search"
@input=${this.performSearch}
class=${classMap({ "search-term": !this.noSearchTerm() })}
></input>
</div>
<!-- Search results -->
${
this.noSearchTerm()
? nothing
: html` <div id="backdrop">${this.renderSearchResults()}</div> `
}
`;
}
renderSearchResults() {
return html`
<div id="search-results" tabindex="1">
${this.searchResults.length !== 0
? html`
${this.searchResults.slice(0, 10).map(
(nonprofit) => html`
<button
class="search-result"
@click=${() => this.handleSearchResultClick(nonprofit)}
>
<img src=${nonprofit.icon_url} />
<div class="name">${nonprofit.name}</div>
<div class="category">${nonprofit.category}</div>
</button>
`
)}
`
: nothing}
${this.searchResults.length === 0 && !this.loading
? html`
<p id="no-results">
Are we missing a nonprofit? Email hello@getchange.io and
we'll help!
</p>
`
: nothing}
${this.loading
? html`
<div id="loading-overlay"><span class="spinner"></span></div>
`
: nothing}
</div>
`;
}
clear() {
this.searchInput.value = "";
this.requestUpdate();
}
private handleSearchResultClick(nonprofit: Nonprofit) {
this.dispatchEvent(
new CustomEvent("select-nonprofit", {
detail: nonprofit,
bubbles: true,
composed: true,
})
);
}
/**
* Search nonprofits given the current state of the search input box and filters.
*/
private performSearch() {
const name = this.searchInput.value;
if (name === "") {
this.loading = false;
this.searchResults = [];
return;
}
this.loading = true;
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchTimeout = window.setTimeout(() => {
const queryParams = new URLSearchParams();
queryParams.append("search_term", name);
fetch(
`https://api.getchange.io/api/v1/nonprofit_basics?${queryParams.toString()}`,
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => response.json())
.then((response) => response.nonprofits as Nonprofit[])
.then((nonprofits) => {
this.searchResults = nonprofits;
})
.catch(() => {})
.finally(() => {
this.loading = false;
});
}, 200);
}
private noSearchTerm() {
if (!this.searchInput) {
return true;
}
const searchInputEmpty =
this.searchInput.value === null || this.searchInput.value === "";
return searchInputEmpty;
}
static styles = [
css`
:host {
display: block;
position: relative;
--spinner-primary-color: white;
--spinner-secondary-color: rgba(255, 255, 255, 0.2);
}
input:focus ~ #icon {
opacity: 0.8;
}
#search-area {
display: flex;
align-items: center;
position: relative;
z-index: 2;
}
#search-area svg {
position: absolute;
left: 0.7em;
width: 1.5em;
}
input {
width: 100%;
border-radius: 1em;
background: var(--input-background-color, white);
padding: 0.8em 1.1em 0.8em 3em;
margin: 0;
font-family: inherit;
border: 1px solid var(--input-border-color, transparent);
color: var(--input-color, black);
box-shadow: 0px 0px 21px rgba(0, 0, 0, 0.04);
}
input.search-term {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
border-bottom: 1px solid var(--input-border-color, #ddd);
}
input::placeholder {
color: var(--input-placeholder-color, #999);
}
#no-results {
text-align: center;
font-size: 1.3em;
}
#search-results {
padding: 1.2em;
position: relative;
top: 0.1em;
box-sizing: border-box;
min-height: 9em;
}
.search-result {
display: flex;
align-items: center;
padding: 9px 16px;
margin: 0 -16px;
width: calc(100% + 32px);
border-radius: 6px;
border: none;
z-index: 1;
color: inherit;
text-decoration: none;
background-color: transparent;
transition: background-color 0.1s ease-out;
white-space: nowrap;
text-overflow: ellipsis;
}
.search-result img {
height: 1.5em;
border-radius: 50%;
margin-right: 0.7em;
}
.search-result:hover {
background-color: var(--search-result-background-hover, #f6f7fa);
}
.search-result .name {
}
@media (max-width: 800px) {
.search-result .name {
flex: 1;
}
}
.search-result .category {
color: var(--color, black);
opacity: 0.5;
margin-left: 12px;
}
#loading-overlay {
position: absolute;
display: flex;
justify-content: center;
background-color: rgba(0, 0, 0, 0.1);
align-items: center;
z-index: 10;
--inset: 10px;
left: var(--inset);
right: var(--inset);
top: calc(0.5em + var(--inset));
bottom: var(--inset);
border-radius: 1em;
}
#backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
background-color: var(--input-background-color, white);
padding-top: 2.2em;
border-radius: 1em;
box-shadow: 0px 0px 21px rgba(0, 0, 0, 0.04);
z-index: 1;
}
.spinner {
width: 48px;
height: 48px;
border: 5px solid var(--spinner-primary-color, rgb(134, 55, 225));
border-bottom-color: var(
--spinner-secondary-color,
rgba(134, 55, 225, 0.2)
);
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
button,
input {
font-size: inherit;
}
button {
cursor: pointer;
}
`,
];
}
function searchIcon() {
return svg`
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.163 41.6551C33.8263 41.6551 41.66 33.8221 41.66 24.1596C41.66 14.4971 33.8263 6.66406 24.163 6.66406C14.4997 6.66406 6.66602 14.4971 6.66602 24.1596C6.66602 33.8221 14.4997 41.6551 24.163 41.6551Z" stroke="#8A959E" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36.5332 36.5322L46.6565 46.6549" stroke="#8A959E" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment