Skip to content

Instantly share code, notes, and snippets.

@meodai
Last active October 28, 2024 12:21
Show Gist options
  • Save meodai/dfd5a78e8a5121fa0915a8310539dc3b to your computer and use it in GitHub Desktop.
Save meodai/dfd5a78e8a5121fa0915a8310539dc3b to your computer and use it in GitHub Desktop.
webcomponent boilerplate
/**
* Web Component Boilerplate
* ------------------------
* This boilerplate provides a foundation for creating Web Components using TypeScript.
* It implements best practices and common patterns for building maintainable,
* type-safe custom elements.
*
* Key Features:
* - TypeScript support with strict typing
* - Shadow DOM encapsulation
* - Lifecycle management
* - Event handling with proper binding
* - State management
* - Attribute observation and reflection
* - Custom event dispatch
* - Debug mode support
* - Error handling
*
* Usage:
* ```typescript
* interface MyState {
* count: number;
* }
*
* class MyComponent extends BaseWebComponent<MyState> {
* static override componentName = 'my-component';
* protected override initialState = { count: 0 };
*
* private handleClick = this.bindMethod(this.onClick);
*
* protected override attachEventListeners(): void {
* this.addEventListener(this.$component, 'click', this.handleClick);
* }
*
* private onClick(e: Event): void {
* this.setState({ count: this.state.count + 1 });
* }
* }
*
* MyComponent.define();
* ```
*/
// Types for component state and options
interface ComponentOptions {
debug?: boolean;
validateState?: boolean;
}
// Base interface for component state
interface BaseState {
[key: string]: unknown;
}
// Optional: Method binding decorator
export function bound(_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
return {
configurable: true,
get() {
const boundFunction = originalMethod.bind(this);
Object.defineProperty(this, propertyKey, {
value: boundFunction,
configurable: true,
writable: true
});
return boundFunction;
}
};
}
// Create stylesheet
const stylesheet = new CSSStyleSheet();
// Define base styles
const baseStyles = `
:host {
--component-spacing: 1rem;
display: block;
}
:host([hidden]) {
display: none;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
.component {
display: block;
}
:host([loading]) .component {
opacity: 0.7;
cursor: wait;
}
:host([error]) .component {
border: 1px solid red;
}
.error-message {
color: red;
margin-top: var(--component-spacing);
}
.loading-indicator {
display: flex;
justify-content: center;
align-items: center;
padding: var(--component-spacing);
}
`;
// Create template
const componentTemplate = document.createElement('template');
componentTemplate.innerHTML = `
<div class="component" data-component>
<slot></slot>
<div class="error-message" hidden data-error></div>
<div class="loading-indicator" hidden data-loading>Loading...</div>
</div>
`;
// Apply styles
stylesheet.replaceSync(baseStyles);
export abstract class BaseWebComponent<T extends BaseState = BaseState> extends HTMLElement {
// Static properties
static componentName: string = 'base-component';
protected static defaultOptions: ComponentOptions = {
debug: false,
validateState: true
};
// Abstract property that must be implemented by child classes
protected abstract initialState: T;
// Private fields
#isInternalUpdate = false;
#state: T;
#options: ComponentOptions;
#debugMode: boolean;
#eventHandlers = new Map<string, {
element: HTMLElement | Window | Document,
handler: EventListener,
options?: AddEventListenerOptions
}[]>();
// Protected fields
protected shadowRoot: ShadowRoot;
protected $wrapper: DocumentFragment;
protected $component: HTMLElement;
protected $errorDisplay: HTMLElement;
protected $loadingIndicator: HTMLElement;
// Getters
get state(): Readonly<T> {
return { ...this.#state };
}
static get observedAttributes(): string[] {
return ['disabled', 'loading', 'error'];
}
constructor(options: ComponentOptions = {}) {
super();
this.#options = { ...BaseWebComponent.defaultOptions, ...options };
this.#debugMode = this.#options.debug || false;
this.shadowRoot = this.attachShadow({ mode: 'open' });
this.#initializeTemplate();
this.#state = this.initialState;
}
// Static registration method
static define(): void {
if (!customElements.get(this.componentName)) {
customElements.define(this.componentName, this);
}
}
// Lifecycle methods
connectedCallback(): void {
this.logDebug('Component connected');
try {
this.render();
this.attachEventListeners();
this.shadowRoot.adoptedStyleSheets = [stylesheet];
this.dispatchEvent(new CustomEvent('component-connected'));
} catch (error) {
this.handleError(error);
}
}
disconnectedCallback(): void {
this.logDebug('Component disconnected');
this.removeEventListeners();
this.dispatchEvent(new CustomEvent('component-disconnected'));
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
): void {
if (oldValue === newValue) return;
this.logDebug(`Attribute "${name}" changed:`, { oldValue, newValue });
if (!this.#isInternalUpdate) {
try {
this.handleAttributeChange(name, oldValue, newValue);
} catch (error) {
this.handleError(error);
}
}
}
// Protected methods
protected handleAttributeChange(
name: string,
oldValue: string | null,
newValue: string | null
): void {
switch (name) {
case 'disabled':
this.onDisabledChange(newValue !== null);
break;
case 'loading':
this.onLoadingChange(newValue !== null);
break;
case 'error':
this.onErrorChange(newValue);
break;
}
}
protected onDisabledChange(isDisabled: boolean): void {
this.toggleAttribute('aria-disabled', isDisabled);
}
protected onLoadingChange(isLoading: boolean): void {
if (this.$loadingIndicator) {
this.$loadingIndicator.hidden = !isLoading;
}
}
protected onErrorChange(error: string | null): void {
if (this.$errorDisplay) {
this.$errorDisplay.hidden = !error;
if (error) {
this.$errorDisplay.textContent = error;
}
}
}
/**
* Utility method to bind class methods to the component instance.
* Helps avoid losing 'this' context in event handlers.
*/
protected bindMethod<F extends (...args: any[]) => any>(method: F): F {
return method.bind(this);
}
/**
* Creates an event handler with automatic binding and optional parameters
*/
protected createEventHandler<E extends Event, A extends any[]>(
handler: (event: E, ...args: A) => void,
...args: A
): (event: E) => void {
return this.bindMethod((event: E) => handler.call(this, event, ...args));
}
/**
* Safely adds event listeners with automatic cleanup
*/
protected addEventListener(
element: HTMLElement | Window | Document,
eventName: string,
handler: EventListener,
options?: AddEventListenerOptions
): void {
const boundHandler = this.bindMethod(handler);
element.addEventListener(eventName, boundHandler, options);
const handlers = this.#eventHandlers.get(eventName) || [];
handlers.push({ element, handler: boundHandler, options });
this.#eventHandlers.set(eventName, handlers);
}
/**
* Removes a specific event listener
*/
protected removeEventListener(
element: HTMLElement | Window | Document,
eventName: string,
handler: EventListener,
options?: EventListenerOptions
): void {
const handlers = this.#eventHandlers.get(eventName);
if (handlers) {
const index = handlers.findIndex(h => h.handler === handler);
if (index !== -1) {
const { handler: boundHandler } = handlers[index];
element.removeEventListener(eventName, boundHandler, options);
handlers.splice(index, 1);
if (handlers.length === 0) {
this.#eventHandlers.delete(eventName);
}
}
}
}
// State management
protected setState(newState: Partial<T>, callback?: () => void): void {
const oldState = { ...this.#state };
this.#state = { ...this.#state, ...newState };
if (this.#options.validateState) {
this.validateState(this.#state);
}
this.logDebug('State updated:', { oldState, newState: this.#state });
this.render();
this.dispatchEvent(new CustomEvent('state-changed', {
detail: { oldState, newState: this.#state },
bubbles: true,
composed: true
}));
if (callback) {
callback();
}
}
// Error handling
protected handleError(error: unknown): void {
console.error(`Error in ${this.constructor.name}:`, error);
this.setAttribute('error', error instanceof Error ? error.message : 'Unknown error');
this.dispatchEvent(new CustomEvent('component-error', {
detail: { error },
bubbles: true,
composed: true
}));
}
// Validation
protected validateState(state: T): void {
// Override this method to implement state validation
}
// Utility methods
protected logDebug(...args: unknown[]): void {
if (this.#debugMode) {
console.log(`[${this.constructor.name}]`, ...args);
}
}
// Private initialization
#initializeTemplate(): void {
this.$wrapper = componentTemplate.content.cloneNode(true) as DocumentFragment;
this.$component = this.$wrapper.querySelector('[data-component]')!;
this.$errorDisplay = this.$wrapper.querySelector('[data-error]')!;
this.$loadingIndicator = this.$wrapper.querySelector('[data-loading]')!;
this.shadowRoot.appendChild(this.$wrapper);
}
// Methods to be implemented by child classes
protected attachEventListeners(): void {
// Override this method to add your event listeners
}
protected removeEventListeners(): void {
this.#eventHandlers.forEach((handlers, eventName) => {
handlers.forEach(({ element, handler, options }) => {
element.removeEventListener(eventName, handler, options);
});
});
this.#eventHandlers.clear();
}
protected render(): void {
// Override this method to implement your rendering logic
this.logDebug('Render called with state:', this.#state);
}
// Public API
public async updateAsync(callback: (state: T) => Promise<Partial<T>>): Promise<void> {
try {
this.setAttribute('loading', '');
const newState = await callback(this.state);
this.setState(newState);
} catch (error) {
this.handleError(error);
} finally {
this.removeAttribute('loading');
}
}
public reset(): void {
this.setState(this.initialState);
}
}
interface MyComponentState {
count: number;
label: string;
}
class MyComponent extends BaseWebComponent<MyComponentState> {
static override componentName = 'my-component';
protected override initialState = {
count: 0,
label: 'Default'
};
// Example of binding an event handler
private handleClick = this.bindMethod(this.onClick);
private onClick(e: Event): void {
this.setState({
count: this.state.count + 1,
label: `Clicked ${this.state.count + 1} times`
});
}
protected override attachEventListeners(): void {
this.addEventListener(this.$component, 'click', this.handleClick);
}
protected override render(): void {
this.$component.innerHTML = `
<h2>${this.state.label}</h2>
<p>Count: ${this.state.count}</p>
<button>Click me</button>
`;
}
}
// Register the component
MyComponent.define();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment