UNPKG

@jadis/core

Version:

Jadis is a minimal JavaScript library for building web interfaces using native Web Components.

553 lines (542 loc) 19.1 kB
'use strict'; /** * A helper function to assert conditions in the code. * It throws an error if the condition is not met. * @param condition The condition to check * @param message The error message to throw if the condition is not met * @throws Will throw an error if the condition is false */ function assert(condition, message) { if (!condition) { throw new Error(message); } } /** * Converts a string to kebab-case. * This function replaces uppercase letters with their lowercase equivalents, * prefixing them with a hyphen if they are not at the start of the string. * @example * toKebabCase('myVariableName'); // 'my-variable-name' * toKebabCase('MyVariableName'); // 'my-variable-name' * @param str The input string * @returns The kebab-cased string */ const toKebabCase = (str) => { return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase()); }; function createElement(tag, attributes = {}, appendTo) { const el = document.createElement(tag); Object.entries(attributes).forEach(([key, value]) => el.setAttribute(toKebabCase(key), value)); appendTo?.appendChild(el); return el; } /** * Base class for all Jadis components. * It provides a structure for creating web components with a shadow DOM, * event handling, and attribute management. */ class Jadis extends HTMLElement { static selector; static template = ''; static observedAttributes = []; shadowRoot; attributesCallback = {}; _abortController = new AbortController(); _isConnected = false; constructor() { super(); this.shadowRoot = this.attachShadow({ mode: 'open' }); this.shadowRoot.appendChild(this.buildTemplate()); } /** * Creates a new instance of the component. * @param attributes The attributes to set on the component * @param appendTo The element to append the component to * @returns The created component instance */ static createElement(attributes = {}, appendTo) { return createElement(this.selector, attributes, appendTo); } /** * Registers the component as a custom element. * This method should be called once to define the custom element in the browser. * It checks if the selector is defined and if the custom element is not already registered. * @throws Will throw an error if the selector is not defined for the component */ static register() { assert(this.selector, `selector is not defined for ${this.name}`); if (!customElements.get(this.typeOfClass.selector)) { customElements.define(this.typeOfClass.selector, this.typeOfClass); } } /** * Checks if the component is connected to the DOM. * @returns True if the component is connected, false otherwise */ get isConnected() { return this._isConnected; } connectedCallback() { this._isConnected = true; setTimeout(() => this.onConnect?.()); } disconnectedCallback() { this._abortController.abort(); this.onDisconnect?.(); } attributeChangedCallback(name, oldValue, newValue) { this.attributesCallback[name]?.(newValue, oldValue); } /** * Returns the AbortSignal associated with this component. * This signal can be used to cancel ongoing operations or event listeners. * @returns The AbortSignal for this component */ get killSignal() { return this._abortController.signal; } /** * Retrieves an element from the component's template. * @param query The query string to find the element * @returns The found element * @throws Will throw an error if the element is not found */ getElement(query) { const el = query .split('>>>') .reduce((nextEl, nextQuery) => { return (nextEl.shadowRoot ?? nextEl).querySelector(nextQuery); }, this); assert(el, `${query} element is not reachable`); return el; } /** * Toggles a class on the component based on a condition. * If the condition is true, the class will be added; if false, it will be removed. * @param className The name of the class to toggle * @param condition The binary condition to determine whether to add or remove the class */ toggleClass(className, condition) { condition ? this.classList.add(className) : this.classList.remove(className); } /** * Registers a callback for a specific event on a bus. * @param bus The event bus to register the callback on * @param event The event key to listen for * @param callback The callback to invoke when the event is emitted */ onBus(bus, event, callback) { bus.register(event, callback, this.killSignal); } /** * Creates a handler for events on the component. * This handler allows registering and emitting events in a type-safe manner. * @template EventType The type of events to handle * @returns An object with methods to register and emit events * @example * // Typescript usage: * const events = this.useEvents<{ someEvent: string }>(); * events.register('someEvent', (detail) => { * console.log('Event detail:', detail); * }); * events.emit('someEvent', 'Hello World'); * * // Javascript usage: * const events = this.useEvents({someEvent: String}); * events.register('someEvent', (detail) => { * console.log('Event detail:', detail); * }); * events.emit('someEvent', 'Hello World'); */ useEvents(_schema) { return { register: (event, callback) => { const listener = ({ detail, }) => callback(detail); this.addEventListener(event, listener, { signal: this.killSignal, }); }, emit: (event, ...params) => { this.dispatchEvent(new CustomEvent(event, { detail: params[0] })); }, }; } /** * Registers a callback for a specific event on an element. * @param element The element to listen for events on * @param event The event key to listen for * @param callback The callback to invoke when the event is emitted */ on(element, event, callback) { element.addEventListener(event, callback, { signal: this.killSignal, }); } buildTemplate() { const style = document.createElement('style'); style.textContent = this.templateCss?.() ?? ''; const fragment = document.createDocumentFragment(); fragment.appendChild(style); const htmlContent = this.templateHtml?.(); if (htmlContent) { fragment.appendChild(htmlContent); } return fragment; } static get typeOfClass() { return this.prototype.constructor; } get typeOfConstructor() { return this.constructor; } } const ROUTER_PARAMETER_PREFIX = ':'; const defaultOptions = { mode: 'history', baseUrl: '/', }; /** * Router class for managing navigation and routing in a web application. * It supports both hash and history modes for navigation. * It allows defining routes, navigating to them, and mounting components based on the current URL. */ class Router { _routes = []; _mode; _baseUrl; _parametersRegexp = new RegExp(`${ROUTER_PARAMETER_PREFIX}\\w+`, 'g'); _mount; _currentRoute; constructor(options) { this._mode = options?.mode ?? defaultOptions.mode; this._baseUrl = options?.baseUrl ?? defaultOptions.baseUrl; window.addEventListener(this.eventName, (evt) => { evt.preventDefault(); this.onUrlChange(); }); } get config() { return { mode: this._mode, baseUrl: this._baseUrl, }; } /** * Gets the current route. * @throws Will throw an error if no route is found * @returns The current route */ get currentRoute() { assert(this._currentRoute, 'No route found'); return this._currentRoute; } /** * Adds a new route to the router. * @param path The path of the route * @param componentSelector The selector of the component to mount for this route * @param name An optional name for the route * @example * router.addRoute('/home', 'home-component', 'home'); * @returns this */ addRoute(path, componentSelector, name) { const pathWithoutParameters = path.replace(this._parametersRegexp, '(.+)'); this._routes.push({ name, path, componentSelector, regexp: new RegExp(`^${pathWithoutParameters}$`), }); return this; } /** * Adds a group of routes defined in a RouteGroup. * This allows for organizing routes under a common prefix. * @param routeGroup The RouteGroup containing routes to add * @example * const group = RouteGroup.create('/api') * .addRoute('/users', 'user-list') * .addRoute('/users/:id', 'user-detail'); * router.addGroup(group); * @returns this */ addGroup(routeGroup) { routeGroup .getRoutes() .forEach(({ path, componentSelector, name }) => this.addRoute(path, componentSelector, name)); return this; } /** * Mounts the router on a specific HTML element. * @param el The element to mount the router on */ mountOn(el) { this._mount = el; this.onUrlChange(); } /** * Navigates to a route by its name. * @param name The name of the route to navigate to * @param params The parameters to include with the route */ gotoName(name, params) { const route = this.getRouteByName(name); assert(route, `No route found for name: ${name}`); this.gotoPath(this.formatPath(route.path, params)); } /** * Navigates to a route by its path. * @param path The path of the route to navigate to */ gotoPath(path) { const urlPath = this._mode === 'hash' ? `#${path}` : path; window.history.pushState({}, '', `${this.baseUrl}/${urlPath}`.replace(/\/{2,}/g, '/')); this.onUrlChange(); } get baseUrl() { return this._baseUrl.endsWith('/') ? this._baseUrl.slice(0, -1) : this._baseUrl; } get mountPoint() { assert(this._mount, 'No mount point defined'); return this._mount; } get eventName() { return this._mode === 'hash' ? 'hashchange' : 'popstate'; } get currentUrlPath() { const formattedPath = window.location.pathname.startsWith(this.baseUrl) ? window.location.pathname.slice(this.baseUrl.length) : window.location.pathname; const path = this._mode === 'hash' ? window.location.hash.slice(1) : formattedPath; return path.startsWith('/') ? path : `/${path}`; } onUrlChange() { const urlPath = this.currentUrlPath; const matchedRoute = this.getRouteByPath(urlPath); assert(matchedRoute, `No route found for path: ${urlPath}`); this._currentRoute = matchedRoute; const component = this.getComponentToLoad({ ...matchedRoute, urlPath }); this.mountPoint.replaceChildren(component); } formatPath(routePath, params = {}) { return this.extractPathParams(routePath).reduce((acc, param) => { assert(params.hasOwnProperty(param), `Missing parameter "${param}" for path: ${routePath}`); return acc.replace(`${ROUTER_PARAMETER_PREFIX}${param}`, params[param]); }, routePath); } getComponentToLoad(matchedRoute) { const { componentSelector } = matchedRoute; const params = this.getRouteParameters(matchedRoute); return createElement(componentSelector, params); } getRouteParameters(matchedRoute) { const { urlPath, regexp, path } = matchedRoute; const match = regexp.exec(urlPath); assert(match, `No match found for path: ${urlPath}`); return this.extractPathParams(path).reduce((acc, param, index) => { return { ...acc, [param]: match[index + 1] }; }, {}); } getRouteByName(name) { return (this._routes.find(({ name: routeName }) => routeName === name) ?? null); } getRouteByPath(path) { return this._routes.find(({ regexp }) => regexp.test(path)) ?? null; } extractPathParams(path) { return path.split('/').reduce((acc, part) => { return part.startsWith(ROUTER_PARAMETER_PREFIX) ? [...acc, part.slice(ROUTER_PARAMETER_PREFIX.length)] : acc; }, []); } } class RouteGroup { _routes = []; routePrefix; namePrefix; constructor(routePrefix, namePrefix) { this.routePrefix = routePrefix; this.namePrefix = namePrefix ?? ''; } /** * Creates a new RouteGroup instance. * @param routePrefix The prefix for the route paths. * @param namePrefix The prefix for the route names. * @returns A new RouteGroup instance. */ static create(routePrefix, namePrefix) { const prefixed = routePrefix.startsWith('/') ? routePrefix : `/${routePrefix}`; const suffixed = prefixed.endsWith('/') ? prefixed : `${prefixed}/`; return new RouteGroup(suffixed, namePrefix); } getRoutes() { return this._routes; } /** * Adds a route to the group. * @param path The path of the route. * @param componentSelector The component selector for the route. * @param name The name of the route. * @returns The current RouteGroup instance. */ addRoute(path, componentSelector, name) { this._routes.push({ componentSelector, path: `${this.routePrefix}${path.startsWith('/') ? path.slice(1) : path}`, name: name ? `${this.namePrefix}${name}` : undefined, }); return this; } /** * Adds a group of routes to the current group. * @param group The route group to add. * @returns The current RouteGroup instance. */ addGroup(group) { group.getRoutes().forEach(({ path, componentSelector, name }) => { this.addRoute(path, componentSelector, name); }); return this; } } /** * A helper for creating HTML templates using tagged template literals. * It allows for easy creation of HTML structures with interpolation. * @example * const template = html` * <div class="my-class"> * <p>${content}</p> * </div> * `; * @returns A DocumentFragment containing the HTML structure * @throws Will throw an error if the template contains invalid HTML. */ function html(strings, ...values) { const { markup, markers } = htmlMarkup(strings, ...values); const templateEl = createElement('template'); templateEl.innerHTML = markup; const content = templateEl.content; const walker = document.createTreeWalker(content, NodeFilter.SHOW_COMMENT); const updates = []; while (walker.nextNode()) { const node = walker.currentNode; const match = markers[node.nodeValue?.trim() ?? '']; if (match && node.parentNode) { updates.push({ target: node, replacement: match }); } } updates.forEach(({ target, replacement }) => { target.parentNode?.replaceChild(replacement, target); }); return content; } /** * A helper for creating CSS styles using tagged template literals. * It allows for easy creation of CSS styles with interpolation. * @example * const styles = css` * .my-class { * color: red; * } * `; * @returns The concatenated CSS string */ const css = (strings, ...args) => { return strings.reduce((acc, curr, index) => `${acc}${curr}${args[index] ?? ''}`, ''); }; function createMarker(node, k1) { const key = `marker-${k1}`; return Array.isArray(node) ? node.reduce((acc, n, k2) => ({ markers: { ...acc.markers, [`${key}-${k2}`]: n }, markup: `${acc.markup}<!--${key}-${k2}-->`, }), { markup: '', markers: {} }) : { markers: { [key]: node }, markup: `<!--${key}-->`, }; } function htmlMarkup(strings, ...values) { if (typeof strings === 'string') { return { markup: strings, markers: {} }; } return strings.reduce((acc, str, k1) => { const val = values[k1]; if (val instanceof Node || Array.isArray(val)) { const { markers, markup } = createMarker(val, k1); return { markup: `${acc.markup}${str}${markup}`, markers: { ...acc.markers, ...markers }, }; } return { markup: `${acc.markup}${str}${String(val ?? '')}`, markers: acc.markers, }; }, { markup: '', markers: {} }); } /** * A bus for handling events in a type-safe manner. * It allows registering and emitting events with specific types. */ class Bus { domElement = document.createElement('div'); constructor(_schema) { // only for typing } /** * Registers a callback for a specific event. * @param event The event key to listen for * @param callback The callback to invoke when the event is emitted * @param signal The AbortSignal to cancel the listener */ register(event, callback, signal) { const listener = ({ detail }) => callback(detail); this.domElement.addEventListener(event, listener, { signal }); } /** * Emits an event on the bus. * @param event The event key to emit * @param params The parameters to include with the event */ emit(event, ...params) { this.domElement.dispatchEvent(new CustomEvent(event, { detail: params[0] })); } } /** * Checks if a string is a valid component selector. * @param key The string to check * @returns True if the string is a valid component selector, false otherwise */ function isComponentSelector(key) { return key.includes('-'); } /** * Creates a component selector from a string. * @param name The name of the component. It must contain a hyphen. * @throws Will throw an error if the name does not contain a hyphen. * @returns The component selector */ const createSelector = (name) => { assert(isComponentSelector(name), `Custom element name must contain a hyphen: ${name}`); return name; }; exports.Bus = Bus; exports.Jadis = Jadis; exports.RouteGroup = RouteGroup; exports.Router = Router; exports.assert = assert; exports.createElement = createElement; exports.createSelector = createSelector; exports.css = css; exports.html = html; exports.isComponentSelector = isComponentSelector; exports.toKebabCase = toKebabCase; //# sourceMappingURL=index.js.map