Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions examples/custom-elements.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!DOCTYPE html>
Copy link
Member Author

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.

<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 }) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example could be more interesting. How about we revive marquee and blink as custom components?

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>
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@
"import": "./dist/crank.js",
"require": "./dist/crank.cjs"
},
"./custom-elements": {
"import": "./dist/custom-elements.js",
"require": "./dist/custom-elements.cjs"
},
"./custom-elements.js": {
"import": "./dist/custom-elements.js",
"require": "./dist/custom-elements.cjs"
},
"./dom": {
"import": "./dist/dom.js",
"require": "./dist/dom.cjs"
Expand Down
1 change: 1 addition & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function copyPackage() {
const input = [
"src/async.ts",
"src/crank.ts",
"src/custom-elements.ts",
"src/dom.ts",
"src/event-target.ts",
"src/jsx-runtime.ts",
Expand Down
228 changes: 228 additions & 0 deletions src/custom-elements.ts
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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to have the name of the custom element in the generated class. If this is possible. So Constructor.name would be the component name, somehow.

private _props: Partial<TProps> = {};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use declare [_props]: Partial;

No initializers, all properties should be defined in the constructor.

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();
}

Copy link
Member Author

Choose a reason for hiding this comment

The 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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the convention of hidden Symbol methods for private methods. This is the public element interface.

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;
}
Loading
Loading