Last active
October 28, 2024 12:21
-
-
Save meodai/dfd5a78e8a5121fa0915a8310539dc3b to your computer and use it in GitHub Desktop.
webcomponent boilerplate
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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