UNPKG

@mr_hugo/boredom

Version:

Another boring JavaScript framework.

650 lines (594 loc) 21.2 kB
/** * 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, }); } }, ); };