@mr_hugo/boredom
Version:
Another boring JavaScript framework.
650 lines (594 loc) • 21.2 kB
text/typescript
/**
* DOM integration layer for boreDOM.
*
* Responsibilities:
* - Discover <template data-component> nodes and register custom elements
* - Provide utilities to query and create elements
* - Define the base custom element class (Bored) and the component factory
* - Wire inline event attributes (onclick, etc.) to custom event dispatchers
* - Support dynamic import of per-component scripts
*/
import { createSlotsAccessor } from "./bore";
import type { LoadedFunction } from "./types";
/**
* It dynamically imports all scripts that have a filename that matches the
* provided name. This is intended to load the string of the `<template>` `data-component` attribute.
*
* @param names - The list of names to try to dynamically load. It appends .js to them.
*
* @returns A Map of the registered web-components tag names, and their corresponding
* dynamically loaded .js file exported function (or null if there is no .js file).
*/
/**
* Attempts to import component scripts based on <script src> tags present
* in the document. For each tag name, it finds a script whose src contains
* that name, and dynamically imports it.
*
* Notes:
* - The first export found in the module is used as the component function.
* Keep component modules with a single export to avoid ambiguity.
* - Errors are logged but do not throw to keep the page operational.
*
* Example:
* ```html
* <!-- In your HTML -->
* <script type="module" src="/components/user-card.js"></script>
* ```
* ```ts
* const map = await dynamicImportScripts(['user-card']);
* const init = map.get('user-card'); // a loaded function or null
* ```
*/
export const dynamicImportScripts = async (names: string[]) => {
const result: Map<string, null | LoadedFunction> = new Map();
for (let i = 0; i < names.length; ++i) {
// Load the associated script if it exists
const scriptLocation = query(`script[src*="${names[i]}"]`)?.getAttribute(
"src",
);
let f: null | LoadedFunction = null;
if (scriptLocation) {
// Dynamic import it and get the default export
try {
const exports = await import(scriptLocation);
for (const exported of Object.keys(exports)) {
f = exports[exported];
break;
}
result.set(names[i], f);
} catch (e) {
console.error(`Unable to import "${scriptLocation}"`, e);
}
}
}
return result;
};
/**
* Set of helper functions to handle the DOM.
*
* Reads the DOM and queries for all the `<template>` tags that
* have a `data-component=""`. For each of them:
* - Registers a web-component with that tag name (@see `component()` local function).
* - All attributes in the `<template>` tag are passed as is to the web component when connected.
* - Returns the registered web component name.
*
* @returns A list of the tag names that were registered.
*/
/**
* Scans the DOM for <template data-component> and registers a custom element
* for each one. Any additional data-* attributes on the template are copied
* to the custom element instance as attributes on connect.
*
* Example:
* ```html
* <template data-component="user-card" data-aria-label="Profile"></template>
* ```
* ```ts
* const names = searchForComponents(); // ['user-card']
* customElements.get('user-card'); // defined
* ```
*/
export const searchForComponents = () => {
// Query all templates with a data-component attribute, these will be used to
// create custom web components with the tag name similar to the id
return Array.from(queryAll("template[data-component]"))
.filter((elem): elem is HTMLElement => elem instanceof HTMLElement)
.map((t) => {
const result: { name: string; attributes: [string, string][] } = {
name: "",
attributes: [],
};
for (const attribute in t.dataset) {
if (attribute === "component") {
result.name = t.dataset[attribute] ?? "";
} else {
// Attribute is not "component": pass it as-is (kebab-case),
// using empty string when no value is present.
result.attributes.push([
decamelize(attribute),
t.dataset[attribute] ?? "",
]);
}
}
if (result.name === "") {
throw new Error(
`A <template> was found with an invalid data-component: "${t.dataset.component}"`,
);
}
return result;
})
.map(({ name, attributes }) => {
// Create and register the web component:
component(name, { attributes });
return name;
});
};
/**
* Creates a new web-component and registers it with the provided tag name
*/
/**
* Creates an element for a registered tag and optionally assigns its render
* callback. Throws if the tag was not registered via a matching template.
*
* Example:
* ```ts
* const el = createComponent('user-card', (c) => { c.textContent = 'Hi'; });
* document.body.appendChild(el);
* ```
*/
export const createComponent = (
name: string,
update?: (c: Bored) => void,
): Bored => {
const element = create(name);
if (!isBored(element)) {
const error = `The tag name "${name}" is not a BoreDOM component.
\n"createComponent" only accepts tag-names with matching <template> tags that have a data-component attribute in them.`;
console.error(error);
throw new Error(error);
}
if (update) {
element.renderCallback = update;
}
return element;
};
/**
* Queries for the component tag name in the DOM. Throws error if not found.
*/
/**
* Queries a component by CSS selector and returns it only if it is a
* boreDOM component (Bored). Returns undefined when not found/mismatched.
*
* Example:
* ```ts
* const card = queryComponent('user-card');
* if (card) card.setAttribute('data-visible', 'true');
* ```
*/
export const queryComponent = (q: string): Bored | undefined => {
const elem = query(q);
if (elem === null || !(isBored(elem))) {
return undefined;
}
return elem;
};
/** `document.querySelector` */
/** document.querySelector */
export const query = (query: string) => document.querySelector(query);
/** `document.querySelectorAll` */
export const queryAll = (query: string) => document.querySelectorAll(query);
/** `document.createElement` */
export const create = (tagName: string, children?: HTMLElement[]) => {
const e = document.createElement(tagName);
if (children && Array.isArray(children) && children.length > 0) {
children.map((c) => e.appendChild(c));
}
return e;
};
export const queryHtml = (q: string): HTMLElement => {
const html = query(q);
if (!(html instanceof HTMLElement)) {
throw new Error(`Cannot find an HTMLElement with selector "${q}"`);
}
return html;
};
/** `dispatchEvent(new CustomEvent(name, { detail }))` */
/**
* Dispatches a CustomEvent on the document, ensuring it runs after DOM is
* ready if called too early. Used by inline handlers via onclick="dispatch('...')".
*
* Example (HTML + TS):
* ```html
* <button onclick="dispatch('save', { id: 1 })">Save</button>
* ```
* ```ts
* handle<{ id: number }>('save', ({ id }) => console.log(id));
* ```
*/
export const dispatch = (name: string, detail?: any) => {
if (document.readyState === "loading") {
addEventListener(
"DOMContentLoaded",
() => dispatchEvent(new CustomEvent(name, { detail })),
);
} else {
dispatchEvent(new CustomEvent(name, { detail }));
}
};
/** Calls addEventListener, returns the function used as listener */
/** Adds an event listener for a custom event and returns the bound handler */
export const handle = <T>(name: string, f: (detail: T) => void) => {
const handler = (e: CustomEvent) => f(e.detail);
addEventListener(name as any, handler);
return handler;
};
export const isTemplate = (e: HTMLElement): e is HTMLTemplateElement =>
e instanceof HTMLTemplateElement;
const isObject = (t: any): t is object => typeof t === "object";
const isFunction = (t: any): t is Function => typeof t === "function";
export const isBored = (t: unknown): t is Bored =>
isObject(t) && "isBored" in t && Boolean(t.isBored);
/** Placeholder for future API to introspect event emissions */
export const emitsEvent = (eventName: string, elem: HTMLElement) => {};
const camelize = (str: string) => {
return str.split("-")
.map((item, index) =>
index
? item.charAt(0).toUpperCase() + item.slice(1).toLowerCase()
: item.toLowerCase()
)
.join("");
};
const decamelize = (str: string): string => {
if (
str === "" || !str.split("").some((char) => char !== char.toLowerCase())
) {
return str;
}
let result = "";
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (char === char.toUpperCase() && i !== 0) {
result += "-";
}
result += char.toLowerCase();
}
return result;
};
/**
* Finds the first ancestor that is a custom element (has a dash in tagName).
* Not exported; kept for potential future use.
*/
const firstWebComponentParent = (elem: HTMLElement) => {
let currentParent = elem.parentElement;
while (currentParent && currentParent.tagName.indexOf("-") < 0) {
currentParent = currentParent.parentElement;
}
return currentParent;
};
type StartsWithOn = `on${string}`;
type StartsWithQueriedOn = `queriedOn${string}`;
const isStartsWithOn = (s: string): s is StartsWithOn => s.startsWith("on");
const isStartsWithQueriedOn = (s: string): s is StartsWithQueriedOn =>
s.startsWith("queriedOn");
/**
* Normalizes prop names like onClick/queriedOnClick to native event names
* ("click").
*/
const getEventName = (s: StartsWithOn | StartsWithQueriedOn) => {
if (isStartsWithOn(s)) {
return s.slice(2).toLowerCase();
}
return s.slice(9).toLowerCase();
};
export abstract class Bored extends HTMLElement {
abstract renderCallback: (elem: Bored) => void;
}
/**
* Defines and registers a custom element for a tag name, applying lifecycle
* hooks, attribute mirroring, and event wiring.
*
* Props support:
* - shadow/shadowrootmode/style: ShadowRoot setup and styling
* - on*, queriedOn*: Event listeners either on host (on*) or queried children
* - attributeChangedCallback: per-attribute change handlers
* - attributes: initial attributes to mirror from template data-*
*
* Example (event wiring):
* ```ts
* component('my-tag', {
* onKeydown: (e) => console.log('host keydown', e.key),
* queriedOnClick: { 'button.primary': () => console.log('clicked') },
* });
* ```
*/
const component = <T>(tag: string, props: {
/** Shadow-root content for this component */
shadow?: string;
shadowrootmode?: ShadowRootMode;
/** Style for this component, placed in a <style> tag in the #shadowroot */
style?: string;
connectedCallback?: (e: HTMLElement) => void;
disconnectedCallback?: (e: HTMLElement) => void;
adoptedCallback?: (e: HTMLElement) => void;
attributeChangedCallback?: {
[key: string]: (changed?: {
element: HTMLElement;
name: string;
oldValue: string;
newValue: string;
}) => void;
};
attributes?: [string, string][];
[key: StartsWithOn]: (<T extends Event>(e: T) => any) | undefined;
[key: StartsWithQueriedOn]:
| ({ [key: string]: <T extends Event>(e: T) => any })
| undefined;
} = {}) => {
// Don't register two components with the same custom tag:
if (customElements.get(tag)) return;
customElements.define(
tag,
class extends Bored {
// Specify observed attributes so that
// attributeChangedCallback will work
static get observedAttributes() {
if (typeof props.attributeChangedCallback === "object") {
return Object.keys(props.attributeChangedCallback);
}
return [];
}
constructor() {
super();
}
/**
* Useful to know if a given HTMLElement is a Bored component.
* @see `isBored()` typeguard
*/
isBored = true;
traverse(
f: (elem: HTMLElement, i: number, all: HTMLElement[]) => void,
{ traverseShadowRoot, query }: {
/** defaults to "false" */
traverseShadowRoot?: boolean;
/** defaults to "*" */
query?: string;
} = {},
) {
Array.from(
traverseShadowRoot
? this.shadowRoot?.querySelectorAll(query ?? "*") ?? []
: [],
)
.concat(Array.from(this.querySelectorAll(query ?? "*")))
.filter((n): n is HTMLElement => n instanceof HTMLElement)
.forEach(f);
}
/**
* Returns the list of custom event names from a string that is shaped like:
* `"dispatch('event1', 'event2', ...)"`
*
* This is useful when traversing for event handlers to be replaced
* with custom dispatchers.
* @returns an array of strings
*/
/** Extracts event names from strings like "dispatch('a','b')" */
#parseCustomEventNames(str: string) {
return str.split("'").filter((s) =>
s.length > 2 &&
!(s.includes("(") || s.includes(",") || s.includes(")"))
);
}
/**
* Replaces inline on* attributes within the component DOM with real
* listeners that dispatch custom events using dispatch().
*/
#createDispatchers() {
let host: HTMLElement;
this.traverse((node) => {
// Check for 'on' attributes
if (node instanceof HTMLElement) {
const isWebComponent = customElements.get(
node.tagName.toLowerCase(),
);
if (isWebComponent) host = node;
for (let i = 0; i < node.attributes.length; i++) {
const attribute = node.attributes[i];
if (isStartsWithOn(attribute.name)) {
// Parse the custom events names:
const eventNames = this.#parseCustomEventNames(attribute.value);
if (eventNames.length > 0) {
// Add listener and dispatcher
eventNames.forEach((customEventName) => {
node.addEventListener(
getEventName(attribute.name as any),
(e) =>
dispatch(customEventName, {
event: e,
dispatcher: node,
component: this,
index: this.parentElement
? Array.from(this.parentElement.children).indexOf(
this,
)
: -1,
}),
);
});
}
// Update the attributes to signal that they are now active:
node.setAttribute(
`data-${attribute.name}-dispatches`,
eventNames.join(),
);
node.removeAttribute(attribute.name);
}
}
}
}, { traverseShadowRoot: true });
}
isInitialized: boolean = false;
#init() {
let template: HTMLTemplateElement =
query(`[data-component="${tag}"]`) as any ??
create("template");
const isTemplateShadowRoot = template.getAttribute("shadowrootmode") as
| ShadowRootMode
| null;
const isShadowRootNeeded = props.style || props.shadow ||
isTemplateShadowRoot;
if (isShadowRootNeeded) {
const shadowRootMode = props.shadowrootmode ?? isTemplateShadowRoot ??
"open" as const;
const shadowRoot = this.attachShadow({ mode: shadowRootMode });
if (props.style) {
const style = create("style");
style.textContent = props.style;
shadowRoot.appendChild(style);
}
if (props.shadow) {
// Set the shadow string inside a template, this is useful
// to make sure we are dealing with fragments from this point
// forward
const tmp = create("template") as HTMLTemplateElement;
tmp.innerHTML = props.shadow;
shadowRoot.appendChild(tmp.content.cloneNode(true));
} else if (isTemplateShadowRoot) {
shadowRoot.appendChild(template.content.cloneNode(true));
}
}
if (template && !isTemplateShadowRoot) {
this.appendChild(template.content.cloneNode(true));
}
if (props.onSlotChange) {
this.traverse((elem) => {
if (!(elem instanceof HTMLSlotElement)) return;
elem.addEventListener("slotchange", (e) => props.onSlotChange?.(e));
}, { traverseShadowRoot: true });
}
// Add the onClick handler if it is set
if (isFunction(props.onClick)) {
this.addEventListener("click", props.onClick);
}
// Add the on* and queriedOn* handlers that might exist
for (const [key, value] of Object.entries(props)) {
// Is this a on*? (i.e. onClick or onMouseMove, etc)
if (isStartsWithOn(key)) {
if (!isFunction(value)) continue;
// Register the handler for the event on this element directly:
this.addEventListener(getEventName(key) as any, value);
} else if (isStartsWithQueriedOn(key)) {
// Is this a queriedOn*? (i.e. queriedOnClick or queriedOnMouseMove, etc)
const queries = value;
if (!isObject(queries)) continue;
const eventName = getEventName(key);
// Go through all the queries, and register the handler for the event in
// all of the nodes that the query returns:
for (const [query, handler] of Object.entries(queries)) {
this.traverse((node) => {
node.addEventListener(eventName, handler);
}, { traverseShadowRoot: true, query });
}
}
}
// Set the attributes provided:
if (props.attributes && Array.isArray(props.attributes)) {
props.attributes.map(([attr, value]) =>
this.setAttribute(attr, value)
);
}
this.#createDispatchers();
// this.#createSlots();
this.isInitialized = true;
}
/**
* User-provided renderer is assigned here by createComponent.
* Called on connect and whenever state triggers subscriptions.
*/
renderCallback = (_: Bored) => {};
connectedCallback() {
if (!this.isInitialized) this.#init();
// else this.#createSlots();
this.renderCallback(this);
props.connectedCallback?.(this);
}
slots = createSlotsAccessor(this);
/*
#createSlots() {
const slots = Array.from(this.querySelectorAll("slot"));
const webComponent = this;
slots.forEach((slot) => {
const slotName = slot.getAttribute("name");
if (!slotName) return;
const camelizedSlotName = camelize(slotName);
Object.defineProperty(webComponent.slots, camelizedSlotName, {
get() {
return webComponent.querySelector(`[data-slot="${slotName}"]`);
},
set(value) {
let elem = value;
if (value instanceof HTMLElement) {
value.setAttribute("data-slot", slotName);
} else if (typeof value === "string") {
elem = create("span");
elem.setAttribute("data-slot", slotName);
elem.innerText = value;
}
const existingSlot = this[camelizedSlotName];
if (existingSlot) {
existingSlot.parentElement.replaceChild(elem, existingSlot);
} else {
slot.parentElement?.replaceChild(elem, slot);
}
},
});
});
}
*/
updateSlot(
slotName: string,
content: HTMLElement | HTMLElement[],
withinTag: string,
) {
const container = document.createElement(withinTag);
container.setAttribute("slot", slotName);
}
/*
#createProperties() {
const elementsFound = document.evaluate(
"//*[contains(text(),'this.')]",
document,
null,
XPathResult.ORDERED_NODE_ITERATOR_TYPE,
null,
);
let element = null;
while (element = elementsFound.iterateNext()) {
console.log("Found ", element);
}
}
*/
disconnectedCallback() {
console.log("disconnected " + this.tagName);
props.disconnectedCallback?.(this);
}
adoptedCallback() {
console.log("adopted " + this.tagName);
props.adoptedCallback?.(this);
}
attributeChangedCallback(
name: string,
oldValue: string,
newValue: string,
) {
if (!props.attributeChangedCallback) return;
props.attributeChangedCallback[name]({
element: this,
name,
oldValue,
newValue,
});
}
},
);
};