browsertrix/frontend/src/components/combobox.ts
2023-05-30 14:59:34 -07:00

167 lines
3.7 KiB
TypeScript

import { LitElement, html, css } from "lit";
import {
state,
property,
query,
queryAssignedElements,
} from "lit/decorators.js";
import {
SlInput,
SlMenu,
SlMenuItem,
SlPopup,
SlTag,
} from "@shoelace-style/shoelace";
import { dropdown } from "../utils/css";
/**
* Input that opens a popup of autocomplete options
*
* Usage:
* ```ts
* ```
*
* @event request-close
*/
export class Combobox extends LitElement {
static styles = [
dropdown,
css`
:host {
position: relative;
z-index: 2;
}
`,
];
@property({ type: Boolean })
open = false;
@state()
isActive = true;
@query("#dropdown")
private dropdown?: HTMLDivElement;
@query("sl-menu")
private menu?: SlMenu;
@query("sl-popup")
private combobox?: SlPopup;
@queryAssignedElements({
flatten: true,
})
private anchor?: HTMLElement[];
@queryAssignedElements({
slot: "menu-item",
selector: "sl-menu-item:not([disabled])",
})
private menuItems?: SlMenuItem[];
protected willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("open")) {
if (this.open) {
this.openDropdown();
} else {
this.closeDropdown();
}
}
}
render() {
return html`
<sl-popup
placement="bottom-start"
shift
strategy="fixed"
?active=${this.isActive}
@keydown=${this.onKeydown}
@keyup=${this.onKeyup}
@focusout=${this.onFocusout}
>
<div slot="anchor">
<slot></slot>
</div>
<div
id="dropdown"
class="dropdown hidden"
@animationend=${(e: AnimationEvent) => {
const el = e.target as HTMLDivElement;
if (e.animationName === "dropdownShow") {
el.classList.remove("animateShow");
}
if (e.animationName === "dropdownHide") {
el.classList.add("hidden");
el.classList.remove("animateHide");
this.isActive = false;
}
}}
>
<sl-menu role="listbox">
<slot name="menu-item"></slot>
</sl-menu>
</div>
</sl-popup>
`;
}
private async onFocusout(e: FocusEvent) {
const relatedTarget = e.relatedTarget as HTMLElement;
if (!this.open) {
return;
}
if (
!relatedTarget ||
(!this.anchor?.some((item) => item === relatedTarget) &&
!this.menuItems?.some((item) => item === relatedTarget))
) {
await this.updateComplete;
this.dispatchEvent(new CustomEvent("request-close"));
}
}
private onKeydown(e: KeyboardEvent) {
if (this.open && e.key === "ArrowDown") {
if (this.menu && this.menuItems?.length && !this.menu.getCurrentItem()) {
// Focus on first menu item
e.preventDefault();
const menuItem = this.menuItems[0];
this.menu!.setCurrentItem(menuItem);
menuItem.focus();
}
}
}
private async onKeyup(e: KeyboardEvent) {
if (this.open && e.key === "Escape") {
await this.updateComplete;
this.dispatchEvent(new CustomEvent("request-close"));
}
}
private async openDropdown() {
this.isActive = true;
await this.combobox?.updateComplete;
// Manually sync dropdown width instead of using `sync="width"`
// to get around ResizeObserver loop error
if (this.anchor?.length && this.dropdown) {
const anchorWidth = this.anchor[0].clientWidth;
if (anchorWidth) {
this.dropdown.style.width = `${anchorWidth}px`;
}
}
this.dropdown?.classList.add("animateShow");
this.dropdown?.classList.remove("hidden");
}
private closeDropdown() {
this.dropdown?.classList.add("animateHide");
}
}