diff --git a/eslint.config.mjs b/eslint.config.mjs index a23a60b..2986fb2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,5 @@ export default tseslint.config( } }, ...tseslint.configs.strict, - ...tseslint.configs.stylistic, jsdoc.configs['flat/recommended-typescript'] ); \ No newline at end of file diff --git a/src/abstractComponent.ts b/src/abstractComponent.ts new file mode 100644 index 0000000..c6fdba0 --- /dev/null +++ b/src/abstractComponent.ts @@ -0,0 +1,30 @@ +import { ComponentParams, CUSTOM_COMPONENT_CONFIG, LinkedProperty } from "./component.type"; + +/** Basic class used to declare web components */ +export class AbstractComponent extends HTMLElement { + + /** List of all the class members linked to a dom element */ + public __linkedProperties?: LinkedProperty[] + /** All the component's params */ + public [CUSTOM_COMPONENT_CONFIG]?: ComponentParams; + + public connectedCallback(): void { + this.init(); + this.refresh(); + } + + public disconnectedCallback(): void { } + + /** + * Function called in the component's life cycle + * Used to init the DOM and the sub components + */ + public init(): void { } + + /** + * Function called in the component's life cycle + * Used to refresh the datas, and re render the component + * Please be conscious that if you clean THIS as a dom element, the template won't be re rendered again + */ + public refresh(): void { } +} \ No newline at end of file diff --git a/src/component.tools.ts b/src/component.tools.ts new file mode 100644 index 0000000..c417cdd --- /dev/null +++ b/src/component.tools.ts @@ -0,0 +1,108 @@ +import { AbstractComponent } from "./abstractComponent"; +import { ComponentParams, CUSTOM_COMPONENT_CONFIG } from "./component.type"; +import { findRefElement, parseModuleToHtml } from "./dom.tools"; + +// Declare a unique key to store the flag on the prototype. +// This symbol is used to uniquely identify and access the DOM link flag, +// ensuring there are no conflicts with other properties on the prototype. +export const REQUEST_DOM_LINK_KEY = Symbol("requestDomLink"); + +interface ConstructorFunction { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new(...args: any[]): T +} + +/** + * Define a custom web component using a decorator. + * The decorator configures the component's template, styles, and any additional + * properties, as well as handling Shadow DOM attachment if specified. + * @param p @see {@link ComponentParams} + * @returns a constructor factory + */ +export function CustomComponent>(p: ComponentParams): (constructor: T) => void { + return (constructor: T): void => { + + // Create a template element to contain the component's HTML structure, parsed for use. + const templateElement = document.createElement('template'); + templateElement.innerHTML = parseModuleToHtml(p.template); + + // Attach component configuration as a non-writable property on the prototype + Object.defineProperty(constructor.prototype, CUSTOM_COMPONENT_CONFIG, { + value: p + }); + + // Save the original connectedCallback, if it exists, to allow for extension. + const originalConnectedCallback = constructor.prototype.connectedCallback; + + // Define a custom connectedCallback for the component + constructor.prototype.connectedCallback = function (this: AbstractComponent) { + + // If Shadow DOM is specified, attach it and add the template to it + if (p.attachToShadow) { + // Determine Shadow DOM mode, defaulting to 'open' if boolean true is given. + const mode: ShadowRootMode = typeof p.attachToShadow === "boolean" ? "open" : p.attachToShadow; + + // Attach a Shadow DOM if not already attached + if (!this.shadowRoot) { + this.attachShadow({ mode }); + } + + // Append the template content to the Shadow DOM + if (this.shadowRoot) { + this.shadowRoot.appendChild(templateElement.content.cloneNode(true)); + } + } else { + // If not using Shadow DOM, insert template content directly into Light DOM + this.innerHTML = ""; + this.appendChild(templateElement.content.cloneNode(true)); + } + + // Apply any specified CSS classes to the component root element + this.classList.add(...p.cssClasses); + + // Process and link properties specified in __linkedProperties to their DOM elements + for (const requested of this.__linkedProperties ?? []) { + const elt = findRefElement(requested.ref); + if (elt == null) { + throw new Error(`ref ${requested.ref} not found`); + } + // Attach the found element as a property on the component instance + Object.defineProperty(this, requested.property, { value: elt }); + } + + // Call the original connectedCallback if it exists + if (originalConnectedCallback) { + originalConnectedCallback.call(this); + } + + }; + + // Register the custom element with the specified tag name + window.customElements.define(p.tagName, constructor); + }; +} + + +/** + * Decorator that flags the class member as a link to a dom element + * @param ref + */ +export function LinkToDom(ref: string) { + return function (target: T, key: string | symbol): void { + // Si target est indéfini, renvoyer un avertissement + if (!target) { + throw new Error(`LinkToDom: 'target' is undefined for property ${String(key)}`); + } + + // Ajouter un flag `request` pour marquer cette propriété comme liée au DOM + if (!target.__linkedProperties) { + target.__linkedProperties = []; + } + + // Ajouter la propriété à la liste des propriétés liées + target.__linkedProperties.push({ + property: String(key), + ref + }); + } +} \ No newline at end of file diff --git a/src/component.type.ts b/src/component.type.ts new file mode 100644 index 0000000..db95128 --- /dev/null +++ b/src/component.type.ts @@ -0,0 +1,28 @@ + +export const CUSTOM_COMPONENT_CONFIG = "__customComponentConfig" + +/** Abstract component params*/ +export interface ComponentParams { + /** + * A tag name that will be used to register the component as a custom element + * Should always follow the following format "my-name" + * ex: "custom-menu-component" + */ + tagName: string; + /** HTML Template */ + template?: string; + /** Specific style of the component */ + style?: string; + /** Css classes to append */ + cssClasses?: string[]; + /** Define if the component should be atatched to the shadow dom */ + attachToShadow?: boolean | ShadowRootMode +} + +/** Map a property key to a html element by it's ref attribute */ +export interface LinkedProperty { + /** The ref attribute value */ + ref: string; + /** The object's property */ + property: string; +} \ No newline at end of file diff --git a/src/dom.tools.ts b/src/dom.tools.ts new file mode 100644 index 0000000..b370e36 --- /dev/null +++ b/src/dom.tools.ts @@ -0,0 +1,25 @@ + +/** The attriute used to bind dom to component's members */ +const REF_ATTRIBUTE = "ref" + +/** + * Find an element with the attribute ref= in the given parent (or the entire body) + * @param ref the ref attribute's value of the target dom element + * @param parent the container used to search the element. document.body by default + * @returns the founded html element or undefined + */ +export function findRefElement(ref: string, parent?: HTMLElement): HTMLElement | undefined { + const parentElement = parent ?? document.body; + const elt = parentElement.querySelector(`[${REF_ATTRIBUTE}=${ref}]`); + + return elt == null ? undefined : elt as HTMLElement +} + +/** + * Parse the value of ComponentParams.template into a string if it was fetched with required function + * @param mod the template value + * @returns the template as a string + */ +export function parseModuleToHtml(mod: any): string { + return "default" in mod ? mod.default as string : ""; +} \ No newline at end of file diff --git a/src/dom.types.ts b/src/dom.types.ts new file mode 100644 index 0000000..762ee17 --- /dev/null +++ b/src/dom.types.ts @@ -0,0 +1,2 @@ +/** Anything that could be append in html*/ +export type AcceptedHTMLContent = HTMLElement | string diff --git a/src/index.ts b/src/index.ts index ad21821..f200e12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,6 @@ -console.log("hello world") \ No newline at end of file +export { AbstractComponent } from "./abstractComponent"; +export { CustomComponent, LinkToDom } from "./component.tools"; +export { ComponentParams, LinkedProperty } from "./component.type"; +export { findRefElement } from "./dom.tools"; +export { AcceptedHTMLContent } from "./dom.types"; +