-
Notifications
You must be signed in to change notification settings - Fork 79
Implement custom elements interface for Crank components #307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <title>Crank Custom Elements Example</title> | ||
| <script type="module"> | ||
| import { createElement, Raw } from "../dist/crank.js"; | ||
| import { createCustomElementClass } from "../dist/custom-elements.js"; | ||
|
|
||
| // Define a Counter component | ||
| function Counter({ count, ref }) { | ||
|
||
| const num = parseInt(count || "0", 10); | ||
| let customElement = null; | ||
|
|
||
| // Expose API methods via ref with explicit element parameter | ||
| ref?.((element) => { | ||
| customElement = element; | ||
| return { | ||
| increment() { | ||
| element.setAttribute("count", (num + 1).toString()); | ||
| }, | ||
| decrement() { | ||
| element.setAttribute("count", Math.max(0, num - 1).toString()); | ||
| }, | ||
| reset() { | ||
| element.setAttribute("count", "0"); | ||
| }, | ||
| get value() { | ||
| return num; | ||
| }, | ||
| // Custom event property for demonstration | ||
| oncustomcount: null | ||
| }; | ||
| }); | ||
|
|
||
| return createElement("div", { style: "padding: 20px; border: 1px solid #ccc; margin: 10px;" }, | ||
| createElement("h3", null, "Counter Component"), | ||
| createElement("p", null, `Count: ${num}`), | ||
| createElement("button", { | ||
| onclick: () => customElement?.increment() | ||
| }, "Increment"), | ||
| createElement("button", { | ||
| onclick: () => customElement?.decrement(), | ||
| style: "margin-left: 10px;" | ||
| }, "Decrement"), | ||
| createElement("button", { | ||
| onclick: () => customElement?.reset(), | ||
| style: "margin-left: 10px;" | ||
| }, "Reset") | ||
| ); | ||
| } | ||
|
|
||
| // Define a Card component with shadow DOM and slots | ||
| function Card({ title, header, children, footer, ref }) { | ||
| ref?.((element) => ({ | ||
| updateTitle(newTitle) { | ||
| element.setAttribute("title", newTitle); | ||
| }, | ||
| // Custom event for card interactions | ||
| oncardaction: null | ||
| })); | ||
|
|
||
| return createElement("div", { style: "border: 2px solid #333; border-radius: 8px; padding: 16px; margin: 10px;" }, | ||
| createElement("h2", { style: "margin-top: 0; color: #333;" }, title || "Card Title"), | ||
| header ? createElement("div", { style: "background: #f0f0f0; padding: 8px; margin: 8px 0;" }, | ||
| createElement(Raw, { value: header })) : null, | ||
| createElement("div", { style: "border-top: 1px solid #eee; padding-top: 12px;" }, | ||
| createElement(Raw, { value: children })), | ||
| footer ? createElement("div", { style: "background: #f0f0f0; padding: 8px; margin: 8px 0 0 0; font-size: 0.9em;" }, | ||
| createElement(Raw, { value: footer })) : null | ||
| ); | ||
| } | ||
|
|
||
| // Create custom element classes | ||
| const CounterElement = createCustomElementClass(Counter, { | ||
| observedAttributes: ["count"] | ||
| }); | ||
|
|
||
| const CardElement = createCustomElementClass(Card, { | ||
| observedAttributes: ["title"], | ||
| shadowDOM: "open" | ||
| }); | ||
|
|
||
| // Register custom elements | ||
| customElements.define("crank-counter", CounterElement); | ||
| customElements.define("crank-card", CardElement); | ||
|
|
||
| // Demonstrate programmatic API access | ||
| document.addEventListener("DOMContentLoaded", () => { | ||
| const counter = document.querySelector("crank-counter"); | ||
| const card = document.querySelector("crank-card"); | ||
|
|
||
| // Add external control buttons | ||
| document.getElementById("external-increment").onclick = () => counter.increment(); | ||
| document.getElementById("external-value").onclick = () => alert(`Counter value: ${counter.value}`); | ||
| document.getElementById("update-card-title").onclick = () => card.updateTitle("Updated Title!"); | ||
|
|
||
| // Demonstrate custom event properties | ||
| counter.oncustomcount = (event) => { | ||
| console.log("Custom count event:", event.detail); | ||
| }; | ||
|
|
||
| card.oncardaction = (event) => { | ||
| console.log("Card action:", event.detail); | ||
| }; | ||
|
|
||
| // Test firing custom events | ||
| document.getElementById("fire-custom-event").onclick = () => { | ||
| counter.dispatchEvent(new CustomEvent('customcount', { detail: { value: counter.value } })); | ||
| card.dispatchEvent(new CustomEvent('cardaction', { detail: { action: 'test' } })); | ||
| }; | ||
| }); | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <h1>Crank Custom Elements Example</h1> | ||
|
|
||
| <h2>Light DOM Counter</h2> | ||
| <crank-counter count="5"></crank-counter> | ||
|
|
||
| <h2>Shadow DOM Card with Slotted Content</h2> | ||
| <crank-card title="My Card"> | ||
| <div slot="header"> | ||
| <strong>🎯 Card Header</strong> | ||
| <p>This header content uses the "header" slot!</p> | ||
| </div> | ||
|
|
||
| <p>This is the default slot content (no slot attribute).</p> | ||
| <ul> | ||
| <li>Light DOM content</li> | ||
| <li>Projected into shadow DOM</li> | ||
| <li>Via slot-based props and Raw elements</li> | ||
| </ul> | ||
|
|
||
| <div slot="footer"> | ||
| <em>Card footer - using the "footer" slot</em> | ||
| </div> | ||
| </crank-card> | ||
|
|
||
| <h2>External API Control</h2> | ||
| <button id="external-increment">Increment Counter Externally</button> | ||
| <button id="external-value">Get Counter Value</button> | ||
| <button id="update-card-title">Update Card Title</button> | ||
| <button id="fire-custom-event">Fire Custom Events</button> | ||
| </body> | ||
| </html> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| import { Component, Context, Children } from "./crank.js"; | ||
| import { DOMRenderer, renderer } from "./dom.js"; | ||
| import { addEventTargetDelegates, removeEventTargetDelegates } from "./event-target.js"; | ||
|
|
||
| /** | ||
| * Options for creating a Custom Element class from a Crank component | ||
| */ | ||
| export interface CreateCustomElementOptions<TProps extends Record<string, any>> { | ||
| /** | ||
| * Attributes to observe for changes (triggers re-render when changed) | ||
| */ | ||
| observedAttributes?: string[]; | ||
|
|
||
| /** | ||
| * Shadow DOM mode: 'open', 'closed', or false for light DOM | ||
| * @default false (light DOM) | ||
| */ | ||
| shadowDOM?: 'open' | 'closed' | false; | ||
|
|
||
| /** | ||
| * Custom renderer instance | ||
| * @default renderer (default DOM renderer) | ||
| */ | ||
| renderer?: DOMRenderer; | ||
| } | ||
|
|
||
| /** | ||
| * API object that can be returned from the ref callback to extend the custom element | ||
| */ | ||
| export type RefAPI = Record<string, any>; | ||
|
|
||
| /** | ||
| * Convert kebab-case to camelCase | ||
| */ | ||
| function kebabToCamelCase(str: string): string { | ||
| return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); | ||
| } | ||
|
|
||
| /** | ||
| * Apply ref API object to custom element with proper this binding | ||
| */ | ||
| function applyRefAPI(element: HTMLElement, api: RefAPI): void { | ||
| if (!api || typeof api !== 'object') return; | ||
|
|
||
| Object.getOwnPropertyNames(api).forEach(name => { | ||
| const descriptor = Object.getOwnPropertyDescriptor(api, name); | ||
| if (!descriptor) return; | ||
|
|
||
| if (descriptor.get || descriptor.set) { | ||
| // Handle getters and setters with proper this binding | ||
| Object.defineProperty(element, name, { | ||
| get: descriptor.get?.bind(element), | ||
| set: descriptor.set?.bind(element), | ||
| configurable: true, | ||
| enumerable: true | ||
| }); | ||
| } else if (typeof descriptor.value === 'function') { | ||
| // Handle methods with proper this binding | ||
| (element as any)[name] = descriptor.value.bind(element); | ||
| } else { | ||
| // Handle regular properties | ||
| (element as any)[name] = descriptor.value; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Creates a Custom Element class from a Crank component | ||
| */ | ||
| export function createCustomElementClass<TProps extends Record<string, any>>( | ||
| component: Component<TProps & { | ||
| ref?: (apiFactory: (element: HTMLElement) => RefAPI) => void; | ||
| children?: Node[]; | ||
| [slotName: string]: any; // Allow any slot names as props | ||
| }>, | ||
| options: CreateCustomElementOptions<TProps> = {} | ||
| ): CustomElementConstructor { | ||
| const { | ||
| observedAttributes = [], | ||
| shadowDOM = false, | ||
| renderer: customRenderer = renderer | ||
| } = options; | ||
|
|
||
| class CrankCustomElement extends HTMLElement { | ||
|
||
| private _props: Partial<TProps> = {}; | ||
|
||
| private _updateScheduled = false; | ||
| private _renderRoot: Element | ShadowRoot; | ||
| private _slots: Record<string, Array<Node>> = {}; | ||
| private _refAPI: RefAPI = {}; | ||
|
|
||
| static get observedAttributes(): string[] { | ||
| return observedAttributes; | ||
| } | ||
|
|
||
| constructor() { | ||
| super(); | ||
|
|
||
| // Set up render root (shadow DOM or light DOM) | ||
| if (shadowDOM) { | ||
| this._renderRoot = this.attachShadow({ mode: shadowDOM }); | ||
| } else { | ||
| this._renderRoot = this; | ||
| } | ||
| } | ||
|
|
||
| connectedCallback(): void { | ||
| // Parse light DOM children into slots | ||
| if (shadowDOM) { | ||
| this._parseSlots(); | ||
| } | ||
| this._scheduleUpdate(); | ||
| } | ||
|
|
||
| disconnectedCallback(): void { | ||
| // Clean up everything by rendering null | ||
| customRenderer.render(null, this._renderRoot); | ||
| } | ||
|
|
||
| // Override dispatchEvent to call matching on* properties | ||
| dispatchEvent(event: Event): boolean { | ||
| // Call standard DOM dispatchEvent first | ||
| const result = super.dispatchEvent(event); | ||
|
|
||
| // Check for matching on* property and call it | ||
| const handlerName = 'on' + event.type.toLowerCase(); | ||
| if (this.hasOwnProperty(handlerName) && typeof (this as any)[handlerName] === 'function') { | ||
| (this as any)[handlerName](event); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { | ||
| if (oldValue === newValue) return; | ||
|
|
||
| // Store the raw attribute value - let component handle conversion | ||
| this._props[name as keyof TProps] = newValue as any; | ||
| this._scheduleUpdate(); | ||
| } | ||
|
|
||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don’t use the “upgrade” part of the CustomElement lifecycle, maybe that can be when we call the callback which the ref gives us (I know kinda mind-bending, but something about the ref callback being called with a callback during upgrade makes me feel some ways. |
||
| private _parseSlots(): void { | ||
|
||
| this._slots = {}; | ||
| Array.from(this.childNodes).forEach(node => { | ||
| // Get slot name from slot attribute (for elements) or use 'children' as default | ||
| let slotName = 'children'; | ||
| if (node.nodeType === Node.ELEMENT_NODE) { | ||
| const slotAttr = (node as Element).getAttribute('slot'); | ||
| if (slotAttr) { | ||
| slotName = slotAttr; | ||
| } | ||
| } | ||
|
|
||
| if (!this._slots[slotName]) { | ||
| this._slots[slotName] = []; | ||
| } | ||
| this._slots[slotName].push(node); | ||
| }); | ||
| } | ||
|
|
||
| private _scheduleUpdate(): void { | ||
| if (!this._updateScheduled) { | ||
| this._updateScheduled = true; | ||
| queueMicrotask(() => { | ||
| this._updateScheduled = false; | ||
| this._render(); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| private _render(): void { | ||
| // Collect current attributes as props | ||
| const props: any = { ...this._props }; | ||
|
|
||
| // Add slots as individual props | ||
| if (shadowDOM) { | ||
| Object.assign(props, this._slots); | ||
| } | ||
|
|
||
| // Add ref callback - component will call this with a function that takes an element | ||
| props.ref = (apiFactory: (element: HTMLElement) => RefAPI) => { | ||
| // Component calls ref with a function, we call that function with this element | ||
| const api = apiFactory(this); | ||
| this._refAPI = api; | ||
| applyRefAPI(this, api); | ||
| }; | ||
|
|
||
| try { | ||
| // Create component result - component will call ref() during execution | ||
| const context = {} as Context<TProps & { ref?: (apiFactory: (element: HTMLElement) => RefAPI) => void }>; | ||
| const componentResult = component.call(context, props, context); | ||
|
|
||
| // Render the component normally | ||
| const result = customRenderer.render( | ||
| componentResult as Children, | ||
| this._renderRoot | ||
| ); | ||
|
|
||
| // Bridge component EventTarget to custom element | ||
| const retainer = customRenderer.cache.get(this._renderRoot); | ||
| if (retainer && retainer.ctx) { | ||
| // Access delegates using the same symbol | ||
| const _delegates = Symbol.for("CustomEventTarget.delegates"); | ||
| const delegatesSet = (retainer.ctx as any)[_delegates]; | ||
|
|
||
| // Clear existing child delegates | ||
| if (delegatesSet && delegatesSet.size > 0) { | ||
| const existingDelegates = Array.from(delegatesSet); | ||
| removeEventTargetDelegates(retainer.ctx as any, existingDelegates); | ||
| } | ||
|
|
||
| // Add custom element as delegate | ||
| addEventTargetDelegates(retainer.ctx as any, [this]); | ||
| } | ||
|
|
||
| // Handle async rendering | ||
| if (result && typeof (result as any).then === 'function') { | ||
| (result as Promise<any>).catch((error: Error) => { | ||
| console.error('Error in async render:', error); | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| console.error('Error rendering Crank component in custom element:', error); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return CrankCustomElement as any; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@brainkim1 This should be a JavaScript file. See examples for examples of examples.