Recipe
Dropdown
A class-based dropdown with model binding, directive value parsing, and support for multiple instances.
Markup
<!-- DROPDOWN: model binding, @click -->
<section style="margin-top: 2rem;">
<h2>Dropdown (model="selected")</h2>
<div custom-dropdown model="selected">
<button ref="toggle">@selected</button>
<div custom-dropdown-menu ref="menu">
<a href="javascript:void(0);" @click="select('Home')">Home</a>
<a href="javascript:void(0);" @click="select('About')">About</a>
<a href="javascript:void(0);" @click="select('Contact')">Contact</a>
</div>
</div>
<p>Selected: <strong data-dropdown-display>Home</strong></p>
</section>
Capsule
import { Capsule, CapsuleOptions, PropsChangeListener } from "tiny-engine-core";
export interface DropdownOptions extends CapsuleOptions {
model?: string;
open?: boolean;
}
export class Dropdown extends Capsule {
static defaults: DropdownOptions = { open: false };
declare options: DropdownOptions;
private selectedValue = "";
constructor(el: HTMLElement, options: DropdownOptions) {
super(el, options);
requestAnimationFrame(() => {
const toggle = this.resolveToggle();
const menu = this.resolveMenu();
if (!toggle || !menu) {
return;
}
this.selectedValue = this.resolveInitialValue(menu);
this.syncSelectionUI();
this.on(toggle, "click", (event) => {
event.preventDefault();
event.stopPropagation();
this.props.open = !this.props.open;
});
menu.querySelectorAll<HTMLAnchorElement>("a").forEach((item) => {
this.on(item, "click", (event) => {
event.preventDefault();
event.stopPropagation();
this.select(this.extractValue(item));
});
});
this.on(menu, "click", (event) => event.stopPropagation());
this.on(document, "click", () => {
if (this.options.open) {
this.props.open = false;
}
});
this.handleOpenChange(this.options.open ?? false, false, "open");
});
this.onPropChange("open", this.handleOpenChange);
}
select(value: string): void {
this.selectedValue = value;
this.el.setAttribute("data-selected", value);
this.syncSelectionUI();
this.props.open = false;
this.emit("select", { value, model: this.options.model ?? null });
}
private resolveToggle(): HTMLButtonElement | null {
return (
(this.refs.toggle as HTMLButtonElement | undefined) ??
this.el.querySelector<HTMLButtonElement>('[ref="toggle"], [ref^="toggle"]') ??
this.el.querySelector<HTMLButtonElement>("button")
);
}
private resolveMenu(): HTMLElement | null {
return (
(this.refs.menu as HTMLElement | undefined) ??
this.el.querySelector<HTMLElement>('[ref="menu"], [ref^="menu"]') ??
this.el.querySelector<HTMLElement>("[custom-dropdown-menu]")
);
}
private resolveDisplay(): HTMLElement | null {
const section = this.el.closest("section");
if (section) {
return (
section.querySelector<HTMLElement>("[data-dropdown-display]") ??
section.querySelector<HTMLElement>('[ref="display"], [ref^="display"]') ??
section.querySelector<HTMLElement>('strong[id="selected-display"]')
);
}
return null;
}
private resolveInitialValue(menu: HTMLElement): string {
const displayValue = this.resolveDisplay()?.textContent?.trim();
if (displayValue) {
return displayValue;
}
const firstItem = menu.querySelector<HTMLAnchorElement>("a");
return firstItem ? this.extractValue(firstItem) : "";
}
private extractValue(item: HTMLAnchorElement): string {
const dataValue = item.getAttribute("data-value");
if (dataValue) {
return dataValue;
}
const directive = item.getAttribute("@click");
if (directive) {
const match = directive.match(/select\(['"]([^'"]+)['"]\)/);
if (match) {
return match[1];
}
}
return item.textContent?.trim() || "";
}
private syncSelectionUI(): void {
const value = this.selectedValue || "";
const toggle = this.resolveToggle();
const display = this.resolveDisplay();
if (toggle) {
toggle.textContent = value;
}
if (display) {
display.textContent = value;
}
}
private handleOpenChange: PropsChangeListener = (newValue: unknown) => {
const open = !!newValue;
const menu = this.resolveMenu();
if (menu) {
menu.style.display = open ? "block" : "none";
}
const toggle = this.resolveToggle();
if (toggle) {
toggle.setAttribute("aria-expanded", open ? "true" : "false");
}
};
}Demo
Demo mention below: two independent dropdowns bind values to nearby display nodes and emit select with the active model id.