diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..240635e86 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,35 @@ +{ + "extends": "@nodutilus", + "env": { + "browser": true, + "node": false + }, + "overrides": [ + { + "files": [ + "./test/**" + ], + "env": { + "browser": true, + "node": true + } + }, + { + "files": [ + "./webtest/index.js" + ], + "env": { + "browser": false, + "node": true + } + } + ], + "ignorePatterns": [ + "/_deprecated/", + "/build/", + "/@notml/notml/check-compatible.min.js", + "/@notml/notml/core.js", + "/@notml/notml/core.min.js", + "*.d.ts" + ] +} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..95e42354d --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,15 @@ +name: Checks ➜ Tests ➜ Publish + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + main: + uses: nodutilus/project-actions/.github/workflows/main.yml@main + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 67045665d..a8c9f7d75 100644 --- a/.gitignore +++ b/.gitignore @@ -2,103 +2,19 @@ logs *.log npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - # Dependency directories node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history # Output of 'npm pack' *.tgz -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Next.js build output -.next - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and *not* Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port +# Generate output +/coverage +/build +/@notml/notml/check-compatible.min.js +/@notml/notml/core.js +/@notml/notml/core.min.js diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..971311444 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save=false +package-lock=false diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 000000000..f185ff860 --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,13 @@ +{ + "extends": "@nodutilus/project-config/nyc", + "include": [ + "@notml", + "test" + ], + "exclude": [ + "@notml/notml/**", + "test/pre-test.js", + "test/mem.js", + "**/*.d.ts" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..9d4c049ff --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "configurations": [{ + "type": "node", + "request": "launch", + "name": "Current file", + "skipFiles": [ + "/**" + ], + "program": "${file}" + }, + { + "type": "node", + "request": "launch", + "name": "Tests", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/test/index.js" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..ca6425790 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "editor.tabSize": 2, + "editor.rulers": [ + 110 + ], + "editor.detectIndentation": false, + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 3000, + "files.trimTrailingWhitespace": true, + "files.eol": "\n", + "files.insertFinalNewline": true, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "[jsonс]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "javascript.validate.enable": false +} diff --git a/@notml/core/README.md b/@notml/core/README.md new file mode 100644 index 000000000..3577ecba5 --- /dev/null +++ b/@notml/core/README.md @@ -0,0 +1,11 @@ +# NotML Core [![npm][npmbadge]][npm] [![build][badge]][actions] + +Not a HTML - is object-oriented modeling of HTML and CSS + +[npmbadge]: https://img.shields.io/npm/v/@notml/core?label=@notml/core + +[npm]: https://www.npmjs.com/package/@notml/core + +[badge]: https://github.com/nodutilus/notml/actions/workflows/main.yml/badge.svg + +[actions]: https://github.com/nodutilus/notml/actions diff --git a/@notml/core/core.js b/@notml/core/core.js new file mode 100644 index 000000000..fbd53c418 --- /dev/null +++ b/@notml/core/core.js @@ -0,0 +1,20 @@ +import { OOMElement } from './lib/factory.js' +import { extendsCustomElement, defineCustomElement } from './lib/custom-elements.js' + +const oomOrigin = Object.assign(Object.create(null), { + extends: extendsCustomElement, + define: defineCustomElement +}) + + +/** @type {import('@notml/core').OOMProxy} */ +export const oom = oomOrigin.oom = new Proxy(OOMElement, { + /** @type {import('@notml/core').OOMProxy.apply} */ + apply: (_, __, args) => { + return OOMElement.createProxy(args) + }, + get: (_, tagName, proxy) => { + return oomOrigin[tagName] || ((...args) => proxy(tagName, ...args)) + }, + set: () => false +}) diff --git a/@notml/core/lib/custom-elements.js b/@notml/core/lib/custom-elements.js new file mode 100644 index 000000000..2f794c17f --- /dev/null +++ b/@notml/core/lib/custom-elements.js @@ -0,0 +1,202 @@ +import { OOMElement } from './factory.js' +import { OOMStyle } from './style.js' + +const oomElementRedySymbol = Symbol('oomElementRedySymbol') +const { document, DocumentFragment, HTMLElement, HTMLHeadElement, customElements, ShadowRoot } = window +const oomCustomElementMap = new WeakMap() +const shadowRootOOMStyleMap = new WeakMap() +const optionsDefaultsGlobals = Object.freeze({}) +const extendsTagNameMap = new Map() + + +/** @type {import('@notml/core').CustomElement.applyOOMTemplate} */ +function applyOOMTemplate(instance) { + const { attachShadow } = instance.constructor + let { template } = instance + const rootNode = instance.getRootNode() + /** @type {import('@notml/core').CustomElement | ShadowRoot} */ + let root = instance + + if (attachShadow) { + root = instance.attachShadow(typeof attachShadow === 'object' ? attachShadow : { mode: 'open' }) + } + + // Клонирование стилей компонента во внутрь ShadowRoot + if (rootNode instanceof ShadowRoot) { + const { style } = instance.constructor + + if (style instanceof OOMElement && style.dom instanceof OOMStyle) { + let styleSet = shadowRootOOMStyleMap.get(rootNode) + + if (!styleSet) { + shadowRootOOMStyleMap.set(rootNode, (styleSet = new WeakSet())) + } + if (!styleSet.has(instance.constructor)) { + /** @type {HTMLElement} */ + // @ts-ignore + let head = rootNode.firstChild + + if (!(head instanceof HTMLHeadElement)) { + head = document.createElement('head') + rootNode.prepend(head) + } + + head.append(style.clone().dom) + styleSet.add(instance.constructor) + } + } + } + + // Построение верстки компонента произвольным методом + // Вернет void, если функция выполнила вставку дочерних элементов + if (typeof instance.template === 'function' && !(template instanceof OOMElement)) { + // @ts-ignore - проверка на instanceof OOMElement откидывает все Proxy типы + template = instance.template(root) + } + + if (template instanceof Promise) { + instance[OOMElement.async] = template.then(asyncTemplate => { + if (asyncTemplate instanceof OOMElement) { + root.append(asyncTemplate.dom) + } else if (asyncTemplate instanceof HTMLElement || asyncTemplate instanceof DocumentFragment) { + root.append(asyncTemplate) + } else if (typeof asyncTemplate === 'string') { + root.innerHTML += asyncTemplate + } + }).catch(error => { + const err = document.createElement('code') + + err.textContent = String(error.stack || error) + root.append(err) + }) + } else { + if (template instanceof OOMElement) { + root.append(template.dom) + } else if (template instanceof HTMLElement || template instanceof DocumentFragment) { + root.append(template) + } else if (typeof template === 'string') { + root.innerHTML += template + } + } +} + + +/** @type {import('@notml/core').CustomElement.resolveOptions } */ +function resolveOptions(target, source, prevSources = new WeakSet()) { + let result + + if (source && typeof source === 'object') { + // Только простые объекты и массивы подвергаем копированию и заморозке, + // чтобы не сломать логику встроенных и пользовательских классов + if (source.constructor === Object || source.constructor === Array) { + // Для защиты от бесконечной рекурсии заменяем цикличные ссылки на undefined + if (prevSources.has(source)) { + return undefined + } + prevSources.add(source) + result = Object.assign(source instanceof Array ? [] : {}, target, source) + for (const key in result) { + result[key] = resolveOptions(null, result[key], prevSources) + } + result = Object.freeze(result) + } else { + result = source + } + } else { + result = typeof source === 'undefined' ? target : source + } + + return result +} + + +/** @type {import('@notml/core').CustomElement.extendsCustomElement} */ +function extendsCustomElement(CustomElement, optionsDefaults) { + if (oomCustomElementMap.has(CustomElement) && typeof optionsDefaults === 'undefined') { + return oomCustomElementMap.get(CustomElement) + } else { + /** @type {import('@notml/core').CustomElement} */ + class OOMCustomElement extends CustomElement { + + /** Создание элемента по шаблону при вставке в DOM */ + connectedCallback() { + if (super.connectedCallback) { + super.connectedCallback() + } + if (!this[oomElementRedySymbol]) { + this[oomElementRedySymbol] = false + applyOOMTemplate(this) + } + } + + /** @type {import('@notml/core').CustomElement.constructor} */ + constructor( + /** @type {import('@notml/core').CustomElement.Options} */ + options + ) { + if (options && Object.isFrozen(options)) { + super(options) + } else { + options = resolveOptions(OOMCustomElement.optionsDefaults, options) + super(options) + if (extendsTagNameMap.has(this.constructor)) { + // Атрибут "is" предназначен для хранения имени тега пользовательского элемента, + // но он не заполняется при создании элементов из JS, поэтому проставляем его принудительно + this.setAttribute('is', extendsTagNameMap.get(this.constructor)) + } + } + if (!Reflect.has(this, 'options')) { + Object.defineProperty(this, 'options', { + value: options, + writable: false + }) + } + if ('className' in this.constructor) { + this.className = this.constructor.className + } + } + + } + + Object.defineProperty(OOMCustomElement, 'optionsDefaults', { + value: resolveOptions(CustomElement.optionsDefaults, optionsDefaults) || optionsDefaultsGlobals, + writable: false + }) + + if (typeof optionsDefaults === 'undefined') { + oomCustomElementMap.set(CustomElement, OOMCustomElement) + } + + return OOMCustomElement + } +} + + +/** @type {import('@notml/core').CustomElement.defineCustomElement} */ +function defineCustomElement(...oomCustomElements) { + for (const CustomElement of oomCustomElements) { + const { tagName } = CustomElement + + customElements.define(tagName, CustomElement, { extends: CustomElement.extendsTagName }) + if (CustomElement.extendsTagName) { + extendsTagNameMap.set(CustomElement, tagName) + } + if (CustomElement.style instanceof OOMElement && CustomElement.style.dom instanceof OOMStyle) { + if (CustomElement.extendsTagName) { + CustomElement.style(`${CustomElement.extendsTagName}[is="${tagName}"]`) + } else { + CustomElement.style(tagName) + } + CustomElement.style.dom.setAttribute('oom-element', tagName) + document.head.append(CustomElement.style.dom) + } + } + + return oomCustomElements +} + + +export { + extendsCustomElement, + defineCustomElement +} diff --git a/@notml/core/lib/factory.js b/@notml/core/lib/factory.js new file mode 100644 index 000000000..b944a3778 --- /dev/null +++ b/@notml/core/lib/factory.js @@ -0,0 +1,298 @@ +import { OOMStyle } from './style.js' + +const { document, customElements, DocumentFragment, Element, HTMLElement, HTMLTemplateElement } = window +const isOOMElementSymbol = Symbol('isOOMElement') +const proxiesMap = new WeakMap() +/** @type {import('@notml/core').base.OOMProxyConstructor} */ +const OOMProxyConstructor = Proxy + +/** @type {import('@notml/core').OOMElement} */ +class OOMElement { + + /** @type {import('@notml/core').OOMElement.createProxy} */ + static createProxy( + /** @type {import('@notml/core').OOMElement.OOMElementArgs} */ + args + ) { + const wrapper = /* c8 ignore next */ () => { } + const proxy = new OOMProxyConstructor(wrapper, OOMElement.proxyHandler) + + wrapper.instance = new OOMElement(...args) + proxiesMap.set(wrapper.instance, proxy) + + return proxy + } + + /** @type {import('@notml/core').OOMElement.proxyApply} */ + static proxyApply( + /** @type {import('@notml/core').OOMElement.OOMElementWrapper} */ + { instance }, /** @type {any} */_, + /** @type {import('@notml/core').OOMElement.ProxyApplyArgs} */ + args + ) { + const proxy = proxiesMap.get(instance) + + if (instance.dom instanceof OOMStyle) { + // @ts-ignore вызываем независимо от аргументов, чтобы упало стандартное исключение из DOM API + instance.dom.update(...args) + } else { + for (const arg of args) { + const isChild = + arg instanceof OOMElement || + arg instanceof HTMLElement || + arg instanceof DocumentFragment || + typeof arg !== 'object' || !arg || + arg.constructor !== Object + + // Чтобы поймать стандартное исключение DOM API, игнорируем проверку на произвольные типы данных + if (isChild) { + // @ts-ignore независимо от типа добавляемого элемента вызовем вставку + instance.append(arg) + } else { + // @ts-ignore независимо от типа аргументов вызываем установку атрибутов + OOMElement.setAttributes(instance.dom, arg) + } + } + } + + return proxy + } + + /** @type {import('@notml/core').OOMElement.proxyGetter} */ + static proxyGetter( + /** @type {import('@notml/core').OOMElement.OOMElementWrapper} */ + { instance }, + /** @type {import('@notml/core').OOMElement.TagName} */ + tagName, + /** @type {import('@notml/core').OOMElementProxy} */ + proxy + ) { + if (tagName in instance) { + if (tagName === 'then') { + if (instance.dom[OOMElement.async] instanceof Promise) { + return async (/** @type {Function} */resolve) => { + await instance.then() + resolve(proxy) + } + } else { + return null + } + } else if (typeof instance[tagName] === 'function') { + return (...args) => { + const result = instance[tagName](...args) + + return result === instance ? proxy : result + } + } else { + return instance[tagName] + } + } else { + return (...args) => { + if (instance.dom instanceof DocumentFragment) { + instance.append(new OOMElement(tagName, ...args)) + } else { + proxy = OOMElement.createProxy([ + document.createDocumentFragment(), + proxy.dom, + new OOMElement(tagName, ...args) + ]) + } + + return proxy + } + } + } + + /** @type {import('@notml/core').OOMElement.hasInstance} */ + static [Symbol.hasInstance]( + /** @type {import('@notml/core').OOMElementProxy} */ + instance + ) { + // @ts-ignore https://github.com/microsoft/TypeScript/pull/44512 + return instance && instance[isOOMElementSymbol] === true + } + + /** @type {import('@notml/core').OOMElement.setAttribute} */ + static setAttribute( + /** @type {import('@notml/core').CustomElement} */ + instance, + /** @type {import('@notml/core').OOMElement.AttributeName} */ + attrName, + /** @type {import('@notml/core').OOMElement.OOMAttributeValue} */ + attrValue + ) { + switch (typeof attrValue) { + case 'object': + if (attrName === 'style') { + for (const name in attrValue) { + instance.style[name] = attrValue[name] + } + } + break + case 'function': + instance[attrName] = attrValue + break + case 'boolean': + // Пустая строка по умолчанию для логических атрибутов: + // https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute + attrName = attrName.replace(/[A-Z]+/g, str => `-${str.toLowerCase()}`) + if (attrValue) { + // Для защиты от подмены поведения на экземпляре, используем базовый метод + Element.prototype.setAttribute.call(instance, attrName, '') + } else { + // Для защиты от подмены поведения на экземпляре, используем базовый метод + Element.prototype.removeAttribute.call(instance, attrName) + } + break + default: + attrName = attrName.replace(/[A-Z]+/g, str => `-${str.toLowerCase()}`) + if (attrName === 'class' && 'className' in instance.constructor) { + attrValue = instance.constructor.className + ' ' + attrValue + } + if (attrName === 'inner-html') { + instance.innerHTML = attrValue + break + } + // Для защиты от подмены поведения на экземпляре, используем базовый метод + Element.prototype.setAttribute.call(instance, attrName, attrValue) + break + } + } + + /** @type {import('@notml/core').OOMElement.setAttributes} */ + static setAttributes( + /** @type {import('@notml/core').CustomElement} */ + instance, + /** @type {import('@notml/core').OOMElement.OOMAttributes} */ + attributes = {} + ) { + for (const [attrName, attrValue] of Object.entries(attributes)) { + OOMElement.setAttribute(instance, attrName, attrValue) + } + } + + /** @type {import('@notml/core').OOMElement.getAttribute} */ + static getAttribute( + /** @type {HTMLElement} */ + instance, + /** @type {import('@notml/core').OOMElement.AttributeName} */ + attrName + ) { + if (typeof instance[attrName] === 'function') { + return instance[attrName] + } + if ((/[A-Z]/).test(attrName)) { + attrName = attrName.replace(/[A-Z]/g, str => `-${str.toLowerCase()}`) + } + if (attrName === 'style') { + return instance.style + } else { + return instance.getAttribute(attrName) + } + } + + /** @type {import('@notml/core').OOMElement.isOOMElementSymbol} */ + [isOOMElementSymbol] = true + + /** @type {import('@notml/core').OOMElement.DOMElement} */ + dom + + /** @type {import('@notml/core').OOMElement.HTML} */ + get html() { + const { dom } = this + let html = '' + + if (dom instanceof DocumentFragment) { + for (const item of Array.from(dom.childNodes)) { + if (item instanceof HTMLElement) { + html += item.outerHTML + } else if (item.nodeType === document.TEXT_NODE) { + html += item.textContent + } + } + } else { + html = dom.outerHTML + } + + return html + } + + /** @type {import('@notml/core').OOMElement.async} */ + static async = Symbol('asyncOOMElement') + + /** @type {import('@notml/core').OOMElement.then} */ + async then() { + await this.dom[OOMElement.async] + delete this.dom[OOMElement.async] + } + + /** @type {import('@notml/core').OOMElement.constructor} */ + constructor( + /** @type {import('@notml/core').OOMElement.OOMTagName} */ + tagName, + /** @type {import('@notml/core').OOMElement.ProxyApplyArgs} */ + ...args + ) { + if (typeof tagName === 'string') { + tagName = tagName.replace((/[A-Z]+/g), str => `-${str.toLowerCase()}`) + if (tagName === 'style') { + tagName = 'oom-style' + } + + const Constructor = customElements.get(tagName) + + if (Constructor) { + this.dom = new Constructor() + } else { + this.dom = document.createElement(tagName) + } + } else if (tagName instanceof HTMLElement || tagName instanceof DocumentFragment) { + this.dom = tagName + /* eslint-disable-next-line no-prototype-builtins */ + } else if (HTMLElement.isPrototypeOf(tagName) || DocumentFragment.isPrototypeOf(tagName)) { + /* eslint-disable-next-line new-cap */ + this.dom = new tagName() + } else if (typeof tagName === 'undefined') { + this.dom = document.createDocumentFragment() + } else if (tagName instanceof OOMElement) { + this.dom = tagName.dom + } else { + // @ts-ignore В остальных случаях createElement вернет собственную ошибку создания элемента + this.dom = document.createElement(tagName) + } + OOMElement.proxyApply({ instance: this }, null, args) + } + + /** @type {import('@notml/core').OOMElement.append} */ + append(/** @type {any} */child) { + let parent = this.dom + + if (parent instanceof HTMLTemplateElement) { + parent = parent.content + } + if (child instanceof OOMElement) { + parent.append(child.dom) + } else if (typeof child !== 'undefined') { + parent.append(child) + } + + return this + } + + /** @type {import('@notml/core').OOMElement.clone} */ + clone() { + const dom = document.importNode(this.dom, true) + + return OOMElement.createProxy([dom]) + } + +} + +/** @type {import('@notml/core').OOMElement.proxyHandler} */ +OOMElement.proxyHandler = { + apply: OOMElement.proxyApply, + get: OOMElement.proxyGetter, + set: () => false +} + +export { OOMElement } diff --git a/@notml/core/lib/style.js b/@notml/core/lib/style.js new file mode 100644 index 000000000..1fc069e82 --- /dev/null +++ b/@notml/core/lib/style.js @@ -0,0 +1,94 @@ +const { document, customElements, HTMLStyleElement } = window +/** @type {WeakMap} */ +const privateOOMStyleMap = new WeakMap() + +/** @type {import('@notml/core').OOMStyle} */ +class OOMStyle extends HTMLStyleElement { + + /** @type {import('@notml/core').OOMStyle.updateStyle} */ + static updateStyle( + /** @type {import('@notml/core').OOMStyle.Style} */ + style, + /** @type {import('@notml/core').OOMStyle.StyleName} */ + styleName, + /** @type {import('@notml/core').OOMStyle.StyleSource} */ + source + ) { + let styleCollection = style.get(styleName) + + for (const [propName, propValue] of Object.entries(source)) { + if (propValue && typeof propValue === 'object' && propValue.constructor === Object) { + // Рекурсивно разворачиваем вложенное описание стилей в плоский список + OOMStyle.updateStyle(style, styleName ? `${styleName} ${propName}` : propName, propValue) + } else { + if (!styleCollection) { + styleCollection = document.createElement('div') + style.set(styleName, styleCollection) + } + if (propName in styleCollection.style) { + styleCollection.style[propName] = propValue + } else { + // @ts-ignore Игнорируем типы, метод приводит любые значения к строке + styleCollection.style.setProperty(propName, propValue) + } + } + } + } + + /** Добавляем инициализацию приватных данных класса */ + constructor() { + const privateSlots = { + scopeName: '', + style: new Map() + } + + super() + privateOOMStyleMap.set(this, privateSlots) + } + + /** @type {import('@notml/core').OOMStyle.update} */ + update( + /** @type {import('@notml/core').OOMStyle.ScopeName | import('@notml/core').OOMStyle.StyleSource} */ + scopeName, + /** @type {Array} */ + ...styles + ) { + const privateSlots = privateOOMStyleMap.get(this) + + if (typeof scopeName === 'string') { + privateSlots.scopeName = scopeName + } else if (typeof scopeName !== 'undefined') { + styles.unshift(scopeName) + } + for (const style of styles) { + OOMStyle.updateStyle(privateSlots.style, '', style) + } + } + + /** @type {import('@notml/core').OOMStyle.connectedCallback} */ + connectedCallback() { + const privateSlots = privateOOMStyleMap.get(this) + + if (privateSlots.style.size) { + let textStyle = '' + + for (const [name, style] of privateSlots.style) { + const selector = (privateSlots.scopeName && name.startsWith(privateSlots.scopeName) && name) || + (privateSlots.scopeName && name && `${privateSlots.scopeName} ${name}`) || + privateSlots.scopeName || name || '*' + + textStyle += `${selector}{ ${style.getAttribute('style') || ''} }` + } + + this.innerHTML = textStyle + privateSlots.style.clear() + } + } + +} + + +customElements.define('oom-style', OOMStyle, { extends: 'style' }) + + +export { OOMStyle } diff --git a/@notml/core/package.json b/@notml/core/package.json new file mode 100644 index 000000000..2ca0766fc --- /dev/null +++ b/@notml/core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@notml/core", + "version": "0.1.0-pre.15", + "description": "Not a HTML - is object-oriented modeling of HTML and CSS", + "keywords": [ + "web-components", + "custom-elements", + "html-in-js", + "css-in-js" + ], + "license": "Unlicense", + "repository": "github:nodutilus/notml", + "type": "module", + "engines": { + "node": ">=16" + }, + "files": [ + "lib", + "core.js", + "types.d.ts" + ], + "exports": { + ".": "./core.js" + }, + "module": "./core.js", + "types": "./types.d.ts", + "publishConfig": { + "access": "public" + } +} diff --git a/@notml/core/types.d.ts b/@notml/core/types.d.ts new file mode 100644 index 000000000..bf8fe9ccb --- /dev/null +++ b/@notml/core/types.d.ts @@ -0,0 +1,1324 @@ +declare module '@notml/core' { + + namespace base { + /** Набор ловушек для создания прокси-объекта для OOMElement с уточненным типом ключей */ + interface OOMProxyHandler extends ProxyHandler { + get?(target: T, name: K, receiver: U): U[K] + set?(target: T, name: K, value: U[K], receiver: U): boolean + } + + /** Расширенный конструктор для создания прокси-объекта для OOMElement */ + interface OOMProxyConstructor extends ProxyConstructor { + new (target: T, handler: OOMProxyHandler): U + } + + /** + * Модифицированный CSSStyleDeclaration из typescript/lib/lib.dom.d.ts (typescript@4.3.5) + * с необязательным определением атрибутов для использования в OOMAttributeValue + */ + interface CSSStyleDeclaration { + alignContent?: string + alignItems?: string + alignSelf?: string + alignmentBaseline?: string + all?: string + animation?: string + animationDelay?: string + animationDirection?: string + animationDuration?: string + animationFillMode?: string + animationIterationCount?: string + animationName?: string + animationPlayState?: string + animationTimingFunction?: string + backfaceVisibility?: string + background?: string + backgroundAttachment?: string + backgroundClip?: string + backgroundColor?: string + backgroundImage?: string + backgroundOrigin?: string + backgroundPosition?: string + backgroundPositionX?: string + backgroundPositionY?: string + backgroundRepeat?: string + backgroundSize?: string + baselineShift?: string + blockSize?: string + border?: string + borderBlockEnd?: string + borderBlockEndColor?: string + borderBlockEndStyle?: string + borderBlockEndWidth?: string + borderBlockStart?: string + borderBlockStartColor?: string + borderBlockStartStyle?: string + borderBlockStartWidth?: string + borderBottom?: string + borderBottomColor?: string + borderBottomLeftRadius?: string + borderBottomRightRadius?: string + borderBottomStyle?: string + borderBottomWidth?: string + borderCollapse?: string + borderColor?: string + borderImage?: string + borderImageOutset?: string + borderImageRepeat?: string + borderImageSlice?: string + borderImageSource?: string + borderImageWidth?: string + borderInlineEnd?: string + borderInlineEndColor?: string + borderInlineEndStyle?: string + borderInlineEndWidth?: string + borderInlineStart?: string + borderInlineStartColor?: string + borderInlineStartStyle?: string + borderInlineStartWidth?: string + borderLeft?: string + borderLeftColor?: string + borderLeftStyle?: string + borderLeftWidth?: string + borderRadius?: string + borderRight?: string + borderRightColor?: string + borderRightStyle?: string + borderRightWidth?: string + borderSpacing?: string + borderStyle?: string + borderTop?: string + borderTopColor?: string + borderTopLeftRadius?: string + borderTopRightRadius?: string + borderTopStyle?: string + borderTopWidth?: string + borderWidth?: string + bottom?: string + boxShadow?: string + boxSizing?: string + breakAfter?: string + breakBefore?: string + breakInside?: string + captionSide?: string + caretColor?: string + clear?: string + clip?: string + clipPath?: string + clipRule?: string + color?: string + colorInterpolation?: string + colorInterpolationFilters?: string + columnCount?: string + columnFill?: string + columnGap?: string + columnRule?: string + columnRuleColor?: string + columnRuleStyle?: string + columnRuleWidth?: string + columnSpan?: string + columnWidth?: string + columns?: string + content?: string + counterIncrement?: string + counterReset?: string + cssFloat?: string + cssText?: string + cursor?: string + direction?: string + display?: string + dominantBaseline?: string + emptyCells?: string + fill?: string + fillOpacity?: string + fillRule?: string + filter?: string + flex?: string + flexBasis?: string + flexDirection?: string + flexFlow?: string + flexGrow?: string + flexShrink?: string + flexWrap?: string + float?: string + floodColor?: string + floodOpacity?: string + font?: string + fontFamily?: string + fontFeatureSettings?: string + fontKerning?: string + fontSize?: string + fontSizeAdjust?: string + fontStretch?: string + fontStyle?: string + fontSynthesis?: string + fontVariant?: string + fontVariantCaps?: string + fontVariantEastAsian?: string + fontVariantLigatures?: string + fontVariantNumeric?: string + fontVariantPosition?: string + fontWeight?: string + gap?: string + glyphOrientationVertical?: string + grid?: string + gridArea?: string + gridAutoColumns?: string + gridAutoFlow?: string + gridAutoRows?: string + gridColumn?: string + gridColumnEnd?: string + gridColumnGap?: string + gridColumnStart?: string + gridGap?: string + gridRow?: string + gridRowEnd?: string + gridRowGap?: string + gridRowStart?: string + gridTemplate?: string + gridTemplateAreas?: string + gridTemplateColumns?: string + gridTemplateRows?: string + height?: string + hyphens?: string + imageOrientation?: string + imageRendering?: string + inlineSize?: string + justifyContent?: string + justifyItems?: string + justifySelf?: string + left?: string + letterSpacing?: string + lightingColor?: string + lineBreak?: string + lineHeight?: string + listStyle?: string + listStyleImage?: string + listStylePosition?: string + listStyleType?: string + margin?: string + marginBlockEnd?: string + marginBlockStart?: string + marginBottom?: string + marginInlineEnd?: string + marginInlineStart?: string + marginLeft?: string + marginRight?: string + marginTop?: string + marker?: string + markerEnd?: string + markerMid?: string + markerStart?: string + mask?: string + maskComposite?: string + maskImage?: string + maskPosition?: string + maskRepeat?: string + maskSize?: string + maskType?: string + maxBlockSize?: string + maxHeight?: string + maxInlineSize?: string + maxWidth?: string + minBlockSize?: string + minHeight?: string + minInlineSize?: string + minWidth?: string + objectFit?: string + objectPosition?: string + opacity?: string + order?: string + orphans?: string + outline?: string + outlineColor?: string + outlineOffset?: string + outlineStyle?: string + outlineWidth?: string + overflow?: string + overflowAnchor?: string + overflowWrap?: string + overflowX?: string + overflowY?: string + overscrollBehavior?: string + overscrollBehaviorBlock?: string + overscrollBehaviorInline?: string + overscrollBehaviorX?: string + overscrollBehaviorY?: string + padding?: string + paddingBlockEnd?: string + paddingBlockStart?: string + paddingBottom?: string + paddingInlineEnd?: string + paddingInlineStart?: string + paddingLeft?: string + paddingRight?: string + paddingTop?: string + pageBreakAfter?: string + pageBreakBefore?: string + pageBreakInside?: string + paintOrder?: string + perspective?: string + perspectiveOrigin?: string + placeContent?: string + placeItems?: string + placeSelf?: string + pointerEvents?: string + position?: string + quotes?: string + resize?: string + right?: string + rotate?: string + rowGap?: string + rubyAlign?: string + rubyPosition?: string + scale?: string + scrollBehavior?: string + shapeRendering?: string + stopColor?: string + stopOpacity?: string + stroke?: string + strokeDasharray?: string + strokeDashoffset?: string + strokeLinecap?: string + strokeLinejoin?: string + strokeMiterlimit?: string + strokeOpacity?: string + strokeWidth?: string + tabSize?: string + tableLayout?: string + textAlign?: string + textAlignLast?: string + textAnchor?: string + textCombineUpright?: string + textDecoration?: string + textDecorationColor?: string + textDecorationLine?: string + textDecorationStyle?: string + textEmphasis?: string + textEmphasisColor?: string + textEmphasisPosition?: string + textEmphasisStyle?: string + textIndent?: string + textJustify?: string + textOrientation?: string + textOverflow?: string + textRendering?: string + textShadow?: string + textTransform?: string + textUnderlinePosition?: string + top?: string + touchAction?: string + transform?: string + transformBox?: string + transformOrigin?: string + transformStyle?: string + transition?: string + transitionDelay?: string + transitionDuration?: string + transitionProperty?: string + transitionTimingFunction?: string + translate?: string + unicodeBidi?: string + userSelect?: string + verticalAlign?: string + visibility?: string + /** @deprecated */ + webkitAlignContent?: string + /** @deprecated */ + webkitAlignItems?: string + /** @deprecated */ + webkitAlignSelf?: string + /** @deprecated */ + webkitAnimation?: string + /** @deprecated */ + webkitAnimationDelay?: string + /** @deprecated */ + webkitAnimationDirection?: string + /** @deprecated */ + webkitAnimationDuration?: string + /** @deprecated */ + webkitAnimationFillMode?: string + /** @deprecated */ + webkitAnimationIterationCount?: string + /** @deprecated */ + webkitAnimationName?: string + /** @deprecated */ + webkitAnimationPlayState?: string + /** @deprecated */ + webkitAnimationTimingFunction?: string + /** @deprecated */ + webkitAppearance?: string + /** @deprecated */ + webkitBackfaceVisibility?: string + /** @deprecated */ + webkitBackgroundClip?: string + /** @deprecated */ + webkitBackgroundOrigin?: string + /** @deprecated */ + webkitBackgroundSize?: string + /** @deprecated */ + webkitBorderBottomLeftRadius?: string + /** @deprecated */ + webkitBorderBottomRightRadius?: string + /** @deprecated */ + webkitBorderRadius?: string + /** @deprecated */ + webkitBorderTopLeftRadius?: string + /** @deprecated */ + webkitBorderTopRightRadius?: string + /** @deprecated */ + webkitBoxAlign?: string + /** @deprecated */ + webkitBoxFlex?: string + /** @deprecated */ + webkitBoxOrdinalGroup?: string + /** @deprecated */ + webkitBoxOrient?: string + /** @deprecated */ + webkitBoxPack?: string + /** @deprecated */ + webkitBoxShadow?: string + /** @deprecated */ + webkitBoxSizing?: string + /** @deprecated */ + webkitFilter?: string + /** @deprecated */ + webkitFlex?: string + /** @deprecated */ + webkitFlexBasis?: string + /** @deprecated */ + webkitFlexDirection?: string + /** @deprecated */ + webkitFlexFlow?: string + /** @deprecated */ + webkitFlexGrow?: string + /** @deprecated */ + webkitFlexShrink?: string + /** @deprecated */ + webkitFlexWrap?: string + /** @deprecated */ + webkitJustifyContent?: string + webkitLineClamp?: string + /** @deprecated */ + webkitMask?: string + /** @deprecated */ + webkitMaskBoxImage?: string + /** @deprecated */ + webkitMaskBoxImageOutset?: string + /** @deprecated */ + webkitMaskBoxImageRepeat?: string + /** @deprecated */ + webkitMaskBoxImageSlice?: string + /** @deprecated */ + webkitMaskBoxImageSource?: string + /** @deprecated */ + webkitMaskBoxImageWidth?: string + /** @deprecated */ + webkitMaskClip?: string + /** @deprecated */ + webkitMaskComposite?: string + /** @deprecated */ + webkitMaskImage?: string + /** @deprecated */ + webkitMaskOrigin?: string + /** @deprecated */ + webkitMaskPosition?: string + /** @deprecated */ + webkitMaskRepeat?: string + /** @deprecated */ + webkitMaskSize?: string + /** @deprecated */ + webkitOrder?: string + /** @deprecated */ + webkitPerspective?: string + /** @deprecated */ + webkitPerspectiveOrigin?: string + webkitTapHighlightColor?: string + /** @deprecated */ + webkitTextFillColor?: string + /** @deprecated */ + webkitTextSizeAdjust?: string + /** @deprecated */ + webkitTextStroke?: string + /** @deprecated */ + webkitTextStrokeColor?: string + /** @deprecated */ + webkitTextStrokeWidth?: string + /** @deprecated */ + webkitTransform?: string + /** @deprecated */ + webkitTransformOrigin?: string + /** @deprecated */ + webkitTransformStyle?: string + /** @deprecated */ + webkitTransition?: string + /** @deprecated */ + webkitTransitionDelay?: string + /** @deprecated */ + webkitTransitionDuration?: string + /** @deprecated */ + webkitTransitionProperty?: string + /** @deprecated */ + webkitTransitionTimingFunction?: string + /** @deprecated */ + webkitUserSelect?: string + whiteSpace?: string + widows?: string + width?: string + willChange?: string + wordBreak?: string + wordSpacing?: string + wordWrap?: string + writingMode?: string + zIndex?: string + /** @deprecated */ + zoom?: string + } + + } + + namespace OOMStyle { + + /** + * Коллекция CSS селекторов и заданных для них правил, + * преобразуемых через атрибут style тега div из объектного представления в текстовый. + * Для преобразования используется особенность работы класса CSSStyleDeclaration, + * см. HTMLElement.style (@see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) + */ + type Style = Map + + /** + * CSS селектор для указания правил + */ + type StyleName = string + + /** + * CSS селектор, который будет являться родительским для всех селекторов, описанных в коллекции + */ + type ScopeName = string + + /** + * Объект с правилами CSS определяется в формате CSSStyleDeclaration. + * Может содержать вложенные объекты, преобразуемые в CSS селекторы согласно пути в объекте CSS правил + */ + interface StyleSource extends base.CSSStyleDeclaration { + [x: string]: string | StyleSource + } + + /** Выполняет обновление коллекция CSS селекторов и их правил */ + interface updateStyle { + (style: Style, styleName: StyleName, source: StyleSource): void + } + + /** + * Выполняет обновление коллекция CSS селекторов и их правил. + * С поддержкой указания имени области действия в качестве необязательного 1го аргумента + */ + interface update { + (scopeName?: ScopeName | StyleSource, ...styles: Array): void + } + + /** + * Выполняет обновление содержимого элемента при вставке в DOM. + * Преобразует CSS селекторы из #style в текстовый вид CSS и записывает в innerHTML + */ + interface connectedCallback { + (): any + } + } + + /** Пользовательский элемент, наследуемый от style, для использования CSS-in-JS в шаблонах OOM */ + class OOMStyle extends HTMLStyleElement { + static updateStyle: OOMStyle.updateStyle + #scopeName: OOMStyle.ScopeName + #style: OOMStyle.Style + update: OOMStyle.update + connectedCallback: OOMStyle.connectedCallback + } + + namespace OOMElement { + + /** Имя тега DOM элемента для создания элемента */ + type TagName = string + + /** + * Имя тега DOM элемента для создания элемента, сам DOM элемент, + * или его функция конструктор, на основе которого будет создан OOM элемент + */ + type OOMTagName = DocumentFragment | HTMLElement | string | typeof DocumentFragment | typeof HTMLElement + + /** Имя атрибута DOM элемента */ + type AttributeName = string + + /** Поддерживаемые значения атрибутов для OOMElement */ + type OOMAttributeValue = string | boolean | Function | base.CSSStyleDeclaration + + /** Справочник атрибутов для OOMElement */ + type OOMAttributes = { + [x: string]: OOMAttributeValue + /** CSS стили DOM элемента */ + style?: base.CSSStyleDeclaration | string + /** HTML разметка для вставки в DOM элемент */ + innerHTML?: string + /** HTML разметка для вставки в DOM элемент */ + ['inner-html']?: string + } + + /** Экземпляр элемента для вставки */ + type OOMChild = string | Node | DocumentFragment | HTMLElement | OOMElement | OOMFragmentProxy | OOMElementProxy | OOMTemplateProxy | OOMStyleProxy + + /** Функция-шаблон для генерации пользовательского компонента */ + interface TemplateFN { + ( + /** + * Для случаев когда компонент имеет теневой DOM, + * this будет самим компонентом, а root корнем теневого DOM + */ + root: CustomElement | ShadowRoot + ): Promise | OOMElement.OOMChild | void + } + + /** + * Аргументы вызова OOMElementProxy элемента - объекты с атрибутами элемента, или вложенные элементы. + * Типы аргументов можно комбинировать в 1-ом вызове + */ + type ProxyApplyArgs = Array + + /** Аргументы для конструктора OOMElement */ + type OOMElementArgs = [OOMTagName, ...ProxyApplyArgs] + + /** + * Обертка для OOMElement, и сам элемент в instance. + * Объявляется как функция для корректной работы Proxy и хука apply. + */ + type OOMElementWrapper = { + (): void + instance: OOMElement + } | { instance: OOMElement } + + /** Создание внешнего Proxy для работы с OOM элементом */ + interface createProxy { + (args: OOMElementArgs): OOMElementProxy + } + + /** + * Обновление атрибутов или добавление вложенных элементов для OOMElement, + * через перехват apply внешнего Proxy. + * Поведение выбирается в зависимости от переданного типа аргументов + */ + interface proxyApply { + (wrapper: OOMElementWrapper, _: any, args: ProxyApplyArgs): OOMElementProxy + } + + /** + * Перехват обращений к свойствам OOM элемента. + * Методы и свойства объявленные в HTMLElement обеспечивают API взаимодействия с элементом. + * Остальные обращения, используя цепочки вызовов, создают OOM элементы на одном уровне используя DocumentFragment. + * Вернет метод или свойство из OOM элемента или фабрику для генерации DocumentFragment + */ + interface proxyGetter { + (wrapper: OOMElementWrapper, tagName: TagName, proxy: OOMElementProxy): any + } + + /** Набор ловушек для создания OOMElementProxy */ + interface proxyHandler { + apply: proxyApply + get: proxyGetter + set: () => boolean + } + + /** Проверка на соответствие экземпляру OOMElement, в т.ч. обернутый в Proxy */ + interface hasInstance { + ( + /** Экземпляр класса для проверки на соответствие OOMElement */ + instance: OOMElementProxy + ): boolean + } + + /** + * Установка атрибута элемента. + * Позволяет задавать методы, стили в виде объекта, и строковые атрибуты DOM элемента. + */ + interface setAttribute { + (instance: HTMLElement, attrName: AttributeName, attrValue: OOMAttributeValue): void + } + + /** + * Установка атрибутов элемента. + * Работает аналогично setAttribute, но обновляет сразу несколько атрибутов + */ + interface setAttributes { + (instance: HTMLElement, attributes: OOMAttributes): void + } + + /** + * Получение атрибута элемента. + * Возвращает значение аналогичное логике установки атрибутов в setAttribute + */ + interface getAttribute { + (instance: HTMLElement, attrName: AttributeName): OOMAttributeValue + } + + type IsOOMElementSymbol = symbol + + /** + * Свойство экземпляра для проверки на соответствие классу OOMElement, + * позволяющее выполнить instanceof для Proxy-объекта через метод класса Symbol.hasInstance + */ + type isOOMElementSymbol = boolean + + /** + * Экземпляр DOM элемента, которым управляет OOM элемент + */ + type DOMElement = DocumentFragment | HTMLElement | CustomElement + + /** + * HTML код DOM элемента, привязанного к OOMElement + */ + type HTML = string + + /** + * Ключ ссылки на Promise ожидающий завершения построения асинхронного компонента + */ + type async = symbol + + /** + * Заглушка для возможности вернуть OOMElementProxy из асинхронного метода, + * в противном случае Promise не завершается и код перестает исполняться + * Вернет функция для обработки Promise, + * для асинхронных компонентов вернет Promise для ожидания построения шаблона, + * для синхронных вернет null + */ + type then = () => Promise + + /** + * Создает экземпляр OOMElement по переданному тегу DOM или классу пользовательского элемента, + * либо оборачивает в OOMElement существующий DOM элемент + */ + interface constructor { + (tagName: OOMElement.OOMTagName, ...args: OOMElement.ProxyApplyArgs): OOMElement + } + + /** + * Добавление дочернего элемента для OOMElement в конец списка элементов. + * Вернет замыкание на самого себя для использования чейнинга + */ + interface append { + (child: OOMChild): OOMElement + } + + /** + * Клонирует DOM элемент и возвращает новый экземпляр OOM, содержащий копию DOM элемента + */ + interface clone { + (): OOMElementProxy + } + + } + + /** Базовый класс для OOM элементов */ + class OOMElement { + static createProxy: OOMElement.createProxy + static proxyApply: OOMElement.proxyApply + static proxyGetter: OOMElement.proxyGetter + static proxyHandler: OOMElement.proxyHandler + static [Symbol.hasInstance]: OOMElement.hasInstance + static setAttribute: OOMElement.setAttribute + static setAttributes: OOMElement.setAttributes + static getAttribute: OOMElement.getAttribute + static async: OOMElement.async + // @ts-ignore https://github.com/microsoft/TypeScript/pull/44512 + [OOMElement.IsOOMElementSymbol]: OOMElement.isOOMElementSymbol + /** Ссылка на оригинальный DOM элемент */ + dom: OOMElement.DOMElement + /** HTML код элемента, аналогично HTMLElement.outerHTML, но работает и для DocumentFragment */ + html: OOMElement.HTML + /** Заглушка для возможности вернуть OOMElementProxy из асинхронного метода */ + then: OOMElement.then + constructor(tagName: OOMElement.OOMTagName, ...args: OOMElement.ProxyApplyArgs) + append: OOMElement.append + clone: OOMElement.clone + } + + namespace CustomElement { + + /** Базовый класс элемента OOM, который будет расширяться пользователем */ + interface CustomElementClsBase { + + /** Объект с опциями компонента по умолчанию */ + readonly optionsDefaults: CustomElement.Options + + new(options?: Options): CustomElement + + } + + /** Класс пользовательского элемента, расширенный для работы с компонентами OOM */ + interface CustomElementCls extends CustomElementClsBase { + + /** Имя тега для регистрации пользовательского элемента */ + tagName: string + + /** + * Встроенное имя класса компонента, + * устанавливается в конструкторе и сохраняется при обновлении атрибута class через oom шаблонизатор + */ + className?: string + + /** + * Имя тега встроенного DOM элемента который расширяется данным классом + * @see customElements.define~options.extends {@link https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#syntax} + */ + extendsTagName?: string + + /** + * Коллекция CSS правил, описывающих стиль пользовательского компонента через oom.style. + * При регистрации компонента через oom.define добавляться в документ в секцию , + * автоматически добавляя для стилей имя области действия соответствующее имени тега элемента. + */ + style?: OOMStyleProxy + + } + + /** Опции пользовательского компонента */ + type Options = { + readonly [K in keyof T]?: T[K] + } + + /** + * Объединяет опции по умолчанию с опциями пользователя, + * возвращая копию защищенную от изменения (только для базовых объектов и массивов). + * Экземпляры пользовательских и сложных встроенных классов передаются по ссылке. + */ + interface resolveOptions { + ( + /** Исходный справочник опций по умолчанию */ + target: object, + /** Пользовательский справочник опций */ + source: object, + /** + * Список обработанных объектов пользовательского справочника, + * используемый для выявления рекурсий. + */ + prevSources?: WeakSet + ): object + } + + /** + * Создает экземпляр CustomElement + */ + interface constructor { + /** Имя тега для регистрации пользовательского элемента */ + tagName: string + /** + * Встроенное имя класса компонента, + * устанавливается в конструкторе и сохраняется при обновлении атрибута class через oom шаблонизатор + */ + className?: string + /** Включает создание теневого DOM внутри компонента */ + attachShadow?: boolean | ShadowRootInit + /** + * Коллекция CSS правил, описывающих стиль пользовательского компонента через oom.style. + */ + style?: OOMStyleProxy + (options: Options): CustomElement + } + + /** + * Применение OOM шаблона пользовательского элемента + */ + interface applyOOMTemplate { + (instance: CustomElement): void + } + + /** + * Расширение пользовательского элемента возможностями OOM шаблонизатора. + * Возвращает новый класс наследуемый от указанного базового или пользовательского класса элемента, + * от которого можно наследовать класс нового элемента с поддержкой OOM + */ + interface extendsCustomElement { + (CustomElement: CustomElementCls, optionsDefaults?: CustomElement.Options): CustomElementCls + } + + /** + * Регистрирует переданный набор классов пользовательских элементов в customElements.define. + * В качестве тега используется имя класса или `static tagName` + */ + interface defineCustomElement { + (...oomCustomElements: Array>): Array> + } + + } + + /** Экземпляр пользовательского DOM элемента, расширенный для работы с компонентами OOM */ + class CustomElement extends HTMLElement { + + /** Имя тега для регистрации пользовательского элемента */ + static tagName: string + + /** + * Встроенное имя класса компонента, + * устанавливается в конструкторе и сохраняется при обновлении атрибута class через oom шаблонизатор + */ + static className?: string + + /** + * Имя тега встроенного DOM элемента который расширяется данным классом + * @see customElements.define~options.extends {@link https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#syntax} + */ + static extendsTagName?: string + + /** Включает создание теневого DOM внутри компонента */ + static attachShadow?: boolean | ShadowRootInit + + /** + * Коллекция CSS правил, описывающих стиль пользовательского компонента через oom.style. + * При регистрации компонента через oom.define добавляться в документ в секцию , + * автоматически добавляя для стилей имя области действия соответствующее имени тега элемента. + */ + static style?: OOMStyleProxy + + /** Объект с опциями компонента по умолчанию */ + static readonly optionsDefaults: CustomElement.Options + + /** Объект с опциями пользовательского компонента */ + readonly options: CustomElement.Options + + /** + * Создает экземпляр CustomElement + */// @ts-ignore + get constructor(): CustomElement.constructor + + /** + * Содержимое пользовательского элемента, которое будет добавлено в его состав + * в момент вставки пользовательского компонента в состав документа + */ + template?: Promise | OOMElement.OOMChild | OOMElement.TemplateFN | void + + /** + * Ссылка на асинхронное состояние готовности компонента. + * void для синхронных компонентов, Promise, если template является асинхронной функцией + */ + // @ts-ignore https://github.com/microsoft/TypeScript/pull/44512 + [OOMElement.async]: Promise | void + + /** Хук ЖЦ элемента срабатывающий при вставке элемента в DOM */ + connectedCallback(): void + + } + + namespace OOMProxy { + + /** + * Создает новый OOM элемент согласно имени запрошенного атрибута, + * затем добавляет его в конец списка дочерних элементов + * + * @example + * oom.div({ class: 'header' }, ...childs) + * + * >> + *
+ * ...childs + *
+ */ + interface createElementProxy { + (...args: Array): OOMElementProxy + } + /** + * Создает OOM элемент, добавляя его в верстку после текущего, + * и возвращает новый фрагмент документа содержащий оба элемента + * + * @example + * const span = oom.span({ class: 'title' }, 'Link: ') + * const fragment = span.a({ href: 'https://test.ok' }, 'test.ok') + * + * document.body.append(span.dom) + * + * >> + * Link: + * test.ok + */ + interface createElementToFragmentProxy { + (...args: Array): OOMFragmentProxy + } + /** + * Создает и добавляет к фрагменту документа еще один OOM элемент + * + * @example + * const fragment = oom().span({ class: 'title' }, 'Link: ') + * + * fragment.a({ href: 'https://test.ok' }, 'test.ok') + * + * document.body.append(component.dom) + * + * >> + * Link: + * test.ok + */ + interface createFragmentProxy { + (...args: Array): OOMFragmentProxy + } + + /** Создает новый OOM элемент и оборачивает его в Proxy */ + interface apply { + (_: any, __: any, args: OOMElement.OOMElementArgs): OOMElementProxy + } + + interface CommonOrigin { + + /** + * Создает экземпляр пользовательского элемента OOMStyle генерирующий таблицу селекторов и их правил. + * OOMStyle является расширением тега style, + * преобразующий объектное представление CSS в текстовый, и заполняет содержимое style. + * Для преобразования используется особенность работы класса CSSStyleDeclaration, + * см. HTMLElement.style (@see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) + * + * @example + * const style = oom.style({ + * 'fontSize': '10px', + * '.my-class': { background: 'red', fontSize: '12px' } + * }) + * + * document.head.append(style.dom) + * + * >> + * + */ + style: ( + scopeName?: OOMStyle.ScopeName | OOMStyle.StyleSource, + ...styles: Array + ) => OOMStyleProxy + + /** + * Создает элемент шаблона контента - template + * @see https://developer.mozilla.org/ru/docs/Web/HTML/Element/template + * + * @example + * const tmpl = oom.template(oom.div()) + * // Клонируем содержимое шаблона через DOM API + * const elm1 = oom.main(tmpl.dom.content.cloneNode(true)) + * // Клонируем сам шаблон через OOM API + * const elm2 = oom.section(tmpl.clone()) + * + * >> + * elm1.dom.outerHTML === '
' + * elm2.dom.outerHTML === '
' + * tmpl.dom.outerHTML === '' + */ + template: (...args: Array) => OOMTemplateProxy + + /** HTML код элемента, аналогично HTMLElement.outerHTML, но работает и для DocumentFragment */ + html: OOMElement.HTML + + } + + /** Внутренний объект OOMProxy описывающий его базовые методы */ + interface origin extends CommonOrigin { + /** + * Расширение пользовательского элемента возможностями OOM шаблонизатора. + * Возвращает новый класс наследуемый от указанного базового или пользовательского класса элемента, + * от которого можно наследовать класс нового элемента с поддержкой OOM + * + * @example + * const optionsDefaults = { caption: '' } + * + * class MyButton extends oom.extends(HTMLButtonElement, optionsDefaults) { + * static tagName = 'my-butt' + * static extendsTagName = 'button' + * static style = oom.style({ + * 'button[is="my-butt"]': { fontSize: '12px' }, + * 'button[is="my-butt"].active': { color: 'yellow' }, + * '.my-butt__caption': { color: 'red' } + * }) + * template = oom.span({ class: 'my-butt__caption' }, this.options.caption) + * } + * + * oom.define(MyButton) + * + * document.body.append(new MyButton({ caption: 'Жми тут' })) + * + * >> + * + * + * + * + * + * + * + * + */ + extends( + CustomElement: CustomElement.CustomElementClsBase, + optionsDefaults?: CustomElement.Options + ): CustomElement.CustomElementClsBase + extends( + CustomElement: typeof HTMLElement, + optionsDefaults?: CustomElement.Options + ): CustomElement.CustomElementClsBase + + /** + * Регистрирует переданный набор классов пользовательских элементов в customElements.define. + * В качестве тега используется имя класса или `static tagName` + */ + define: CustomElement.defineCustomElement + + a: OOMProxy.createElementProxy + abbr: OOMProxy.createElementProxy + address: OOMProxy.createElementProxy + area: OOMProxy.createElementProxy + article: OOMProxy.createElementProxy + aside: OOMProxy.createElementProxy + audio: OOMProxy.createElementProxy + b: OOMProxy.createElementProxy + blockquote: OOMProxy.createElementProxy + body: OOMProxy.createElementProxy + br: OOMProxy.createElementProxy + button: OOMProxy.createElementProxy + canvas: OOMProxy.createElementProxy + caption: OOMProxy.createElementProxy + cite: OOMProxy.createElementProxy + code: OOMProxy.createElementProxy + col: OOMProxy.createElementProxy + colgroup: OOMProxy.createElementProxy + dialog: OOMProxy.createElementProxy + div: OOMProxy.createElementProxy + footer: OOMProxy.createElementProxy + form: OOMProxy.createElementProxy + h1: OOMProxy.createElementProxy + h2: OOMProxy.createElementProxy + h3: OOMProxy.createElementProxy + h4: OOMProxy.createElementProxy + h5: OOMProxy.createElementProxy + h6: OOMProxy.createElementProxy + head: OOMProxy.createElementProxy + header: OOMProxy.createElementProxy + hr: OOMProxy.createElementProxy + i: OOMProxy.createElementProxy + iframe: OOMProxy.createElementProxy + img: OOMProxy.createElementProxy + input: OOMProxy.createElementProxy + label: OOMProxy.createElementProxy + li: OOMProxy.createElementProxy + main: OOMProxy.createElementProxy + meta: OOMProxy.createElementProxy + nav: OOMProxy.createElementProxy + ol: OOMProxy.createElementProxy + optgroup: OOMProxy.createElementProxy + option: OOMProxy.createElementProxy + p: OOMProxy.createElementProxy + pre: OOMProxy.createElementProxy + progress: OOMProxy.createElementProxy + q: OOMProxy.createElementProxy + s: OOMProxy.createElementProxy + section: OOMProxy.createElementProxy + select: OOMProxy.createElementProxy + span: OOMProxy.createElementProxy + strong: OOMProxy.createElementProxy + sub: OOMProxy.createElementProxy + sup: OOMProxy.createElementProxy + table: OOMProxy.createElementProxy + tbody: OOMProxy.createElementProxy + td: OOMProxy.createElementProxy + textarea: OOMProxy.createElementProxy + tfoot: OOMProxy.createElementProxy + th: OOMProxy.createElementProxy + thead: OOMProxy.createElementProxy + title: OOMProxy.createElementProxy + tr: OOMProxy.createElementProxy + track: OOMProxy.createElementProxy + u: OOMProxy.createElementProxy + ul: OOMProxy.createElementProxy + video: OOMProxy.createElementProxy + } + + interface OOMElementOrigin extends CommonOrigin { + /** + * Добавление дочернего элемента к верстке в конец списка элементов. + * Вернет замыкание на собственный OOMElementProxy для использования чейнинга + * + * @example + * const mySpan = oom.span('My element new text') + * const div = oom('div').append(mySpan) + */ + append(child: OOMElement.OOMChild): OOMElementProxy + /** + * Клонирует элемент и возвращает новый экземпляр OOM, содержащий копию DOM элемента + * + * @example + * const mySpan1 = oom.span('My element new text') + * const mySpan2 = mySpan1.clone() + */ + clone(): OOMElementProxy + dom: T + } + + interface OOMFragmentOrigin extends CommonOrigin { + /** + * Добавление дочернего элемента к верстке в конец списка элементов. + * Вернет замыкание на собственный OOMFragmentProxy для использования чейнинга + * + * @example + * const mySpan = oom.span('My element').span('new text') + * const div = oom('div').append(mySpan) + */ + append(child: OOMElement.OOMChild): OOMFragmentProxy + /** + * Клонирует фрагмент и возвращает новый экземпляр OOM фрагмента, содержащий копию DOM элементов + * + * @example + * const mySpan1 = oom.span('My element').span('new text') + * const mySpan2 = mySpan1.clone() + */ + clone(): OOMFragmentProxy + dom: DocumentFragment + } + + interface OOMStyleOrigin extends CommonOrigin { + clone(): OOMStyleProxy + dom: OOMStyle + } + + } + + /** Proxy для работы с OOM элементом */ + interface OOMElementProxy extends OOMProxy.OOMElementOrigin { + /** + * Выполняет обновление атрибутов текущего элемента, + * а также добавление дочерних элемента к верстке в конец списка элементов. + * + * @example + * const div = oom.div() + * + * div({ class: 'MyClass' }, + * oom.span('My text'), + * oom.span('ok')) + * + * >> + *
+ * My text + * ok + *
+ */ + (...args: Array): OOMElementProxy + // @ts-ignore проверка типа индекса (ts 2411) не подходит, а определения типа "все кроме указанных" нет + [tagName: string]: OOMProxy.createElementToFragmentProxy + } + + /** + * Краткая форма для объявления типа обернутого элемента + * + * @example import('@notml/core').OOM + */ + interface OOM extends OOMElementProxy { } + + /** Proxy для работы с элементом template */ + // @ts-ignore переопределение dom из OOMElementOrigin + interface OOMTemplateProxy extends OOMElementProxy { } + + interface OOMFragmentProxy extends OOMProxy.OOMFragmentOrigin { + /** + * Выполняет добавление дочерних элемента к верстке в конец фрагмента документа. + * + * @example + * const fragment = oom() + * + * fragment(oom.span('My text'), oom.span('ok')) + * + * >> + * My text + * ok + */ + (...args: Array): OOMFragmentProxy + // @ts-ignore проверка типа индекса (ts 2411) не подходит, а определения типа "все кроме указанных" нет + [tagName: string]: OOMProxy.createFragmentProxy + } + + /** Proxy для работы с OOMStyle элементом */ + interface OOMStyleProxy extends OOMProxy.OOMStyleOrigin { + /** + * Выполняет обновление селекторов и их правил в элементе OOMStyle, + * С поддержкой указания имени области действия в качестве необязательного 1го аргумента. + * + * **!ВАЖНО:** Обновление возможно только до вставки элемента в документ, + * т.к. после вставки объектная модель CSS будет очищена + * + * @example + * const style = oom.style({ fontSize: '10px' }) + * + * style({ '.my-class': { background: 'red', fontSize: '12px' } }) + * + * document.body.append(style.dom) + * + * >> + * + */ + ( + scopeName?: OOMStyle.ScopeName | OOMStyle.StyleSource, + ...styles: Array + ): OOMStyleProxy + // @ts-ignore проверка типа индекса (ts 2411) не подходит, а определения типа "все кроме указанных" нет + [tagName: string]: OOMProxy.createElementToFragmentProxy + } + + /** Фабрика Proxy для создания OOM элементов и сопутствующее API */ + interface OOMProxy extends OOMProxy.origin { + /** + * Вернет новый экземпляр Proxy элемента для создания верстки + * + * @example + * const component = oom('div', { class: 'link' }, oom + * .span({ class: 'title' }, 'Link: ') + * .a({ href: 'https://test.ok' }, 'test.ok')) + * + * document.body.append(component.dom) + * + * >> + * + */ + ( + tagName: HTMLElement | string | typeof HTMLElement, + ...args: Array + ): OOMElementProxy + /** + * Вернет новый экземпляр Proxy элемента для создания верстки + * + * @example + * const component = oom() + * .span({ class: 'title' }, 'Link: ') + * .a({ href: 'https://test.ok' }, 'test.ok') + * + * document.body.append(component.dom) + * + * >> + * Link: + * test.ok + */ + ( + ...args: Array + ): OOMFragmentProxy + ( + tagName: DocumentFragment | typeof DocumentFragment, + ...args: Array + ): OOMFragmentProxy + // @ts-ignore проверка типа индекса (ts 2411) не подходит, а определения типа "все кроме указанных" нет + [tagName: string]: OOMProxy.createElementProxy + } + + /** Фабрика Proxy для создания OOM элементов и сопутствующее API */ + export const oom: OOMProxy + +} diff --git a/@notml/notml/README.md b/@notml/notml/README.md new file mode 100644 index 000000000..215b8e1d8 --- /dev/null +++ b/@notml/notml/README.md @@ -0,0 +1,13 @@ +# NotML multi-package [![npm][npmbadge]][npm] [![build][badge]][actions] + +All-in-one NotML package for use via CDN + +Not a HTML - is object-oriented modeling of HTML and CSS + +[npmbadge]: https://img.shields.io/npm/v/notml?label=notml + +[npm]: https://www.npmjs.com/package/notml + +[badge]: https://github.com/nodutilus/notml/actions/workflows/main.yml/badge.svg + +[actions]: https://github.com/nodutilus/notml/actions diff --git a/@notml/notml/check-compatible.js b/@notml/notml/check-compatible.js new file mode 100644 index 000000000..7135a3000 --- /dev/null +++ b/@notml/notml/check-compatible.js @@ -0,0 +1,161 @@ +/** + * Выполняет проверку поддержки браузером функционала используемого библиотекой NotML + * В случае выявления проблемы покажет заглушку на странице + */ +(function compatible() { + /* eslint-disable no-var, no-new-func */ + var customElements = + typeof window.customElements === 'object' && + typeof window.customElements.define === 'function' && + typeof window.HTMLElement === 'function' + var success = true + var messages = [] + + if (!customElements) { + success = false + messages.push('customElements are not supported') + } + + try { + var testFunction1 = new Function( + 'class TestClass {' + + ' get(){};' + + ' set(){};' + + ' test(){};' + + ' b(){};' + + '}' + + 'const a = new TestClass()' + ) + testFunction1() + } catch (error) { + success = false + messages.push('JS classes are not supported') + messages.push(error.message + '\n' + error.stack) + } + + try { + var testFunction2 = new Function( + 'class TestClass {' + + ' static get(){};' + + ' static set(){};' + + ' static test(){};' + + ' get(){};' + + ' set(){};' + + ' test(){};' + + ' b(){};' + + '}' + + 'const a = new TestClass()' + ) + testFunction2() + } catch (error) { + success = false + messages.push('Static methods are not supported in JS classes') + messages.push(error.message + '\n' + error.stack) + } + + try { + var testFunction3 = new Function( + 'class TestClass {' + + ' test(){};' + + ' a = 1;' + + ' b(){};' + + '}' + + 'const a = new TestClass()' + ) + testFunction3() + } catch (error) { + success = false + messages.push('Properties are not supported in JS classes') + messages.push(error.message + '\n' + error.stack) + } + + try { + var testFunction4 = new Function( + 'class TestClass {' + + ' test(){};' + + ' static a = 1;' + + ' a = 1;' + + ' b(){};' + + '}' + + 'const a = new TestClass()' + ) + testFunction4() + } catch (error) { + success = false + messages.push('Static properties are not supported in JS classes') + messages.push(error.message + '\n' + error.stack) + } + + + // Пока не везде поддерживается + // try { + // var testFunction5 = new Function( + // 'class TestClass {' + + // ' static #a = 1;' + + // ' #b = 1;' + + // '}' + + // 'const a = new TestClass()' + // ) + // testFunction5() + // } catch (error) { + // success = false + // messages.push('Private properties are not supported in JS classes') + // messages.push(error.message + '\n' + error.stack) + // } + + // try { + // var testFunction6 = new Function( + // 'class TestClass {' + + // ' #test(){};' + + // '}' + + // 'const a = new TestClass()' + // ) + // testFunction6() + // } catch (error) { + // success = false + // messages.push('Private methods are not supported in JS classes') + // messages.push(error.message + '\n' + error.stack) + // } + + if (!success) { + window['@notml/core:compatibility'] = false + window.document.head.innerHTML = '' + window.onload = function onload() { + window.document.body.innerHTML = + '' + + '
' + + '
' + + '

Your browser or device is outdated and not supported.

' + + '

Browser compatibility or newer: Chrome 74, Firefox 90, Safari 14.1, Safari on iOS 14.5

' + + '

We recommend using the latest version of the Google Chrome browser

' + + '

Install Google Chrome

' + + '

' + + ' ' + + ' ' + + '

' + + '
' + + '
' + /** @type {HTMLElement} */ + var moreMessages = window.document.querySelector('#moreMessages') + /** @type {HTMLElement} */ + var errorMessages = window.document.querySelector('#errorMessages') + + moreMessages.onclick = () => { + moreMessages.style.display = 'none' + errorMessages.innerHTML += 'userAgent: ' + window.navigator.userAgent + '\n\n' + messages.join('\n\n') + errorMessages.style.display = 'inline' + } + } + } +})() diff --git a/@notml/notml/core.d.ts b/@notml/notml/core.d.ts new file mode 100644 index 000000000..bc372565e --- /dev/null +++ b/@notml/notml/core.d.ts @@ -0,0 +1 @@ +export { oom } from '@notml/core' diff --git a/@notml/notml/core.min.d.ts b/@notml/notml/core.min.d.ts new file mode 100644 index 000000000..bc372565e --- /dev/null +++ b/@notml/notml/core.min.d.ts @@ -0,0 +1 @@ +export { oom } from '@notml/core' diff --git a/@notml/notml/package.json b/@notml/notml/package.json new file mode 100644 index 000000000..f8c79b805 --- /dev/null +++ b/@notml/notml/package.json @@ -0,0 +1,32 @@ +{ + "name": "notml", + "version": "0.1.0-pre.17", + "description": "All-in-one NotML package for use via CDN - object-oriented modeling of HTML and CSS", + "keywords": [ + "web-components", + "custom-elements", + "html-in-js", + "css-in-js" + ], + "license": "Unlicense", + "repository": "github:nodutilus/notml", + "type": "module", + "engines": { + "node": ">=16" + }, + "files": [ + "check-compatible.min.js", + "check-compatible.js", + "core.min.js", + "core.js" + ], + "browser": "./core.min.js", + "module": "./core.js", + "scripts": { + "build": "npx rollup --config", + "prepack": "npx rollup --config" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/@notml/notml/rollup.config.js b/@notml/notml/rollup.config.js new file mode 100644 index 000000000..7b4d3f7d3 --- /dev/null +++ b/@notml/notml/rollup.config.js @@ -0,0 +1,28 @@ +import resolve from '@rollup/plugin-node-resolve' +import cleanup from 'rollup-plugin-cleanup' +import { terser } from 'rollup-plugin-terser' + + +export default [{ + input: 'src/core.js', + output: { file: 'core.js', format: 'esm', compact: true }, + plugins: [ + resolve({ browser: true, preferBuiltins: false }), + cleanup({ comments: 'none' }) + ] +}, { + input: 'check-compatible.js', + output: { file: 'check-compatible.min.js', compact: true }, + plugins: [ + cleanup({ comments: 'none' }), + terser() + ] +}, { + input: 'core.js', + output: { file: 'core.min.js', format: 'esm', compact: true }, + plugins: [ + resolve({ browser: true, preferBuiltins: false }), + cleanup({ comments: 'none' }), + terser() + ] +}] diff --git a/@notml/notml/src/core.js b/@notml/notml/src/core.js new file mode 100644 index 000000000..b29e36d99 --- /dev/null +++ b/@notml/notml/src/core.js @@ -0,0 +1 @@ +export { oom } from '../../core/core.js' diff --git a/@notml/ssr/package.json b/@notml/ssr/package.json new file mode 100644 index 000000000..d116597ce --- /dev/null +++ b/@notml/ssr/package.json @@ -0,0 +1,30 @@ +{ + "name": "@notml/ssr", + "version": "0.0.1-pre.1", + "description": "Not a HTML - server-side rendering of HTML and CSS", + "keywords": [ + "ssr", + "server-side-rendering", + "html-in-js", + "css-in-js" + ], + "license": "Unlicense", + "repository": "github:nodutilus/notml", + "type": "module", + "engines": { + "node": ">=16" + }, + "files": [ + "lib", + "ssr.js", + "types.d.ts" + ], + "exports": { + ".": "./ssr.js" + }, + "module": "./ssr.js", + "types": "./types.d.ts", + "publishConfig": { + "access": "public" + } +} diff --git a/README.md b/README.md index a2806db28..1a9a9d937 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,207 @@ -# core -Not a HTML - is object-oriented modeling of HTML +# NotML [![build][badge]][actions] + +Not a HTML - is object-oriented modeling of HTML and CSS + +## Packages + +[![npm][npmbadge_notml_core]][npm_notml_core] +[![npm][npmbadge_notml]][npm_notml] + +## Simple HTML-based + +### Example #1 + +Simple layout. Comparison with HTML. + +##### NotML + +```js +import { oom } from '@notml/core' + +const div = oom('div') + .div({ class: 'header' }) + .div({ style: { borderBottom: '1px solid' } }, oom + .span('Name: ', { class: 'test-label' }) + .span('Test', { class: 'test-name' })) + .div({ class: 'footer' }) +``` + +##### HTML + +```html +
+
+
+ Name: + Test +
+ +
+``` + +##### JavaScript native DOM + +Code executed inside NotML + +```js +const divHeader = document.createElement('div') +const spanName = document.createElement('span') +const spanTest = document.createElement('span') +const divBorder = document.createElement('div') +const divFooter = document.createElement('div') +const domDiv = document.createElement('div') + +divHeader.setAttribute('class', 'header') +spanName.setAttribute('class', 'test-label') +spanTest.setAttribute('class', 'test-name') +divBorder.style.borderBottom = '1px solid' +divFooter.setAttribute('class', 'footer') +spanName.textContent = 'Name: ' +spanTest.textContent = 'Test' + +domDiv.append(divHeader) +divBorder.append(spanName) +divBorder.append(spanTest) +domDiv.append(divBorder) +domDiv.append(divFooter) +``` + +### Example #2 + +Reuse prepared elements + +##### NotML + +```js +const header = oom('div', { class: 'header' }) + .span('Test Header') + +const block = oom + .div(oom + .append(header.clone()) + .div('div 1')) + .div(oom + .append(header.clone()) + .div('div 2')) +``` + +##### HTML + +```html +
+
+ Test Header +
+
div 1
+
+
+
+ Test Header +
+
div 2
+
+``` + +## Based on Custom Elements + +### Example #3 + +Simple `template` + +> A `template` instance is cloned on `connectedCallback`. Handler implemented in `oom.define`. + +##### NotML + +```js +class MyElementExp3 extends HTMLElement { + + mySpan = oom.span('My element new text') + + template = oom('div', { class: 'MyElement__inner' }) + .append(this.mySpan.clone()) + .append(oom('br')) + .append(this.mySpan) + +} + +const block = oom.define(MyElementExp3).MyElementExp3() +``` + +##### HTML + +```html + +
+ My element new text +
+ My element new text +
+
+``` + +### Example #4 + +Reactive data-properties and attributes. (see more: `HTMLElement.dataset`) + +##### NotML + +```js +class MyElementExp4 extends HTMLElement { + + static label = oom('span', { class: 'label' }) + static field = oom('span', { class: 'field' }) + + static template({ element }) { + return oom() + .append(this.label.clone() + .span({ class: 'text' }, label => (element._label = label))) + .append(this.field.clone() + .span({ class: 'text' }, field => (element._field = field))) + } + + /** on 'data-field-text' attribute change */ + dataFieldTextChanged(oldValue, newValue) { + this._field.textContent = newValue + } + + /** on 'data-label-text' attribute change */ + dataLabelTextChanged(oldValue, newValue) { + this._label.textContent = newValue + } + +} + +oom.define(MyElementExp4) + +const block = document.createElement('my-element-exp4') + +block.dataset.labelText = 'Name: ' +block.dataset.fieldText = 'Test' + +document.body.append(block) +``` + +##### HTML + +```html + + + Name: + + + Test + + +``` + +[npmbadge_notml_core]: https://img.shields.io/npm/v/@notml/core?label=@notml/core + +[npm_notml_core]: https://www.npmjs.com/package/@notml/core + +[npmbadge_notml]: https://img.shields.io/npm/v/notml?label=notml + +[npm_notml]: https://www.npmjs.com/package/notml + +[badge]: https://github.com/nodutilus/notml/actions/workflows/main.yml/badge.svg + +[actions]: https://github.com/nodutilus/notml/actions diff --git a/_deprecated/_old_custom-elements.js b/_deprecated/_old_custom-elements.js new file mode 100644 index 000000000..73aa4288e --- /dev/null +++ b/_deprecated/_old_custom-elements.js @@ -0,0 +1,225 @@ +import { customTagNames, customElementTagName, customClasses, customOptions } from './shared-const.js' +import { OOMElement } from '../@notml/core/lib/factory.js' + +const { HTMLElement, customElements } = window +const observedAttributesSymbol = Symbol('observedAttributes') +const attributeChangedCacheSymbol = Symbol('attributeChangedCache') +const attributesHandler = { + get: OOMElement.getAttribute, + set: (...args) => { + OOMElement.setAttribute(...args) + + return true + } +} + + +/** + * Подготовка списка атрибутов, изменения которых отслеживаются + * при помощи методов класса, содержащих 'Changed' на конце имени + * + * @param {typeof HTMLElement} proto + * @param {Map} setters + * @returns {Map|null} + */ +function getObservedAttributes(proto, setters) { + const properties = Object.getOwnPropertyNames(proto) + const nestedProto = Reflect.getPrototypeOf(proto) + + if (Object.isPrototypeOf.call(HTMLElement, nestedProto.constructor)) { + getObservedAttributes(nestedProto, setters) + } + + for (const name of properties) { + const { value } = Reflect.getOwnPropertyDescriptor(proto, name) + const isFunction = typeof value === 'function' + const isChanged = name.endsWith('Changed') + const isValidName = (/^[a-z][\w]+$/).test(name) + + if (isFunction && isChanged && isValidName) { + const attributeName = name + .replace(/Changed$/, '') + .replace(/[A-Z]/g, str => `-${str.toLowerCase()}`) + + setters.set(attributeName, name) + } + } + + return setters.size > 0 ? setters : null +} + + +/** + * Вызов обработчиков изменения атрибутов, + * с кэшированием изменений до вставки элемента в DOM + * + * @param {HTMLElement} instance + * @param {string} name + * @param {string} oldValue + * @param {string} newValue + */ +function applyAttributeChangedCallback(instance, name, oldValue, newValue) { + const observed = instance.constructor[observedAttributesSymbol] + + if (observed && observed.has(name)) { + if (newValue && newValue.startsWith('json::')) { + newValue = JSON.parse(newValue.replace('json::', '')) + } + if (instance.isConnected) { + instance[observed.get(name)](oldValue, newValue) + } else { + if (!(attributeChangedCacheSymbol in instance)) { + instance[attributeChangedCacheSymbol] = new Set() + } + instance[attributeChangedCacheSymbol].add({ + name: observed.get(name), + args: [oldValue, newValue] + }) + } + } +} + + +/** + * Применение OOM шаблона пользовательского элемента + * + * @param {HTMLElement} instance + */ +function applyOOMTemplate(instance) { + const attributeChanged = instance[attributeChangedCacheSymbol] + let staticTemplate = instance.constructor.template + let { shadowRootInit, template } = instance + let root = instance + let templateOptions = + (typeof staticTemplate === 'function' && staticTemplate.length > 0) || + (typeof template === 'function' && template.length > 0) || + null + + // TODO: Асинхронные шаблоны + // TODO: Метод на экземпляре applyTemplate + + if (templateOptions) { + templateOptions = Object.assign({}, customOptions.get(instance), { + element: instance, + attributes: new Proxy(instance, attributesHandler) + }) + } + + if (!(template instanceof OOMElement) && typeof template !== 'string') { + if (staticTemplate instanceof OOMElement) { + staticTemplate = staticTemplate.clone() + } else if (typeof staticTemplate === 'function') { + staticTemplate = instance.constructor.template(templateOptions) + } + if (typeof template === 'function') { + if (templateOptions) { + templateOptions.template = staticTemplate + } + template = instance.template(templateOptions) || staticTemplate + } else { + template = staticTemplate + } + } + + if (shadowRootInit) { + root = instance.attachShadow(shadowRootInit) + } + + if (template instanceof OOMElement) { + root.innerHTML = '' + root.append(template.dom) + } else if (typeof template === 'string') { + root.innerHTML = template + } + + if (attributeChanged instanceof Set) { + for (const changed of attributeChanged) { + instance[changed.name](...changed.args) + attributeChanged.delete(changed) + } + delete instance[attributeChangedCacheSymbol] + } +} + + +/** + * @param {typeof HTMLElement} constructor + * @returns {typeof HTMLElement} + */ +function customClassFactory(constructor) { + /** + * Динамический класс пользовательского элемента, реализующий: + * - Использование шаблонов для верстки (template, static template) + * - Опции для шаблона (oom(..., { options:{} })) + * - Методы-обработчики изменения атрибутов (endsWith('Changed')) + * - JSON атрибуты с преобразованием в объект (json::) + */ + class OOMCustomElement extends constructor { + + /** @returns {[string]} */ + static get observedAttributes() { + return this[observedAttributesSymbol] + ? [...this[observedAttributesSymbol].keys(), ...(super.observedAttributes || [])] + : super.observedAttributes + } + + /** Передача опций элемента в пользовательский конструктор */ + constructor() { + super(OOMCustomElement.options || {}) + delete OOMCustomElement.options + } + + /** + * Вызов методов класса отслеживающих изменения атрибутов + * + * @param {string} name + * @param {string} [oldValue] + * @param {string} [newValue] + */ + attributeChangedCallback(name, oldValue, newValue) { + applyAttributeChangedCallback(this, name, oldValue, newValue) + if (super.attributeChangedCallback) { + super.attributeChangedCallback(name, oldValue, newValue) + } + } + + /** Создание элемента по шаблону при вставке в DOM */ + connectedCallback() { + applyOOMTemplate(this) + if (super.connectedCallback) { + super.connectedCallback() + } + } + + } + + OOMCustomElement[observedAttributesSymbol] = getObservedAttributes(constructor.prototype, new Map()) + + return OOMCustomElement +} + + +/** + * @typedef CustomElementsOptions + * @property {string} extends Имя встроенного элемента для расширения + */ +/** + * Регистрация пользовательского элемента с элементами OOM шаблонизатора + * + * @param {string} [name] + * @param {typeof HTMLElement} constructor + * @param {CustomElementsOptions} options + */ +export function defineCustomElement(name, constructor, options) { + if (Object.isPrototypeOf.call(HTMLElement, name)) { + [constructor, options] = [name, constructor] + name = constructor.tagName || OOMElement.resolveTagName(constructor.name) + } + + const customClass = customClasses.get(constructor) || customClassFactory(constructor) + + customElements.define(name, customClass, options) + customClasses.set(customClass, constructor) + customElementTagName.set(constructor, name) + customTagNames.add(name) +} diff --git a/_deprecated/_old_shared-const.js b/_deprecated/_old_shared-const.js new file mode 100644 index 000000000..8d361e291 --- /dev/null +++ b/_deprecated/_old_shared-const.js @@ -0,0 +1,24 @@ +/** + * Список все тегов зарегистрированных пользовательских элементов + * + * @type {Set} + */ +export const customTagNames = new Set() +/** + * Сопоставление класса пользовательского элемента и его тега + * + * @type {Map} + */ +export const customElementTagName = new Map() +/** + * Связь динамических классов пользовательских элементов с классами определенными пользователем + * + * @type {Map} + */ +export const customClasses = new Map() +/** + * Хранилище опций для пользовательских компонентов + * + * @type {WeakMap>} + */ +export const customOptions = new WeakMap() diff --git a/_deprecated/test-oom.js b/_deprecated/test-oom.js new file mode 100644 index 000000000..1a4216a83 --- /dev/null +++ b/_deprecated/test-oom.js @@ -0,0 +1,1064 @@ +import { assert, Test } from '@nodutilus/test' +import { oom } from '@notml/core' + +const { HTMLElement, DocumentFragment, customElements, document } = window + + +/** Тесты источника данных заказа */ +export default class TestOOM extends Test { + + /** Базовый чейнинг для создания верстки */ + ['chaining - base']() { + const { html } = oom + .html(oom + .body(oom + .div('test'))) + + assert.equal(html, '
test
') + } + + /** Чейнинг методов OOMElement */ + ['chaining - oom methods']() { + const div = oom('div') + .append(oom('span')) + .setAttributes({ class: 'test' }) + .html + + assert.equal(div, '
') + } + + /** Чейнинг конструктора элементов oom из самого элемента */ + ['chaining - oom in OOMElement']() { + const div = oom('div') + .oom('span', 'test') + .oom('MyElm') + .html + + assert.equal(div, '
test
') + } + + /** Модификация вложенных элементов, ссылка на DOM созданного элемента */ + ['oom callback, DOM instance']() { + const form1 = oom('form', oom + .span('test', span => { + span.textContent += '-ok' + span.classList.add('test') + })) + .html + const form2 = oom('form') + .span('test', span => { + span.textContent += '-ok' + span.classList.add('test') + }) + .html + const form3 = oom('form', form => { + form.append(oom.span('test', span => { + span.textContent += '-ok' + span.classList.add('test') + }).dom) + }) + .html + const testForm = '
test-ok
' + + assert.equal(form1, testForm) + assert.equal(form2, testForm) + assert.equal(form3, testForm) + } + + /** Пустой вызов oom и обращение к атрибутам oom создают фрагмент */ + ['create Fragment with oom']() { + const fr1 = oom().div1() + const fr2 = oom.div2() + const div1 = oom('div', fr1).dom.innerHTML + const div2 = oom('div', fr2).dom.innerHTML + + assert.equal(div1, '') + assert.equal(div2, '') + assert.ok(fr1.dom instanceof DocumentFragment) + assert.ok(fr2.dom instanceof HTMLElement) + } + + /** Простая запись атрибутов */ + ['setAttributes - simple']() { + const div = oom('div', { id: 'test' }).html + + assert.equal(div, '
') + } + + /** Установка обработчика на элемент через атрибуты */ + ['setAttributes - set a function']() { + let counter = 0 + const div = oom('div', { + onclick: () => { + counter++ + }, + test: () => { + counter++ + } + }).dom + + div.click() + div.test() + + assert.equal(counter, 2) + } + + /** Установка объекта в качесвте атрибута */ + ['setAttributes - object=>json']() { + const div = oom('div', { dataTest: [] }).dom + + assert.equal(div.outerHTML, '
') + + div.dataset.test = '' + assert.equal(div.outerHTML, '
') + + delete div.dataset.test + assert.equal(div.outerHTML, '
') + } + + /** Установка стилей через style в виде объекта и строки */ + ['setAttributes - style']() { + const div1 = oom('div', { style: { borderBottom: '1px solid' } }).dom + const div2 = oom('div', { style: 'border-bottom: 1px solid;' }).dom + const div3 = oom('div', { style: { 'border-bottom': '1px solid' } }).dom + + assert.equal(div1.outerHTML, '
') + assert.equal(div2.outerHTML, div1.outerHTML) + assert.equal(div3.outerHTML, div1.outerHTML) + } + + /** Установка атрибута в верхнем регистре работает по аналогии dataset */ + ['setAttributes - UpperCase']() { + const div = oom('div', { dataTest: 'test' }).dom + + assert.equal(div.outerHTML, '
') + } + + /** Установка атрибута опций в обычный элемент записывает их в формате JSON */ + ['setAttributes - options']() { + const div = oom('div', { options: { a: 'b' } }).dom + + assert.equal(div.outerHTML, '
') + } + + /** Установка атрибутов через oom.setAttributes */ + ['oom - setAttributes']() { + const div = oom('div').dom + + oom.setAttributes(div, { class: 'test1' }) + assert.equal(div.outerHTML, '
') + + oom.setAttribute(div, 'class', 'test2') + assert.equal(div.outerHTML, '
') + } + + /** Метод append от оом аналогичен методу от OOMElement */ + ['oom - append']() { + const div11 = oom().div().append(oom('span')) + const div12 = oom.append(oom('div')).span() + const div13 = oom('div').append(oom('span')) + + assert.equal(div11.html, '
') + assert.equal(div12.html, '
') + assert.equal(div13.html, '
') + } + + /** Перетаскивание элемента между разными узлами DOM */ + ['OOMElement - moving']() { + const span = oom('span', 'test') + const div1 = oom('div') + const div2 = oom('div') + + div1.append(span) + assert.equal(span.dom.parentNode, div1.dom) + assert.equal(div1.dom.innerHTML, 'test') + assert.equal(div2.dom.innerHTML, '') + + div2.append(span) + assert.equal(span.dom.parentNode, div2.dom) + assert.equal(div1.dom.innerHTML, '') + assert.equal(div2.dom.innerHTML, 'test') + + assert.equal(span.html, 'test') + } + + /** Клонирование элемента */ + ['OOMElement - cloning']() { + const span = oom('span', 'test') + const div1 = oom('div') + const div2 = oom('div') + const div3 = oom('div') + + div1.append(span.clone()) + assert.equal(div1.dom.innerHTML, 'test') + assert.equal(div2.dom.innerHTML, '') + + div2.append(span.clone()) + assert.equal(div1.dom.innerHTML, 'test') + assert.equal(div2.dom.innerHTML, 'test') + + assert.equal(span.dom.outerHTML, 'test') + assert.equal(span.dom.parentNode, null) + + div3.append(span) + assert.equal(div2.dom.innerHTML, 'test') + assert.equal(div3.dom.innerHTML, 'test') + assert.equal(span.dom.parentNode, div3.dom) + } + + /** Фрагмент не перетаскивается между разными узлами DOM, + * его содержимое изымается и вставляется в DOM, оставляя фрагмент-пустышку */ + ['OOMFragment - no moving']() { + const span = oom.span('test') + const div1 = oom('div') + const div2 = oom('div') + + assert.equal(span.dom.childNodes.length, 1) + + div1.append(span) + assert.equal(div1.dom.innerHTML, 'test') + assert.equal(div2.dom.innerHTML, '') + + div2.append(span) + assert.equal(div1.dom.innerHTML, 'test') + assert.equal(div2.dom.innerHTML, '') + + assert.equal(span.dom.childNodes.length, 0) + } + + /** Клонирование фрагмента */ + ['OOMFragment - cloning']() { + const span = oom.span('test') + const div1 = oom('div') + const div2 = oom('div') + + assert.equal(span.dom.childNodes.length, 1) + + div1.append(span.clone()) + assert.equal(div1.dom.innerHTML, 'test') + assert.equal(div2.dom.innerHTML, '') + + div2.append(span.clone()) + assert.equal(div1.dom.innerHTML, 'test') + assert.equal(div2.dom.innerHTML, 'test') + + assert.equal(span.dom.childNodes.length, 1) + } + + /** Создание и регистрация пользовательских элементов */ + ['customElements - create']() { + let cCount = 0 + + /** Test custom element */ + class MyElement extends HTMLElement { + + /***/ + constructor() { + super() + this.cCount = ++cCount + } + + } + + customElements.define('my-element1', MyElement) + + const mye = oom('my-element1') + .MyElement1('test', { class: 'Test' }) + + assert.equal(cCount, 2) + assert.equal(mye.dom.cCount, 1) + assert.equal(mye.html, 'test') + } + + /** Кастомизация содержания пользовательских элементов */ + ['customElements - connectedCallback']() { + /** Test custom element */ + class MyElement extends HTMLElement { + + /***/ + connectedCallback() { + this.classList.add('MyElement') + this.append(oom('span', 'test').dom) + } + + } + + customElements.define('my-element2', MyElement) + + const mye = oom.MyElement2() + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, 'test') + document.body.innerHTML = '' + } + + /** Регистрация новых элементов через oom.define */ + ['customElements - oom.define']() { + /** Test custom element */ + class MyElementDefine1 extends HTMLElement { } + + /** Test custom element */ + class MyElementDefine2 extends HTMLElement { } + + + oom.define(MyElementDefine1) + oom.define('m-e-d', MyElementDefine2) + + assert.equal(oom.getDefined('my-element-define1'), MyElementDefine1) + assert.equal(oom.getDefined('m-e-d'), MyElementDefine2) + } + + /** Регистрация новых элементов через oom.define */ + ['customElements - static template']() { + /** Test custom element */ + class MyElement3 extends HTMLElement { + + static template = oom('span', 'test') + + /***/ + template() { + this.classList.add('MyElement') + } + + } + + const mye = oom.define(MyElement3).oom(MyElement3) + + document.body.innerHTML = '' + document.body.append(mye.dom) + // Статические шаблоны клонируются для переиспользования + assert.equal(mye.dom.constructor.template.dom.parentNode, null) + assert.equal(document.body.innerHTML, 'test') + document.body.innerHTML = '' + } + + /** Статический метод шаблона */ + ['customElements - static template function']() { + /** Test custom element */ + class MyElement3x1 extends HTMLElement { + + static tmp = oom('div') + + /** @returns {oom} */ + static template = () => { + return this.tmp.clone().span('test') + } + + } + + const mye = oom.define(MyElement3x1).oom(MyElement3x1) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(mye.dom.constructor.tmp.dom.parentNode, null) + assert.equal(document.body.innerHTML, '
test
') + document.body.innerHTML = '' + } + + /** Статический шаблон в виде текста, передается в метод на экземпляре */ + ['customElements - template string+function']() { + /** Test custom element */ + class MyElement3x2 extends HTMLElement { + + static template = 'test1' + + /** @param {{template:string}} options + * @returns {string} */ + template = ({ template }) => template + 'test2' + + } + + const mye = oom.define(MyElement3x2).oom(MyElement3x2) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(mye.dom.constructor.template, 'test1') + assert.equal(document.body.innerHTML, 'test1test2') + document.body.innerHTML = '' + } + + /** Шаблоны на экземпляре не клонируются, + * т.к. для каждого создается отдельный экземпляр шаблона */ + ['customElements - instance template']() { + /** Test custom element */ + class MyElement4 extends HTMLElement { + + template = oom('span', 'test') + + } + + const mye = oom.define(MyElement4).oom(MyElement4) + const mye2 = oom(MyElement4) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.ok(mye.dom.template !== mye2.dom.template) + assert.ok(mye.dom.template.dom.parentNode === mye.dom) + assert.equal(document.body.innerHTML, 'test') + document.body.innerHTML = '' + } + + /** Шаблон неподдерживаемого типа */ + ['customElements - instance template (bad)']() { + /** Test custom element */ + class MyElement5 extends HTMLElement { + + template = 123 + + } + + const mye = oom.define(MyElement5).oom(MyElement5) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '') + document.body.innerHTML = '' + } + + /** Метод шаблона на экземпляре принимает на вход статичный шаблон */ + ['customElements - instance template function']() { + /** Test custom element */ + class MyElement6 extends HTMLElement { + + static template = oom('div') + + /** @param {{template:oom}} options */ + template({ template }) { + template.span('test') + } + + } + + const mye = oom.define(MyElement6).oom(MyElement6) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(mye.dom.constructor.template.dom.parentNode, null) + assert.equal(document.body.innerHTML, '
test
') + document.body.innerHTML = '' + } + + /** Шаблон на экземпляре в виде строки */ + ['customElements - instance template string']() { + /** Test custom element */ + class MyElement6x1 extends HTMLElement { + + template = '
test
' + + } + + const mye = oom.define(MyElement6x1).oom(MyElement6x1) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(mye.dom.template, '
test
') + assert.equal(document.body.innerHTML, '
test
') + document.body.innerHTML = '' + } + + /** Можно использовать пользовательский connectedCallback */ + ['customElements - oom.define, connectedCallback']() { + /** Test custom element */ + class MyElement7 extends HTMLElement { + + /***/ + connectedCallback() { + this.classList.add('MyElement') + } + + } + + const mye = oom.define(MyElement7).oom(MyElement7) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '') + document.body.innerHTML = '' + } + + /** Отслеживание изменений атрибутов */ + ['customElements - attributeChanged']() { + /** Test custom element */ + class MyElement8x0 extends HTMLElement { + + /** + * @param {string} oldValue + * @param {string} newValue + */ + dataClassNameChanged(oldValue, newValue) { + this.classList.remove(oldValue) + this.classList.add(newValue) + } + + } + + /** Test custom element */ + class MyElement8 extends MyElement8x0 { + + /** @returns {oom} */ + template() { + return oom('div', this.dataset.myText, div => (this._div = div)) + } + + /** + * @param {string} oldValue + * @param {string} newValue + */ + dataMyTextChanged(oldValue, newValue) { + if (this._div.textContent !== newValue) { + this._div.textContent = newValue + } + } + + } + + const mye = oom.define(MyElement8).oom(MyElement8, { + 'data-my-text': 'test1' + }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '
test1
') + + mye.dom.dataset.myText = 'test2' + assert.equal(document.body.innerHTML, '
test2
') + + mye.dom.dataset.className = 'CLS' + assert.equal(document.body.innerHTML, '
test2
') + + document.body.innerHTML = '' + } + + /** Отслеживание изменений атрибутов * 2, кэш внутри */ + ['customElements - attributeChanged * 2, new Set']() { + /** Test custom element */ + class MyElement8x1 extends HTMLElement { + + /** + * @param {string} oldValue + * @param {string} newValue + */ + dataMyText1Changed(oldValue, newValue) { + this.textContent += newValue + } + + /** + * @param {string} oldValue + * @param {string} newValue + */ + dataMyText2Changed(oldValue, newValue) { + this.textContent += newValue + } + + } + + const mye = oom.define(MyElement8x1).oom(MyElement8x1, { + 'data-my-text1': 'test1', + 'data-my-text2': 'test2' + }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '' + + 'test1test2') + + document.body.innerHTML = '' + } + + /** Отслеживание изменений атрибутов с пользовательским обработчиком */ + ['customElements - attributeChanged + observedAttributes']() { + const changed = [] + + /** Test custom element */ + class MyElement9 extends HTMLElement { + + /** @returns {[string]} */ + static get observedAttributes() { + return ['test'] + } + + /** @returns {oom} */ + template() { + return oom('div', div => (this._div = div)) + } + + /** + * @param {string} oldValue + * @param {string} newValue + */ + dataMyTextChanged(oldValue, newValue) { + this._div.textContent = newValue + } + + /** + * @param {string} name + * @param {string} oldValue + * @param {string} newValue + */ + attributeChangedCallback(name, oldValue, newValue) { + changed.push([name, oldValue, newValue]) + } + + } + + const mye = oom.define(MyElement9).oom(MyElement9, { + 'data-my-text': 'test1', + 'test': 'test2' + }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '' + + '
test1
') + + mye.dom.dataset.myText = 'test3' + mye.dom.setAttribute('test', 'test4') + assert.equal(document.body.innerHTML, '' + + '
test3
') + + assert.deepEqual(changed, [ + ['data-my-text', null, 'test1'], + ['test', null, 'test2'], + ['data-my-text', 'test1', 'test3'], + ['test', 'test2', 'test4'] + ]) + document.body.innerHTML = '' + } + + /** JSON объекты в качестве атрибута парсятся в объект */ + ['customElements - attributeChanged + json::*']() { + let result + + /** Test custom element */ + class MyElement10 extends HTMLElement { + + /** + * @param {string} oldValue + * @param {string} newValue + */ + testChanged(oldValue, newValue) { + result = newValue + } + + } + + const mye = oom.define(MyElement10).oom(MyElement10, { test: { a: 1 } }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '') + assert.deepEqual(result, { a: 1 }) + + mye.dom.setAttribute('test', '') + assert.equal(document.body.innerHTML, '') + + mye.dom.removeAttribute('test') + assert.equal(document.body.innerHTML, '') + + document.body.innerHTML = '' + } + + /** Работа с атрибутами в шаблоне */ + ['customElements - template + proxyAttributes']() { + let result + + /** Test custom element */ + class MyElement11 extends HTMLElement { + + static template = oom('div') + + /** + * @param {{element:HTMLElement, template:oom, attributes:Proxy}} options + * @returns {oom} + */ + template = ({ element, template, attributes }) => { + attributes.onclick() + element._test3 = attributes.test3 + + return template + .span(attributes.test1.test2) + } + + } + + const mye = oom.define(MyElement11).oom(MyElement11, { + test1: { + test2: 'test3' + }, + onclick: () => (result = 1) + }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(result, 1) + assert.equal(document.body.innerHTML, '
test3
') + assert.equal(mye.dom._test3, null) + + document.body.innerHTML = '' + } + + /** Работа с атрибутами в статическом шаблоне */ + ['customElements - static template + proxyAttributes']() { + let result + + /** Test custom element */ + class MyElement12 extends HTMLElement { + + /** + * @param {{attributes:Proxy}} options + * @returns {oom} + */ + static template = ({ attributes }) => { + attributes.onclick() + + return oom('div') + .span(attributes.test1.test2) + } + + /** + * @param {{template:oom, attributes:Proxy}} options + * @returns {oom} + */ + template = ({ template, attributes }) => template + .span(attributes.test4) + + } + + const mye = oom.define(MyElement12).oom(MyElement12, { + test1: { + test2: 'test3' + }, + test4: 'test5', + onclick: () => (result = 1) + }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(result, 1) + assert.equal(document.body.innerHTML, '
' + + 'test3test5
') + + document.body.innerHTML = '' + } + + /** Работа с атрибутами в верхнем регистре как с dataset */ + ['customElements - proxyAttributes + UpperCase']() { + /** Test custom element */ + class MyElement13 extends HTMLElement { + + /** + * @param {{attributes:Proxy}} options + */ + static template = ({ attributes }) => { attributes.dataTestAttr2 = 0 } + + /** + * @param {{attributes:Proxy}} options + * @returns {oom} + */ + template = ({ attributes }) => oom + .div(attributes.dataTestAttr1, div => (this._div = div)) + + /** + * @param {string} oldValue + * @param {string} newValue + */ + dataTestAttr1Changed(oldValue, newValue) { + this._div.textContent += newValue + } + + } + + const mye = oom.define(MyElement13).oom(MyElement13, { + dataTestAttr1: 'test1', + dataTestAttr2: 1 + }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '' + + '
test1test1
') + + mye.dom.dataset.testAttr1 = 'test2' + assert.equal(document.body.innerHTML, '' + + '
test1test1test2
') + + document.body.innerHTML = '' + } + + /** Особый атрибут options позволяет сохранить данные на экземпляре, + * но при этом его изменение не отслеживается. + * Концептуально опции изменяться не должны. */ + ['customElements - options']() { + /** Test custom element */ + class MyElement14 extends HTMLElement { + + /** @param {{}} options */ + constructor(options) { + super() + this._options = options + } + + /** + * @param {{element:HTMLElement, attributes:Proxy, test:function}} options + * @returns {oom} + */ + template = ({ element, attributes, test }) => oom + .div([ + element._options === attributes.options, + element._options.test === test, + test() + ].join('-')) + + } + + const mye = oom.define(MyElement14).oom('my-element14', { + options: { + test: () => 'test-ok' + } + }) + + assert.equal(mye.html, '') + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '
true-true-test-ok
') + + document.body.innerHTML = '' + } + + /** Имя тега нового элемента можно задать через статическое свойство tagName */ + ['customElements - tagName']() { + /** Test custom element */ + class MyElement15 extends HTMLElement { + + static tagName = 'my-element15-custom' + + } + + const mye = oom.define(MyElement15).oom(MyElement15) + + assert.equal(mye.html, '') + } + + /** Инициализация теневого DOM */ + ['customElements - shadowRoot init']() { + /** Test custom element */ + class MyElement16 extends HTMLElement { + + shadowRootInit = { mode: 'open' } + + template = oom.test('ok') + + } + + const mye = oom.define(MyElement16).oom(MyElement16) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '') + assert.equal(mye.dom.shadowRoot.innerHTML, 'ok') + + document.body.innerHTML = '' + } + + /** изменения внутренних элементов теневого DOM по ссылкам */ + ['customElements - shadowRoot modify by link']() { + /** Test custom element */ + class MyElement17 extends HTMLElement { + + mySpan = oom('span', 'test') + + shadowRootInit = { mode: 'open' } + + /** + * @returns {oom} + */ + template = () => oom.test(this.mySpan) + + } + + const mye = oom.define(MyElement17).oom(MyElement17) + + document.body.innerHTML = '' + document.body.append(mye.dom) + assert.equal(document.body.innerHTML, '') + assert.equal(mye.dom.shadowRoot.innerHTML, 'test') + + mye.dom.mySpan.dom.textContent = 'ok' + assert.equal(mye.dom.shadowRoot.innerHTML, 'ok') + + document.body.innerHTML = '' + } + + /** Код из примера - Простая верстка */ + ['example in readme - Example #1']() { + const oomDiv = oom('div') + .div({ class: 'header' }) + .div({ style: { borderBottom: '1px solid' } }, oom + .span('Name: ', { class: 'test-label' }) + .span('Test', { class: 'test-name' })) + .div({ class: 'footer' }) + const divHeader = document.createElement('div') + const spanName = document.createElement('span') + const spanTest = document.createElement('span') + const divBorder = document.createElement('div') + const divFooter = document.createElement('div') + const domDiv = document.createElement('div') + + divHeader.setAttribute('class', 'header') + spanName.setAttribute('class', 'test-label') + spanTest.setAttribute('class', 'test-name') + divBorder.style.borderBottom = '1px solid' + divFooter.setAttribute('class', 'footer') + spanName.textContent = 'Name: ' + spanTest.textContent = 'Test' + + domDiv.append(divHeader) + divBorder.append(spanName) + divBorder.append(spanTest) + domDiv.append(divBorder) + domDiv.append(divFooter) + + assert.equal(oomDiv.html, domDiv.outerHTML) + } + + /** Код из примера - Переиспользование элементов */ + ['example in readme - Example #2']() { + const header = oom('div', { class: 'header' }) + .span('Test Header') + const block = oom + .div(oom + .append(header.clone()) + .div('div 1')) + .div(oom + .append(header.clone()) + .div('div 2')) + + assert.equal(block.html, + '
' + + '
Test Header
' + + '
div 1
' + + '
' + + '
' + + '
Test Header
' + + '
div 2
' + + '
') + } + + /** Код из примера - Простой шаблон */ + ['example in readme - Example #3']() { + /** Test custom element */ + class MyElementExp3 extends HTMLElement { + + mySpan = oom.span('My element new text') + + template = oom('div', { class: 'MyElement__inner' }) + .append(this.mySpan.clone()) + .append(oom('br')) + .append(this.mySpan) + + } + + const block = oom.define(MyElementExp3).MyElementExp3() + + document.body.innerHTML = '' + document.body.append(block.dom) + + assert.equal(document.body.innerHTML, + '' + + '
' + + 'My element new text' + + '
' + + 'My element new text' + + '
' + + '
') + + document.body.innerHTML = '' + } + + /** Код из примера - Реактивные свойства */ + ['example in readme - Example #4']() { + /** Test custom element */ + class MyElementExp4 extends HTMLElement { + + static label = oom('span', { class: 'label' }) + static field = oom('span', { class: 'field' }) + + /** + * @param {{element:HTMLElement}} options + * @returns {oom} + */ + static template({ element }) { + return oom() + .append(this.label.clone() + .span({ class: 'text' }, label => (element._label = label))) + .append(this.field.clone() + .span({ class: 'text' }, field => (element._field = field))) + } + + /** + * on 'data-field-text' attribute change + * + * @param {string} oldValue + * @param {string} newValue + */ + dataFieldTextChanged(oldValue, newValue) { + this._field.textContent = newValue + } + + /** + * on 'data-label-text' attribute change + * + * @param {string} oldValue + * @param {string} newValue + */ + dataLabelTextChanged(oldValue, newValue) { + this._label.textContent = newValue + } + + } + + oom.define(MyElementExp4) + + const block = document.createElement('my-element-exp4') + const html = block.outerHTML + + block.dataset.labelText = 'Name: ' + block.dataset.fieldText = 'Test' + + document.body.innerHTML = '' + document.body.append(block) + + assert.equal(html, '') + assert.equal(document.body.innerHTML, + '' + + 'Name: ' + + 'Test' + + '') + } + +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..7b7bff7d6 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "repository": "github:nodutilus/notml", + "license": "Unlicense", + "type": "module", + "devDependencies": { + "@nodutilus/project-config": "latest", + "@nodutilus/test": "latest", + "@rollup/plugin-node-resolve": "latest", + "fastify": "latest", + "fastify-static": "latest", + "jsdom": "latest", + "rollup": "latest", + "rollup-plugin-cleanup": "latest", + "rollup-plugin-terser": "latest" + }, + "scripts": { + "pre-test": "node test/pre-test" + } +} diff --git a/test/1.basic-behavior.js b/test/1.basic-behavior.js new file mode 100644 index 000000000..af65a718d --- /dev/null +++ b/test/1.basic-behavior.js @@ -0,0 +1,459 @@ +// @ts-ignore +import { assert, Test } from '@nodutilus/test' +import { oom } from '@notml/core' + +const { document, HTMLDivElement, HTMLInputElement, DocumentFragment } = window + + +/** Проверка базового поведения создания верстки */ +export default class BasicBehavior extends Test { + + /** Защита от случайного переопределения полей для Proxy */ + ['Отключение setter`а у Proxy OOM элемента']() { + const div = oom('div') + let trows = 0 + + try { + oom.div = null + } catch (error) { + trows++ + } + try { + div.div = null + } catch (error) { + trows++ + } + + assert(typeof oom.div, 'function') + assert(typeof div.div, 'function') + assert(trows, 2) + } + + /** + * Создание всегда начинается с единичного элемента, + * это делает API однозначным + */ + ['Создание DOM элемента через oom']() { + const div1 = oom.div() + const div2 = oom('div') + const div3 = oom.oom('div') + + assert.ok(div1.dom instanceof HTMLDivElement) + assert.ok(div2.dom instanceof HTMLDivElement) + assert.ok(div3.dom instanceof HTMLDivElement) + + assert.equal(div1.html, '
') + assert.equal(div2.html, '
') + assert.equal(div3.html, '
') + } + + /** Создание экземпляра OOM по готовому DOM элементу */ + ['Использование готового DOM элемента в oom']() { + const div = oom(document.createElement('div')) + + assert.ok(div.dom instanceof HTMLDivElement) + + assert.equal(div.html, '
') + } + + /** Создание OOM элемента с некорректным tagName завершается ошибкой */ + ['Ошибка при вызове с некорректным tagName']() { + let div + + try { + // @ts-ignore + div = oom(0) + } catch (error) { + assert.ok(error.message.startsWith('"0" did not match the Name production')) + } + try { + // @ts-ignore + div = oom({}) + } catch (error) { + assert.ok(error.message.startsWith('"[object Object]" did not match the Name production')) + } + + assert.equal(div, undefined) + } + + /** + * Вставка выполняется вызовом как функции ранее созданного элемента. + * Символы "()" создают создают эффект проваливания ниже на уровень + */ + ['Вложение элемента, как экземпляра oom']() { + const div1 = oom.div() + const a2 = oom('a') + const p3 = oom.oom('p') + + div1(oom.oom('p')) + a2(oom.i()) + p3(oom('div')) + + assert.equal(div1.html, '

') + assert.equal(a2.html, '') + assert.equal(p3.html, '

') + } + + /** + * Вставка дочерних элементов, через вызов функции, + * работает и для базовых элементов DOM + */ + ['Вложение элемента, как базового элемента DOM']() { + const div1 = oom.div() + const a2 = oom('a') + const fragment1 = document.createDocumentFragment() + + fragment1.append( + document.createElement('p'), + document.createElement('b') + ) + + div1(document.createElement('a')) + a2(fragment1) + + assert.equal(div1.html, '
') + assert.equal(a2.html, '

') + } + + /** + * При вставке в качестве дочерних элементов встроенных объектов JS, + * и экземпляров пользовательских классов, выполняется приведение к строке. + * Вставка HTML строкой также экранируется стандартным методом HTMLElement.append + */ + ['Вложение элемента, встроенного объекта JS или строки']() { + const p1 = oom.p() + const p2 = oom.p() + const p3 = oom.p() + const p2Date = new Date() + const p2Str = p2Date + '' + + // @ts-ignore + p1(/test/i) // RegExp - превратиться в строку + // @ts-ignore + p2(p2Date) + // @ts-ignore + p3(null, false, true, undefined) + + assert.equal(p1.html, '

/test/i

') + assert.equal(p2.html, `

${p2Str}

`) + assert.equal(p3.html, '

nullfalsetrue

') + } + + /** + * При создании элемента доп. аргументом передается объект с атрибутами, + * которые перекладываются на созданный экземпляр DOM. + * Символ " экранируется, чтобы не портить разметку + */ + ['Установка атрибутов элемента при создании']() { + const div1 = oom.div({ class: 'test1', test: '"' }) + const div2 = oom('div', { class: 'test2', test: '"' }) + const div3 = oom.oom('div', { class: 'test3', test: '"' }) + + assert.equal(div1.html, '
') + assert.equal(div2.html, '
') + assert.equal(div3.html, '
') + } + + /** + * Атрибуты можно устанавливать в camelCase, чтобы не оборачивать в кавычки, + * заглавные буквы автоматически заменяются с добавлением символа "-". + * Это классическое поведение для DataSet и CSSStyleDeclaration + */ + ['Установка атрибутов в camelCase']() { + const div = oom.div({ camelCase: 'camelCase' }) + + assert.equal(div.html, '
') + } + + /** Выполняется вызовом как функции ранее созданного элемента */ + ['Обновление атрибутов элемента через ловушку apply']() { + const div1 = oom.div({ class: 'test1' }) + const div2 = oom('div', { class: 'test2' }) + const div3 = oom.oom('div', { class: 'test3' }) + + assert.equal(div1.html, '
') + assert.equal(div2.html, '
') + assert.equal(div3.html, '
') + + div1({ class: 'test4' }) + div2({ class: 'test5' }) + div3({ class: '' }) + + assert.equal(div1.html, '
') + assert.equal(div2.html, '
') + assert.equal(div3.html, '
') + } + + /** + * Все аргументы вызова как функции созданного элемента последовательно обрабатываются, + * и выполняется вставка дочерних элементов и обновление атрибутов + */ + ['Вложение нескольких элементов / обновление атрибутов за 1 вызов']() { + const div1 = oom.div() + const a2 = oom('a') + const p3 = oom.oom('p') + + div1(a2, p3, { class: 'test' }, { test: 'class', class: 'test2' }) + + assert.equal(div1.html, '

') + } + + /** + * При создании элемента все аргументы используются аналогично аргументам при вызове элемента как функции, + * за исключением аргумента с названием тега. + * А в коде используется общий метод для обновления созданного элемента + */ + ['Вложение нескольких элементов / обновление атрибутов при создании экземпляра oom']() { + const a1 = oom('a') + const p1 = oom.oom('p') + const a2 = oom('a') + const p2 = oom.oom('p') + const div1 = oom.div(a1, p1, { class: 'test' }, { test: 'class', class: 'test2' }) + const div2 = oom('div', a2, p2, { class: 'test' }, { test: 'class', class: 'test2' }) + + assert.equal(div1.html, '

') + assert.equal(div2.html, '

') + } + + /** + * Для наглядной установки логических атрибутов добавим их переключение через true/false + */ + ['Обновление логических атрибутов']() { + const div = oom.div() + + assert.equal(div.html, '
') + + div({ enabled: true }) + assert.equal(div.html, '
') + + div({ enabled: false }) + assert.equal(div.html, '
') + + div({ testEnabled: true }) + assert.equal(div.html, '
') + + div({ testEnabled: false }) + assert.equal(div.html, '
') + } + + /** + * Если имеется только текст верстки, то его можно задать в OOM элемент через атрибут innerHTML. + * Полезно если oom используется в другими библиотеками, + * например @primer/octicons возвращает иконки в виде текста svg-разметки. + */ + ['Установка innerHTML через атрибуты OOM']() { + const div = oom.div() + + div({ innerHTML: 'test1' }) + assert.equal(div.html, '
test1
') + + div({ 'inner-html': 'test2' }) + assert.equal(div.html, '
test2
') + } + + /** + * Создание составных компонентов можно выполнять без использования промежуточных переменных, + * чтобы приблизить вид к верстке через HTML. + */ + ['Верстка составного компонента через аргументы']() { + const component1 = oom('div', { class: 'link' }, + oom.span({ class: 'title' }, 'Link: '), + oom.a({ href: 'https://test.ok' }, 'test.ok') + ) + const component2 = oom.div({ class: 'link' }, + oom.span({ class: 'title' }, 'Link: '), + oom.a({ href: 'https://test.ok' }, 'test.ok') + ) + const componentText = ` + + `.replace(/\s*\n+\s+/g, '') + + assert.equal(component1.html, componentText) + assert.equal(component2.html, componentText) + } + + /** + * Чтобы создать несколько элементов без вложенности на одном уровне, + * а затем поместить в другой элемент, в качестве контейнера используется DocumentFragment + */ + ['Создание OOM элемента с DocumentFragment']() { + const fragment1 = oom() + const fragment2 = oom(document.createDocumentFragment()) + + fragment1(oom.div()) + fragment2(oom.div(), oom.div()) + + assert.ok(fragment1.dom instanceof DocumentFragment) + assert.ok(fragment2.dom instanceof DocumentFragment) + + assert.equal(fragment1.html, '
') + assert.equal(fragment2.html, '
') + } + + /** + * При попытке обновления атрибутов для DocumentFragment + * кидается стандартная ошибка об отсутствии метода + */ + ['Ошибка обновления атрибутов для DocumentFragment']() { + const fragment = oom() + let err + + try { + // @ts-ignore Проверка на стандартное исключение DOM + fragment({ class: 'test' }) + } catch (error) { + err = error + } + + assert.equal(err.message, "'setAttribute' called on an object that is not a valid instance of Element.") + assert.equal(fragment.html, '') + } + + /** + * DocumentFragment может содержать на 1ом уровне не элементы а ноды, + * значимой при версте через OOM является только TEXT_NODE, остальные игнорируются + */ + ['DocumentFragment, Node и nodeType']() { + const comment = document.createComment('comment text') + // @ts-ignore - comment не может быть OOMChild, но при передаче в параметры он проигнорируется + const fragment = oom()('test text', oom.i(), comment) + + assert.equal(fragment.html, 'test text') + } + + /** + * При вставке одного фрагмента DOM в другой элементы переданного фрагмента переносятся в целевой, + * т.к. нет необходимости хранения иерархии для DocumentFragment + */ + ['DocumentFragment в DocumentFragment']() { + const fragment1 = oom()(oom.div('test1')) + const fragment2 = oom()('test2') + + assert.equal(fragment2.dom.childNodes.length, 1) + fragment1(fragment2) + assert.equal(fragment2.dom.childNodes.length, 0) + assert.equal(fragment1.html, '
test1
test2') + assert.ok(fragment1.dom.firstChild instanceof HTMLDivElement) + } + + /** + * Использование чейнинга на элементе создает DocumentFragment, + * помещая элементы последовательно + */ + ['Создание последовательных элементов через чейнинг']() { + const fragment1 = oom + .div('test1') + .div('test2') + .div('test3') + const fragment2 = oom() + const div1 = oom('div', 'test2') + + fragment2.div('test1') + fragment2(div1) + fragment2.div('test3') + + assert.equal(fragment1.html, '
test1
test2
test3
') + assert.equal(fragment2.html, '
test1
test2
test3
') + } + + /** + * Чейнинг работает и после вызова базового элемента для обновления или вставки дочернего элемента. + * Используется когда удобнее добавлять дочерние элементы через вызов функции, + * например при создании пользовательских элементов через `new` + */ + ['Чейнинг после вызова элемента функции']() { + const div = oom.div({ class: 'test1' }) + const fragment = div.clone()({ class: 'test2' }, oom.span('t1')) + .span('t2') + .span('t3') + + + assert.equal(div.html, '
') + assert.equal(fragment.html, '
t1
t2t3') + } + + /** Что бы уменьшить кол-во кода создание составных компонентов можно выполнять с использованием чейнинга */ + ['Верстка составного компонента через чейнинг']() { + const component1 = oom('div', { class: 'link' }, oom + .span({ class: 'title' }, 'Link: ') + .a({ href: 'https://test.ok' }, 'test.ok')) + const component2 = oom.div({ class: 'link' }, oom + .span({ class: 'title' }, 'Link: ') + .a({ href: 'https://test.ok' }, 'test.ok')) + const componentText = ` + + `.replace(/\s*\n+\s+/g, '') + + assert.equal(component1.html, componentText) + assert.equal(component2.html, componentText) + } + + + /** + * Обработчики событий можно задавать через атрибуты или DOM свойства, + * в оом шаблоне оба способа работают через объект с атрибутами. + * Функции автоматически определяются и присваиваются как свойства объекта + */ + ['Базовая обработка событий DOM элементов']() { + let cnt = 0 + const div1 = oom.div({ onclick: () => cnt++ }) + const div2 = oom.div({ onclick: `this.append('${cnt}')` }) + + assert.equal(cnt, 0) + div1.dom.click() + assert.equal(cnt, 1) + + assert.equal(div2.html, '
') + div2.dom.click() + assert.equal(div2.html, '
0
') + } + + /** + * Вставка oom элемента в другой не должна завершаться ошибкой. + * dom элемент передается по ссылке. + */ + ['Вставка oom в oom']() { + const div1 = oom.div() + const div2 = oom(div1) + + assert.equal(div1.dom, div2.dom) + } + + /** + * При работе с тегом template для работы с вложенными элементами нужно использовать elm.content. + * При этом clone из оом клонирует сам шаблон, а его содержимое клонируется через DOM API, + * это дает возможность удобно создавать в JS через oom шаблоны для DOM шаблонов + */ + ['Работа с тегом template']() { + const tmpl = oom.template(oom.div()) + // Клонируем содержимое шаблона через DOM API + const elm1 = oom.main(tmpl.dom.content.cloneNode(true)) + // Клонируем сам шаблон через OOM API + const elm2 = oom.section(tmpl.clone()) + + assert.equal(elm1.dom.outerHTML, '
') + assert.equal(elm2.dom.outerHTML, '
') + assert.equal(tmpl.dom.outerHTML, '') + // @ts-ignore + assert.equal(tmpl.dom.content.firstChild.outerHTML, '
') + } + + /** + * Объявлены основные html теги для упрощения обращения и автоподстановки + */ + ['Типизация .dom']() { + const input = oom.input() + + input.dom.value = 'test' + + assert.equal(input.dom.value, 'test') + assert.ok(input.dom instanceof HTMLInputElement) + } + +} diff --git a/test/2.basic-api.js b/test/2.basic-api.js new file mode 100644 index 000000000..294f3f89c --- /dev/null +++ b/test/2.basic-api.js @@ -0,0 +1,69 @@ +// @ts-ignore +import { assert, Test } from '@nodutilus/test' +import { oom } from '@notml/core' + +const { document } = window + + +/** Проверка API OOM элементов */ +export default class BasicAPI extends Test { + + /** + * Добавление дочернего элемента. + * Выполняет универсальное добавление элемента с учетом реализации OOM элементов. + * Добавление выполняется в конец списка детей, другое поведение нерационально + */ + ['OOMElement#append - Вставка дочернего элемента']() { + const div = oom('div') + .append(oom('a')) + .append(document.createElement('b')) + .append('test') + + assert.equal(div.html, '
test
') + } + + /** + * Добавление элемента в фрагмент выполняется аналогично добавлению в OOM элемент + */ + ['OOMFragment#append - Вставка элемента в фрагмент']() { + const fragment = oom() + .append(oom('a')) + .append(document.createElement('b')) + .append('test') + + assert.equal(fragment.html, 'test') + } + + /** + * Клонирование элемента для его переиспользования. + * Создает копию DOM элемента и оборачивает его в новый экземпляр OOM Proxy + */ + ['OOMElement#clone - Клонирование элемента']() { + const div1 = oom('div', { class: 'test' }) + const div2 = div1.clone() + + div1('test1') + div2('test2') + + assert.notEqual(div1, div2) + assert.notEqual(div1.dom, div2.dom) + assert.equal(div1.html, '
test1
') + assert.equal(div2.html, '
test2
') + } + + /** Фрагменты документов копируются, как и элементы, включая все вложенные элементы */ + ['OOMFragment#clone - Клонирование фрагмента документа']() { + const fragment1 = oom + .div('test1') + .div('test2') + const fragment2 = fragment1.clone() + + fragment2.dom.children[0].className = 'test3' + + assert.notEqual(fragment1, fragment2) + assert.notEqual(fragment1.dom, fragment2.dom) + assert.equal(fragment1.html, '
test1
test2
') + assert.equal(fragment2.html, '
test1
test2
') + } + +} diff --git a/test/3.style.js b/test/3.style.js new file mode 100644 index 000000000..7b26fd4e2 --- /dev/null +++ b/test/3.style.js @@ -0,0 +1,272 @@ +// @ts-ignore +import { assert, Test } from '@nodutilus/test' +import { oom } from '@notml/core' + +const { document } = window + +/** Тесты генерации CSS через OOMStyle */ +export default class OOMStyle extends Test { + + /** + * Атрибут style в oom шаблоне позволяет задавать стили объектом в формате CSSStyleDeclaration. + * Что позволяет последовательно обновлять inline стили, не перетирая всё значение атрибута. + * Также можно указать style в классическом виде, как строку + */ + ['Атрибут style']() { + const div = oom.div({ style: { background: 'red' } }) + + assert.equal(div.html, '
') + + div({ style: { background: 'green', fontSize: '14px' } }) + assert.equal(div.html, '
') + + div({ style: { background: 'orange' } }) + assert.equal(div.html, '
') + + // Можно указать строкой и перезаписать весь style + div({ style: 'background: red;' }) + assert.equal(div.html, '
') + + // Для обнуления стилей можно использовать указание пустой строки + div({ style: '' }) + assert.equal(div.html, '
') + } + + /** + * Генератор стилей наследуется от базового style для сохранения оригинального tagName. + * И все обращения к style через oom шаблонизатор создают OOMStyle, что бы использовать объектную модель + */ + ['OOMStyle extends HTMLStyleElement']() { + let style + + style = oom('style') + assert.equal(style.html, '') + + style = oom('oom-style') + assert.equal(style.html, '') + + style = oom.style() + assert.equal(style.html, '') + + style = oom.oomStyle() + assert.equal(style.html, '') + } + + /** + * У тега style общий чейнинг с другими элементами, базовая верстка через oom не нарушается + */ + ['Чейнинг для style']() { + let style + + style = oom.div().style().span() + assert.equal(style.html, '
') + + style = oom.div(oom.style().span()) + assert.equal(style.html, '
') + + style = oom.div(oom.span().style()) + assert.equal(style.html, '
') + } + + /** + * Возможность создания встроенного style остается через document.createElement, + * в oom шаблоне для обычного style нет особого применения + */ + ['Создание style через document.createElement']() { + const style = oom(document.createElement('style')) + const oomStyle = oom(document.createElement('style', { is: 'oom-style' })) + + assert.equal(style.html, '') + assert.equal(oomStyle.html, '') + } + + /** + * Стили указываются объектом, где ключи это CSS селекторы, а значения объект со свойствами CSS. + * Объект со свойствами определяется в формате CSSStyleDeclaration, как и атрибут style в oom шаблоне + */ + ['Простой объект с селекторами']() { + const style = oom.style({ + 'fontSize': '10px', + '.my-class': { background: 'red', fontSize: '12px' } + }) + + document.body.innerHTML = '' + document.body.append(style.dom) + assert.equal(document.body.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * При ошибке в имени или значение CSS свойства оно игнорируется и не добавляется в верстку + */ + ['Неверные CSS свойства']() { + const style = oom.style({ + // @ts-ignore + '.my-class': { background: 1, fontSize: 'red', noName: 'noName' } + }) + + document.body.innerHTML = '' + document.body.append(style.dom) + assert.equal(document.body.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Основная фича использования стилей как объектов, это переиспользование описания стиля элементов. + * Можно выносить описание классов отдельно и переиспользовать для нескольких компонентов + */ + ['Переиспользование описания стиля']() { + /** @type {import('@notml/core').OOMStyle.StyleSource} */ + const myStyle = { + background: 'red', + fontSize: '12px' + } + const style1 = oom.style({ '.my-class1': myStyle }) + const style2 = oom.style({ '.my-class2': { alignContent: 'center', ...myStyle } }) + const style3 = oom.style({ + '.my-class3': { + 'alignContent': 'center', + '.my-class1': myStyle + } + }) + + style3({ + '.my-class4': { + '.my-class5': myStyle + } + }) + + document.body.innerHTML = '' + document.body.append(style1.dom) + document.body.append(style2.dom) + document.body.append(style3.dom) + assert.equal(document.body.innerHTML, ` + + + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Для всех стилей в коллекции можно задать общее имен области действия. + * Например для пользовательских элементов использовать имя их атрибута для стилизации + */ + ['Имя области действия для селекторов']() { + const style = oom.style('my-scope', { + 'alignItems': 'center', + '.my-class1': { background: 'red', fontSize: '11px' }, + '.my-class2': { background: 'red', fontSize: '12px' } + }) + + document.body.innerHTML = '' + document.body.append(style.dom) + assert.equal(document.body.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Имя области действия в общем случае является именем пользовательского элемента, + * и начало селектора может начинаться с данного имени, что бы сделать модификатор стиля элемента. + * При это имя области не должно повторно добавляется к имени такого селектора. + */ + ['Имя области действия в начале селектора']() { + const style = oom.style('my-scope', { + 'my-scope.active': { color: 'yellow' }, + '.active': { color: 'yellow' } + }) + + document.body.innerHTML = '' + document.body.append(style.dom) + assert.equal(document.body.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Фактически CSS переменные задаются так же как обычные свойства. + * Но на данный момент поведение нельзя полностью протестировать на сервере: + * https://github.com/jsdom/jsdom/issues/1895 + * https://github.com/jsdom/cssstyle/issues/89 + * Значение свойств вида var(--...) на данный момент не присваивается в jsdom/cssstyle, + * но фактически в браузере все работает, см webtest/css-custom-properties + */ + ['css переменные']() { + const style = oom.style({ + ':root': { + '--my-var': 'yellow', + '--my-var2': 'var(--my-var)' + }, + '.my-class': { + background: 'var(--my-var)' + } + }) + + document.body.innerHTML = '' + document.body.append(style.dom) + assert.equal(document.body.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + // Ждем реализации в jsdom/cssstyle + // assert.equal(document.body.innerHTML, ` + // + // `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * css переменные записываются на экземпляр style, + * поэтому алгоритм первичной установки и обновления может отличаться. + * Проверим обновление работает как ожидалось и OOMStyle ничего не отломал + */ + ['css переменные - перезапись']() { + const style = oom.style({ + ':root': { '--my-var': 'yellow' } + }) + + style({ ':root': { '--my-var': 'red' } }) + + document.body.innerHTML = '' + document.body.append(style.dom) + assert.equal(document.body.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + +} diff --git a/test/4.custom-elements.js b/test/4.custom-elements.js new file mode 100644 index 000000000..ee110bf4a --- /dev/null +++ b/test/4.custom-elements.js @@ -0,0 +1,944 @@ +// @ts-ignore +import { assert, Test } from '@nodutilus/test' +import { oom } from '@notml/core' + +const { HTMLElement, HTMLButtonElement, customElements, document } = window + + +/** Проверка расширенной работы пользовательских элементов */ +export default class CustomElements extends Test { + + /** Для удобства регистрации и переиспользования все опции define перенесены на класс */ + ['Регистрация пользовательского элемента']() { + /** Имя тега забирается по названию класса */ + class MyElement1 extends oom.extends(HTMLElement) { + + static tagName = 'my-element1' + + } + + /** Имя тега получается из статического свойства класса */ + class MyElement2 extends oom.extends(HTMLElement) { + + static tagName = 'me-2' + + } + + /** + * Несколько заклавных в названии класса подряд, + * при использовании его в качетстве имени тега, не разделяется + */ + class OOMElement2 extends oom.extends(HTMLElement) { + + static tagName = 'oom-element2' + + } + + /** Расширения базового тега так же выполняется через класс */ + class MyButton1 extends oom.extends(HTMLButtonElement) { + + static tagName = 'my-button1' + static extendsTagName = 'button' + + } + + oom.define(MyElement1, MyElement2, OOMElement2, MyButton1) + + assert.equal(oom(new MyElement1()).html, '') + assert.equal(oom(MyElement1).html, '') + assert.equal(oom.myElement1().html, '') + assert.equal(oom('myElement1').html, '') + assert.equal(customElements.get('my-element1'), MyElement1) + + assert.equal(oom(new MyElement2()).html, '') + assert.equal(oom(MyElement2).html, '') + assert.equal(oom['me-2']().html, '') + assert.equal(oom('me-2').html, '') + assert.equal(customElements.get('me-2'), MyElement2) + + assert.equal(oom(new OOMElement2()).html, '') + assert.equal(oom(OOMElement2).html, '') + assert.equal(oom.oomElement2().html, '') + assert.equal(oom('oomElement2').html, '') + assert.equal(customElements.get('oom-element2'), OOMElement2) + + assert.equal(oom(new MyButton1()).html, '') + assert.equal(oom(MyButton1).html, '') + assert.equal(oom.myButton1().html, '') + assert.equal(oom('myButton1').html, '') + assert.equal(customElements.get('my-button1'), MyButton1) + + assert.equal((new MyButton1()).getAttribute('is'), 'my-button1') + } + + /** Свойство template экземпляра класса используется как шаблон компонента по аналогии с одноименным тегом */ + ['Шаблон компонента в template']() { + /** Класс с шаблоном */ + class MyElement3 extends oom.extends(HTMLElement) { + + static tagName = 'my-element3' + + template = oom('div') + + } + + oom.define(MyElement3) + + const myElm3 = new MyElement3() + + assert.equal(myElm3.outerHTML, '') + + document.body.innerHTML = '' + document.body.append(myElm3) + assert.equal(document.body.innerHTML, '
') + document.body.innerHTML = '' + + assert.equal(myElm3.outerHTML, '
') + } + + /** + * При вставке шаблона определяется такой порядок что бы были 2 точки взаимодействия: + * 1-ая в constructor до применения, 2-ая в connectedCallback, после применения шаблона + * Чтобы можно было гибко управлять содержимым и использовать такие элементы как slot + */ + ['Работа с constructor, connectedCallback и template']() { + /** Шаблон + обновление верстки в конструкторе */ + class MyElement4 extends oom.extends(HTMLElement) { + + static tagName = 'my-element4' + + template = oom('div') + + /** + * На конструкторе шаблон еще не применен, + * и можно изменить внутреннюю верстку, аналогично работе через createElement + */ + constructor() { + super() + assert.equal(this.outerHTML, '') + this.innerHTML = 'test1' + } + + /** + * На обработчике вставки в DOM расширение в oom.extends вставляет шаблон, + * и можно работать с готовой версткой компонента + */ + connectedCallback() { + super.connectedCallback() // Применение template + assert.equal(this.outerHTML, 'test1
') + } + + } + + oom.define(MyElement4) + + const myElm4 = new MyElement4() + + assert.equal(myElm4.outerHTML, 'test1') + + document.body.innerHTML = '' + document.body.append(myElm4) + assert.equal(document.body.innerHTML, 'test1
') + document.body.innerHTML = '' + } + + /** + * oom.extends может работать и от пользовательского класса, например из сторонней библиотеки. + * В таком случае методы из OOMCustomElement выполняются всегда раньше, сохраняя базовое поведение. + * Однако это создает больше классов OOMCustomElement чем наследование от базовых классов + * (по 1му на каждый пользовательский, вместо 1го общего на базовый) + */ + ['Наследование через oom.extends от пользовательского класса']() { + let myE9html = '' + + /** Расширяемый класс. Может быть компонентом из другой библиотеки */ + class MyElement9 extends HTMLElement { + + /** Выполниться до построения верстки в дочернем классе, но имя тега уже будет переопределено */ + connectedCallback() { + myE9html = this.outerHTML + } + + } + + /** Наследуется от стороннего класса пользовательского элемента, при этом добавляя oom поведение */ + class MyElement10 extends oom.extends(MyElement9) { + + static tagName = 'my-element10' + + template = 'test10' + + } + + oom.define(MyElement10) + + document.body.innerHTML = '' + document.body.append(new MyElement10()) + assert.equal(myE9html, '') + assert.equal(document.body.innerHTML, 'test10') + document.body.innerHTML = '' + } + + /** Для разных типов шаблонов должна быть общая последовательность вставки */ + ['Базовые типы template']() { + const [MyElement5, MyElement6, MyElement7, MyElement8] = oom.define( + class MyElement5 extends oom.extends(HTMLElement) { + + static tagName = 'my-element5' + + template = oom('div') + + }, + class MyElement6 extends oom.extends(HTMLElement) { + + static tagName = 'my-element6' + + template = '
' + + }, + class MyElement7 extends oom.extends(HTMLElement) { + + static tagName = 'my-element7' + + template = document.createElement('div') + + }, + class MyElement8 extends oom.extends(HTMLElement) { + + static tagName = 'my-element8' + + template = oom.a().b().dom + + }) + const myElm5 = new MyElement5() + const myElm6 = new MyElement6() + const myElm7 = new MyElement7() + const myElm8 = new MyElement8() + + myElm5.innerHTML = 'test' + myElm6.innerHTML = 'test' + myElm7.innerHTML = 'test' + myElm8.innerHTML = 'test' + + document.body.innerHTML = '' + document.body.append(myElm5) + document.body.append(myElm6) + document.body.append(myElm7) + document.body.append(myElm8) + assert.equal(document.body.innerHTML, ` + test
+ test
+ test
+ test + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Шаблон элемента может быть функцией возвращающей дочерний элемент для вставки, + * или void, тогда предполагается что добавление дочерних элементов осуществляется внутри функции. + * Эта особенность позволяет создавать динамические шаблоны и влиять из функции на сам элемент. + */ + ['Функция в качестве шаблона']() { + /** Шаблон функция */ + class MyElement23 extends oom.extends(HTMLElement) { + + static tagName = 'my-element23' + + template = () => { + this.id = 'test-ok' + oom(this, oom.span('test ok')) + } + + /** Aвтоматическая вставка в DOM */ + constructor() { + super() + oom(document.body, this) + } + + } + + /** Шаблон функция */ + class MyElement24 extends oom.extends(HTMLElement) { + + static tagName = 'my-element24' + + template = () => oom.span('test ok 2') + + /** Aвтоматическая вставка в DOM */ + constructor() { + super() + oom(document.body, this) + } + + } + + oom.define(MyElement23, MyElement24) + document.body.innerHTML = '' + + const myElm23 = oom.myElement23().dom + const myElm24 = oom.myElement24().dom + + assert.equal(myElm23.outerHTML, ` + + test ok + + `.replace(/\s*\n+\s+/g, '')) + assert.equal(myElm24.outerHTML, ` + + test ok 2 + + `.replace(/\s*\n+\s+/g, '')) + assert.equal(document.body.innerHTML, ` + + test ok + + + test ok 2 + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Функция построения шаблона может быть асинхронной, + * в этом случае содержимое компонента обновляется по завершению Promise, либо непосредственно из функции. + * Отображение заглушки на загрузку остается за автором пользовательского элемента. + * Необработанная ошибка шаблона выводится прямо в тело компонента, + * прикладной шаблон должен сам позаботится об отображении, а вывод является последней фатальной мерой. + * Асинхронная загрузка не блокирует компоненты которые строятся после данного шаблона, + * синхронизация состояний задача пользовательского шаблона + */ + async ['Асинхронная функция в качестве шаблона']() { + /** Асинхроный шаблон */ + class MyElement26 extends oom.extends(HTMLElement) { + + static tagName = 'my-element26' + + template = async () => { + const span = oom.span('async') + + await new Promise(resolve => setTimeout(resolve)) + + return span + } + + } + + oom.define(MyElement26) + + const myE26 = oom.myElement26() + + document.body.innerHTML = '' + oom(document.body, myE26.span('sync')) + + assert.equal(document.body.innerHTML, ` + + sync + `.replace(/\s*\n+\s+/g, '')) + + const awaitedMyE26 = await myE26 + + assert.equal(awaitedMyE26, myE26) + assert.equal(document.body.innerHTML, ` + async + sync + `.replace(/\s*\n+\s+/g, '')) + + document.body.innerHTML = '' + } + + /** Как и для базовых типов шаблонов, для асинхронных, должна быть общая последовательность вставки */ + async ['Асинхронные типы template']() { + const [MyElement27, MyElement28, MyElement29, MyElement30] = oom.define( + class MyElement27 extends oom.extends(HTMLElement) { + + static tagName = 'my-element27' + + template = async () => { + await new Promise(resolve => setTimeout(resolve)) + + return oom('div') + } + + }, + class MyElement28 extends oom.extends(HTMLElement) { + + static tagName = 'my-element28' + + template = async () => { + await new Promise(resolve => setTimeout(resolve)) + + return '
' + } + + }, + class MyElement29 extends oom.extends(HTMLElement) { + + static tagName = 'my-element29' + + template = async () => { + await new Promise(resolve => setTimeout(resolve)) + + return document.createElement('div') + } + + }, + class MyElement30 extends oom.extends(HTMLElement) { + + static tagName = 'my-element30' + + template = async () => { + await new Promise(resolve => setTimeout(resolve)) + + return oom.a().b().dom + } + + } + ) + const myElm27 = new MyElement27() + const myElm28 = new MyElement28() + const myElm29 = new MyElement29() + const myElm30 = new MyElement30() + + + myElm27.innerHTML = 'test' + myElm28.innerHTML = 'test' + myElm29.innerHTML = 'test' + myElm30.innerHTML = 'test' + + // До вставки в DOM Promise нет, т.к. компоненты еще не начали строятся + await Promise.all([oom(myElm27), oom(myElm28), oom(myElm29), oom(myElm30)]) + + document.body.innerHTML = '' + document.body.append(myElm27) + document.body.append(myElm28) + document.body.append(myElm29) + document.body.append(myElm30) + + assert.equal(document.body.innerHTML, ` + test + test + test + test + `.replace(/\s*\n+\s+/g, '')) + + await Promise.all([oom(myElm27), oom(myElm28), oom(myElm29), oom(myElm30)]) + + assert.equal(document.body.innerHTML, ` + test
+ test
+ test
+ test + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Ошибка в асинхронном шаблоне не должна приводить к падению построения всей страницы. + * Все необработанные компонентом ошибки добавляются в виде текста сообщения в вертску компонента. + * Это финальная проверки на ошибки, в правильном приложении все ошибки обрабатывают сами компоненты + */ + async ['Ошибка в асинхронном шаблоне']() { + /** Компонент с ошибкой */ + class MyAsyncError1 extends oom.extends(HTMLElement) { + + static tagName = 'my-async-error1' + + template = async () => { + await new Promise(resolve => setTimeout(resolve)) + oom(this, oom.span('test1')) + throw new Error('test2') + } + + } + + /** Компонент с ошибкой (строка) */ + class MyAsyncError2 extends oom.extends(HTMLElement) { + + static tagName = 'my-async-error2' + + template = async () => { + await new Promise(resolve => setTimeout(resolve)) + oom(this, oom.span('test1')) + // eslint-disable-next-line + throw 'test2' + } + + } + + oom.define(MyAsyncError1, MyAsyncError2) + + const myErr1 = oom.myAsyncError1() + const myErr2 = oom.myAsyncError2() + + document.body.innerHTML = '' + oom(document.body, myErr1, myErr2) + + assert.equal(document.body.innerHTML, ` + + + `.replace(/\s*\n+\s+/g, '')) + + const awaitedMyErr1 = await myErr1 + const awaitedMyErr2 = await myErr2 + + assert.equal(awaitedMyErr1, myErr1) + assert.equal(awaitedMyErr2, myErr2) + + assert.ok(myErr1.dom.innerHTML.includes('test1Error: test2')) + assert.ok(myErr1.dom.innerHTML.includes('at MyAsyncError1.template')) + assert.equal(myErr2.dom.outerHTML.replace(/\s*\n+\s+/g, ''), ` + + test1 + test2 + + `.replace(/\s*\n+\s+/g, '')) + + document.body.innerHTML = '' + } + + /** + * В качестве описания структуры опций можно использовать простые объекты и массивы. + * - Объекты будут клонированы и объединены с опциями по умолчанию в новые объекты. + * - Массивы из опций по умолчанию будет полностью заменены копиями массивов указанными в опциях. + * - Примитивные значения опций передаются по значению. + * - Сложные объекты, такие как Date, RegExp, пр., и пользовательские классы, будут переданы по ссылке. + * Такое поведение позволит создавать структурированные опции, + * и использовать в качестве опций DOM элементы для создания агрегаций + */ + ['Опции: объект']() { + let mye11, mye12 + + /** Без опций по умолчанию */ + class MyElement11 extends oom.extends(HTMLElement) { + + static tagName = 'my-element11' + + } + + oom.define(MyElement11) + + // Без указания опций + mye11 = new MyElement11() + assert.deepEqual(mye11.options, {}) + + // Можно передать любые опции + mye11 = new MyElement11({ a: 1, b11: { c11: 2 } }) + assert.deepEqual(mye11.options, { a: 1, b11: { c11: 2 } }) + assert.equal(mye11.options.a, 1) + assert.equal(mye11.options.b11.c11, 2) + + /** С опцией по умолчанию */ + class MyElement12 extends oom.extends(HTMLElement, { a: 'test', b12: { c12: 2 } }) { + + static tagName = 'my-element12' + + } + + oom.define(MyElement12) + + // Без указания опций, вернется по умолчанию + mye12 = new MyElement12() + assert.equal(mye12.options.a, 'test') + assert.equal(mye12.options.b12.c12, 2) + assert.deepEqual(MyElement12.optionsDefaults, { a: 'test', b12: { c12: 2 } }) + + // Обновлениен опций + mye12 = new MyElement12({ a: 'update', b12: { c12: 3 } }) + assert.equal(mye12.options.a, 'update') + assert.equal(mye12.options.b12.c12, 3) + assert.deepEqual(MyElement12.optionsDefaults, { a: 'test', b12: { c12: 2 } }) + } + + /** Опции могут являться массивом, содержимое массивов и вложенные объекты копируются */ + ['Опции: массив']() { + const dOptions = ['defaults'] + const options = ['ok'] + + /** Опции в виде массива */ + class MyElement15 extends oom.extends(HTMLElement, dOptions) { + + static tagName = 'my-element15' + + } + + oom.define(MyElement15) + + const myE15 = new MyElement15(options) + + dOptions.push('1') + options.push('2') + + assert.deepEqual(myE15.options, ['ok']) + assert.deepEqual(MyElement15.optionsDefaults, ['defaults']) + assert.deepEqual(dOptions, ['defaults', '1']) + assert.deepEqual(options, ['ok', '2']) + } + + /** + * Сложные объекты считаем уникальными и передаются по ссылке, например дата или пользовательские классы. + * При этом сложные объекты не подвергается заморозке, чтобы не нарушить их логику работы + */ + ['Опции: сложные объекты']() { + /** Пользовательский класс опций */ + class COptions { c16 = new Date() } + + const dOptions = { a16: null, b16: new Date() } + const options = { a16: new COptions() } + + /** Сложные объекты в опциях */ + class MyElement16 extends oom.extends(HTMLElement, dOptions) { + + static tagName = 'my-element16' + + } + + oom.define(MyElement16) + + const myE16 = new MyElement16(options) + + assert.ok(myE16.options.a16 === options.a16) + assert.ok(myE16.options.a16.c16 === options.a16.c16) + assert.ok(myE16.options.b16 === dOptions.b16) + + myE16.options.a16.e16 = 1 + // @ts-ignore + myE16.options.b16.e16 = 2 + + assert.ok(myE16.options.b16.getTime() > 0) + myE16.options.b16.setTime(0) + + assert.equal(myE16.options.a16.e16, 1) + // @ts-ignore + assert.equal(myE16.options.b16.e16, 2) + assert.equal(myE16.options.b16.getTime(), 0) + } + + + /** Опции запрещено редактировать */ + ['Опции: readonly']() { + const optionsDefaults = { b13: { c13: 2 }, d13: [1, 2, 3] } + + /** объект + объект в объекте */ + class MyElement13 extends oom.extends(HTMLElement, optionsDefaults) { + + static tagName = 'my-element13' + + } + + oom.define(MyElement13) + + let err + const mye13 = new MyElement13() + + try { + // @ts-ignore + mye13.options = {} + } catch (error) { err = error } + assert.equal(err.message, "Cannot assign to read only property 'options' of object '#'") + try { + // @ts-ignore + mye13.options.a13 = {} + } catch (error) { err = error } + assert.equal(err.message, 'Cannot add property a13, object is not extensible') + try { + // @ts-ignore + mye13.options.b13.c13 = {} + } catch (error) { err = error } + assert.equal(err.message, "Cannot assign to read only property 'c13' of object '#'") + try { + // @ts-ignore + mye13.options.b13.e13 = {} + } catch (error) { err = error } + assert.equal(err.message, 'Cannot add property e13, object is not extensible') + try { + // @ts-ignore + mye13.options.d13.push(4) + } catch (error) { err = error } + assert.equal(err.message, 'Cannot add property 3, object is not extensible') + // Опции по умолчанию также неизменяемые + try { + // @ts-ignore + MyElement13.optionsDefaults = {} + } catch (error) { err = error } + assert.ok(err.message.startsWith("Cannot assign to read only property 'optionsDefaults' of function 'class MyElement13")) + try { + MyElement13.optionsDefaults.b13.c13 = 4 + } catch (error) { err = error } + assert.equal(err.message, "Cannot assign to read only property 'c13' of object '#'") + try { + // @ts-ignore + MyElement13.optionsDefaults.b13.cd13 = {} + } catch (error) { err = error } + assert.equal(err.message, 'Cannot add property cd13, object is not extensible') + } + + /** + * Объект передаваемый в качестве значения опций по умолчанию или опций остается неизменным, + * внутри компонента опции копируются, и внешний объект можно использовать повторно + */ + ['Опции: копирование']() { + const dOptions = { a14: 1, b14: { c14: 1 } } + const options = { a14: 2, d14: [{ e14: 0 }, 2, 3] } + + /** объект + объект в объекте */ + class MyElement14 extends oom.extends(HTMLElement, dOptions) { + + static tagName = 'my-element14' + + } + + oom.define(MyElement14) + + const myE14 = new MyElement14(options) + + dOptions.b14.c14 = 3 + options.a14 = 4 + options.d14.push(4) + // @ts-ignore + options.d14[0].e14 = -1 + + assert.deepEqual(myE14.options, { a14: 2, b14: { c14: 1 }, d14: [{ e14: 0 }, 2, 3] }) + assert.deepEqual(dOptions, { a14: 1, b14: { c14: 3 } }) + assert.deepEqual(options, { a14: 4, d14: [{ e14: -1 }, 2, 3, 4] }) + } + + /** + * При наследовании классов расширенных через oom, опции по умолчанию тоже расширяются + */ + ['Опции: наследование optionsDefaults']() { + /** Базовый класс */ + class MyElement17 extends oom.extends(HTMLElement, { a17: 1, c17: 1 }) { } + + /** Класс наследник */ + class MyElement18 extends oom.extends(MyElement17, { b18: 1, c17: 2 }) { } + + assert.deepEqual(MyElement17.optionsDefaults, { a17: 1, c17: 1 }) + assert.deepEqual(MyElement18.optionsDefaults, { a17: 1, b18: 1, c17: 2 }) + } + + /** + * При расширении через наследование опции компонента пробрасываются в конструктор родителя. + * Опции собираются в первом OOMCustomElement, и в родительский конструктор прокидываются только на чтение + */ + ['Опции: доступ к опциям в parent и child']() { + /** Базовый класс */ + class MyElement19 extends oom.extends(HTMLElement, { name: 'testName' }) { + + static tagName = 'my-element19' + + template = oom.div({ class: 'field' }, this.options.name) + + } + + /** Класс наследник */ + class MyElement20 extends oom.extends(MyElement19, { label: 'testLabel' }) { + + static tagName = 'my-element20' + + template = oom.span({ class: 'label' }, + oom.span(this.options.label, { + class: 'label__title' + }), + this.template) + + } + + oom.define(MyElement19, MyElement20) + + const myE19 = new MyElement19() + const myE20 = new MyElement20() + + document.body.innerHTML = '' + document.body.append(myE19) + document.body.append(myE20) + assert.equal(document.body.innerHTML, ` + +
testName
+
+ + + testLabel +
testName
+
+
+ `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + + const myE20v2 = new MyElement20({ label: 'label2', name: 'name2' }) + + document.body.append(myE20v2) + assert.equal(document.body.innerHTML, ` + + + label2 +
name2
+
+
+ `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * При передаче зацикленных полей в options + * необходимо вовремя выходить из рекурсии чтобы избежать падения с ошибкой. + * Цикличные ссылки в опциях заменяются на undefined без изменения исходного объекта + */ + ['Опции: бесконечная рекурсия в resolveOptions']() { + const options = { test1: {} } + + /** Бесконечная рекурсия */ + class MyElement25 extends oom.extends(HTMLElement) { + + static tagName = 'my-element25' + + } + + oom.define(MyElement25) + + options.test2 = options + options.test1.test3 = options + + const myE25 = new MyElement25(options) + + assert.equal(myE25.options.test2, undefined) + assert.equal(myE25.options.test1.test3, undefined) + + assert.equal(options.test2, options) + assert.equal(options.test1.test3, options) + + delete options.test2 + delete options.test1.test3 + } + + /** + * Стили компонентов определяются в статическом свойстве класса style, + * откуда при регистрации пользовательского элемента добавляются в документ в состав . + * Стили автоматически привязываются к имени тега элемента, что позволяет избежать пересечений имен классов + */ + ['Глобальные стили компонента']() { + /** Компонент с глобальными стилями */ + class MyElement21 extends oom.extends(HTMLElement) { + + static tagName = 'my-element21' + static style = oom.style({ + 'my-element21': { fontSize: '12px' }, + 'my-element21.green': { color: 'green' }, + '.small': { fontSize: '8px' } + }) + + template = oom.span('text', { class: 'small' }) + + } + + document.head.innerHTML = '' + oom.define(MyElement21) + document.body.innerHTML = '' + document.body.append(new MyElement21()) + + assert.equal(document.getElementsByTagName('html')[0].outerHTML, ` + + + + + + + text + + + + `.replace(/\s*\n+\s+/g, '')) + + document.head.innerHTML = '' + document.body.innerHTML = '' + } + + /** + * Для пользовательского элемента можно задать собственный класс, + * который не будет стираться обновлением атрибута через шаблонизатор oom. + * Это дает возможность удобно навешивать на элементы обязательные классы из внешних библиотек + */ + ['Класс элемента в static className']() { + /** Компонент с собственным именем класса */ + class MyElement22 extends oom.extends(HTMLElement) { + + static tagName = 'my-element22' + static className = 'my-class22' + + } + + oom.define(MyElement22) + + const mye22 = new MyElement22() + + assert.equal(mye22.outerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + + oom(mye22, { class: 'test1' }) + assert.equal(mye22.outerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + + oom(mye22, { class: 'test2' }) + assert.equal(mye22.outerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + } + + /** Тест примера из в extends из types.d.ts */ + ['types.d.ts - example for extends']() { + /** + * @typedef OptionsDefaults + * @property {string} [caption] Надпись на кнопке + */ + /** @type {OptionsDefaults} */ + const optionsDefaults = { caption: '' } + + /** Тестовая кнопка */ + class MyButton extends oom.extends(HTMLButtonElement, optionsDefaults) { + + static tagName = 'my-butt' + static extendsTagName = 'button' + + static style = oom.style({ + 'button[is="my-butt"]': { fontSize: '12px' }, + 'button[is="my-butt"].active': { color: 'yellow' }, + '.my-butt__caption': { color: 'red' } + }) + + template = oom.span({ class: 'my-butt__caption' }, this.options.caption) + + } + + document.head.innerHTML = '' + oom.define(MyButton) + document.body.innerHTML = '' + document.body.append(new MyButton({ caption: 'Жми тут' })) + assert.equal(document.getElementsByTagName('html')[0].outerHTML, ` + + + + + + + + + `.replace(/\s*\n+\s+/g, '')) + + document.head.innerHTML = '' + document.body.innerHTML = '' + } + +} diff --git a/test/5.attach-shadow.js b/test/5.attach-shadow.js new file mode 100644 index 000000000..f3a603f7e --- /dev/null +++ b/test/5.attach-shadow.js @@ -0,0 +1,203 @@ +// @ts-ignore +import { assert, Test } from '@nodutilus/test' +import { oom } from '@notml/core' + +const { document, HTMLElement } = window + + +/** Проверка работы ShadowRoot в составе CustomElements */ +export default class AttachShadow extends Test { + + /** + * Включение теневого дом выполняется свойством attachShadow на классе элемента. + * Может быть указано как boolean | ShadowRootInit + */ + ['Подключение теневого DOM']() { + /** Открытый теневой DOM */ + class MyShadow1 extends oom.extends(HTMLElement) { + + static tagName = 'my-shadow1' + static attachShadow = true + + template = oom.span('MyShadow1') + + } + + /** Закрытый теневой DOM */ + class MyShadow2 extends oom.extends(HTMLElement) { + + static tagName = 'my-shadow2' + static attachShadow = { mode: 'closed' } + + template = oom.span('MyShadow2') + + } + + oom.define(MyShadow1, MyShadow2) + + const myShadow1 = new MyShadow1() + const myShadow2 = new MyShadow2() + + document.body.innerHTML = '' + document.body.append(myShadow1) + document.body.append(myShadow2) + + assert.equal(document.body.innerHTML, '') + assert.equal(myShadow1.shadowRoot.constructor.name, 'ShadowRoot') + assert.equal(myShadow1.shadowRoot.innerHTML, 'MyShadow1') + assert.equal(myShadow2.shadowRoot, null) + + document.body.innerHTML = '' + } + + /** + * Стили компонентов при вставке в теневой DOM копируются, + * и вставляются перед первым использованием компонента. + * При удалении компонента стили остаются в корне shadowRoot, аналогично стилям в основном дереве, + * и при повторной вставке стили не дублируются. + */ + ['Копирование Style в состав shadowRoot']() { + /** Компонент со стилями 1 */ + class MySpan1 extends oom.extends(HTMLElement) { + + static tagName = 'my-span1' + static style = oom.style({ + '.my-span1_title': { background: 'red' } + }) + + template = oom.span({ class: '.my-span1_title' }) + + } + + /** Компонент со стилями 2 */ + class MySpan2 extends oom.extends(HTMLElement) { + + static tagName = 'my-span2' + static style = oom.style({ + '.my-span2_title': { background: 'green' } + }) + + template = oom.span({ class: '.my-span2_title' }) + + } + + /** Теневой дом содержащий внутри компонент */ + class MyShadow3 extends oom.extends(HTMLElement) { + + static tagName = 'my-shadow3' + static attachShadow = true + + template = oom()(new MySpan1(), new MySpan2()) + + } + + oom.define(MySpan1, MySpan2, MyShadow3) + + const myShadow3 = new MyShadow3() + + document.body.innerHTML = '' + document.body.append(myShadow3) + + assert.equal(document.documentElement.innerHTML, ` + + + + + + + + `.replace(/\s*\n+\s+/g, '')) + assert.equal(myShadow3.shadowRoot.innerHTML, ` + + + + + + + + + + + `.replace(/\s*\n+\s+/g, '')) + + myShadow3.shadowRoot.lastChild.remove() + myShadow3.shadowRoot.lastChild.remove() + + assert.equal(myShadow3.shadowRoot.innerHTML, ` + + + + + `.replace(/\s*\n+\s+/g, '')) + + oom(myShadow3.shadowRoot, new MySpan1(), new MySpan2()) + + assert.equal(myShadow3.shadowRoot.innerHTML, ` + + + + + + + + + + + `.replace(/\s*\n+\s+/g, '')) + + document.head.innerHTML = '' + document.body.innerHTML = '' + } + + /** + * При работе с теневым DOM для добавления элементов в DOM в функцию шаблон передается корень теневого DOM. + * В случае с закрытым теневым DOM это позволит получить к нему доступ из функции шаблона. + * this сам компонент, а root теневой DOM + */ + ['Функция шаблон и теневой DOM']() { + /** Шаблон-функция теневого DOM */ + class MyShadow4 extends oom.extends(HTMLElement) { + + static tagName = 'my-shadow4' + static attachShadow = true + + template = (/** @type {ShadowRoot} */ root) => { + oom(root, oom.span('test root')) + } + + } + + oom.define(MyShadow4) + document.body.innerHTML = '' + + const myE26 = new MyShadow4() + + document.body.append(myE26) + + assert.equal(myE26.shadowRoot.innerHTML, ` + test root + `.replace(/\s*\n+\s+/g, '')) + + assert.equal(document.body.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + +} diff --git a/test/6.container-elements.js b/test/6.container-elements.js new file mode 100644 index 000000000..44522593f --- /dev/null +++ b/test/6.container-elements.js @@ -0,0 +1,212 @@ +// @ts-ignore +import { assert, Test } from '@nodutilus/test' +import { oom } from '@notml/core' + +const { document, HTMLElement } = window + + +/** Проверка работы контейнерных элементов */ +export default class ContainerElements extends Test { + + /** + * В простом варианте контейнер не содержит собственной верстки + */ + ['Контейнер без собственной верстки']() { + /** Контейнер без верстки */ + class MyContainer1 extends oom.extends(HTMLElement) { + + static tagName = 'my-container1' + + } + + oom.define(MyContainer1) + + document.body.innerHTML = '' + + oom(document.body, oom.myContainer1(oom.span('test myContainer1'))) + + assert.equal(document.body.innerHTML, ` + + test myContainer1 + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * Компонент может иметь контентные опции, используемые при описание верстки шаблона + */ + ['Контентные опции']() { + /** Контейнер с контентными опциями */ + class MyContainer2 extends oom.extends(HTMLElement) { + + static tagName = 'my-container2' + + template = oom + .div(this.options.span1) + .div(this.options.span2) + + } + + oom.define(MyContainer2) + + document.body.innerHTML = '' + + oom(document.body, new MyContainer2({ + span1: oom.span('test myContainer2 span1'), + span2: oom.span('test myContainer2 span2') + })) + + assert.equal(document.body.innerHTML, ` + +
+ test myContainer2 span1 +
+
+ test myContainer2 span2 +
+
+ `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + + /** + * В сочетании с теневым DOM можно использовать заполнение контента через слоты + */ + ['Слоты теневого DOM']() { + /** Контейнер со слотами */ + class MyContainer3 extends oom.extends(HTMLElement) { + + static tagName = 'my-container3' + static attachShadow = true + + template = oom + .div(oom.slot({ name: 'title' })) + .div(oom.slot({ name: 'field' })) + + } + + oom.define(MyContainer3) + + const myC3 = new MyContainer3() + const myTitle = oom.span('my-title', { slot: 'title' }) + const myField = oom.span('my-field', { slot: 'field' }) + + document.body.innerHTML = '' + + oom(document.body, oom(myC3, myTitle, myField)) + + /** @type {HTMLSlotElement} */ + // @ts-ignore + const slotTitle = myC3.shadowRoot.firstChild.firstChild + /** @type {HTMLSlotElement} */ + // @ts-ignore + const slotField = myC3.shadowRoot.lastChild.firstChild + + assert.equal(document.body.innerHTML, ` + + my-title + my-field + + `.replace(/\s*\n+\s+/g, '')) + assert.equal(myC3.shadowRoot.innerHTML, ` +
+ +
+
+ +
+ `.replace(/\s*\n+\s+/g, '')) + assert.equal(myTitle.dom, slotTitle.assignedElements()[0]) + assert.equal(myField.dom, slotField.assignedElements()[0]) + + document.body.innerHTML = '' + } + + /** + * Слот по умолчанию работает как и в базовой реализации теневого DOM + */ + ['Слот по умолчанию (первый без имени)']() { + /** Контейнер со слотом по умолчанию */ + class MyContainer4 extends oom.extends(HTMLElement) { + + static tagName = 'my-container4' + static attachShadow = true + + template = oom.div(oom.slot()) + + } + + oom.define(MyContainer4) + + const myC4 = new MyContainer4() + const mySpan = oom.span('my-span') + + document.body.innerHTML = '' + + oom(document.body, oom(myC4, mySpan)) + + /** @type {HTMLSlotElement} */ + // @ts-ignore + const slot = myC4.shadowRoot.firstChild.firstChild + + assert.equal(document.body.innerHTML, ` + + my-span + + `.replace(/\s*\n+\s+/g, '')) + assert.equal(myC4.shadowRoot.innerHTML, ` +
+ +
+ `.replace(/\s*\n+\s+/g, '')) + assert.equal(mySpan.dom, slot.assignedElements()[0]) + + document.body.innerHTML = '' + } + + /** + * При помощи теневого дом и шаблона функции можно создать компонент, + * который в рамках шаблона будет работать сразу с 2мя деревьями, основным и теневым. + * this сам компонент, а root теневой DOM + */ + ['Функция шаблон, основной и теневой DOM']() { + /** Шаблон-функция теневого DOM */ + class MyContainer5 extends oom.extends(HTMLElement) { + + static tagName = 'my-container5' + static attachShadow = true + + template = (/** @type {ShadowRoot} */ root) => { + oom(root, oom.slot()) + oom(this, oom.span('test root')) + } + + } + + oom.define(MyContainer5) + document.body.innerHTML = '' + + const myС5 = new MyContainer5() + + document.body.append(myС5) + + /** @type {HTMLSlotElement} */ + // @ts-ignore + const slot = myС5.shadowRoot.firstChild + + assert.equal(myС5.firstChild, slot.assignedElements()[0]) + + assert.equal(myС5.shadowRoot.innerHTML, ` + + `.replace(/\s*\n+\s+/g, '')) + + assert.equal(document.body.innerHTML, ` + + test root + + `.replace(/\s*\n+\s+/g, '')) + document.body.innerHTML = '' + } + +} diff --git a/test/emulateDOM.js b/test/emulateDOM.js new file mode 100644 index 000000000..0e57b3b62 --- /dev/null +++ b/test/emulateDOM.js @@ -0,0 +1,10 @@ +import jsdom from 'jsdom' + +const { JSDOM } = jsdom +const { window } = new JSDOM('', { + url: 'https://github.com/nodutilus/notml', + runScripts: 'dangerously' +}) + + +global.window = window diff --git a/test/index.js b/test/index.js new file mode 100644 index 000000000..04f119ada --- /dev/null +++ b/test/index.js @@ -0,0 +1,29 @@ +import './emulateDOM.js' +// @ts-ignore +import { Test } from '@nodutilus/test' +import BasicBehavior from './1.basic-behavior.js' +import BasicAPI from './2.basic-api.js' +import OOMStyle from './3.style.js' +import CustomElements from './4.custom-elements.js' +import AttachShadow from './5.attach-shadow.js' +import ContainerElements from './6.container-elements.js' + +// import TestOOM from './oom.js' + + +/** Общий тестовый класс */ +class TestNotMLCore extends Test { + + static ['Базовое поведение'] = BasicBehavior + static ['Базовое API для OOM элементов'] = BasicAPI + static ['Генератор CSS in JS'] = OOMStyle + static ['Пользовательские элементы'] = CustomElements + static ['Теневой DOM'] = AttachShadow + static ['Контейнерные элементы'] = ContainerElements + + // static TestOOM = TestOOM + +} + + +Test.runOnCI(new TestNotMLCore()) diff --git a/test/mem.js b/test/mem.js new file mode 100644 index 000000000..35ccc9889 --- /dev/null +++ b/test/mem.js @@ -0,0 +1,30 @@ +import '../test/emulateDOM.js' +import { memoryUsage } from 'process' +import { oom } from '../@notml/core/core.js' + +let curRSS = 0 +let maxRSS = 0 + +for (let index = 0; index < 10000; index++) { + oom.div() +} + +const initRSS = memoryUsage().rss +const wait = () => { return new Promise(resolve => { setTimeout(resolve) }) } + +(async () => { + for (let index = 0; index < 10000; index++) { + for (let index = 0; index < 10000; index++) { + oom.div() + } + + // node --expose_gc test/mem + global.gc() + + await wait() + curRSS = memoryUsage().rss + curRSS = curRSS / initRSS * 100 ^ 0 + maxRSS = Math.max(maxRSS, curRSS) + console.log(curRSS, '%', '/', maxRSS, '%') + } +})() diff --git a/test/pre-test.js b/test/pre-test.js new file mode 100644 index 000000000..d6311397c --- /dev/null +++ b/test/pre-test.js @@ -0,0 +1,6 @@ +import { mkdirSync, symlinkSync, existsSync } from 'fs' + +mkdirSync('test/node_modules', { recursive: true }) +if (!existsSync('test/node_modules/@notml')) { + symlinkSync('../../@notml', 'test/node_modules/@notml', 'dir') +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..3d193262f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@nodutilus/project-config/tsconfig", + "exclude": [ + "_deprecated/**", + "coverage/**", + "@notml/notml/**" + ] +} diff --git a/webtest/compatible-min.html b/webtest/compatible-min.html new file mode 100644 index 000000000..3b1b75a55 --- /dev/null +++ b/webtest/compatible-min.html @@ -0,0 +1,13 @@ + + + + + + + Compatible NotML Core (min) + + + + NotML is compatible (min) + + diff --git a/webtest/compatible.html b/webtest/compatible.html new file mode 100644 index 000000000..1e75e6f10 --- /dev/null +++ b/webtest/compatible.html @@ -0,0 +1,13 @@ + + + + + + + Compatible NotML Core + + + + NotML is compatible + + diff --git a/webtest/core-from-cdn-min.html b/webtest/core-from-cdn-min.html new file mode 100644 index 000000000..5f23b317b --- /dev/null +++ b/webtest/core-from-cdn-min.html @@ -0,0 +1,14 @@ + + + + + + + NotML Core - from CDN (All-in-one - minimized) + + + + + + + diff --git a/webtest/core-from-cdn-min.js b/webtest/core-from-cdn-min.js new file mode 100644 index 000000000..10fd9a073 --- /dev/null +++ b/webtest/core-from-cdn-min.js @@ -0,0 +1,12 @@ +import { oom } from 'https://cdn.jsdelivr.net/npm/notml@latest/core.min.js' + +const style = oom + .style({ + '.test': { background: 'darkorange' }, + '.label': { color: 'darkgreen' } + }) + +oom(document.head, style) +oom(document.body, oom + .div({ class: 'test' }, oom + .span('NotML Core - from CDN (All-in-one - minimized)', { class: 'label' }))) diff --git a/webtest/core-from-cdn-src.html b/webtest/core-from-cdn-src.html new file mode 100644 index 000000000..9d173ca8c --- /dev/null +++ b/webtest/core-from-cdn-src.html @@ -0,0 +1,14 @@ + + + + + + + NotML Core - from CDN (Source) + + + + + + + diff --git a/webtest/core-from-cdn-src.js b/webtest/core-from-cdn-src.js new file mode 100644 index 000000000..40b6d8d31 --- /dev/null +++ b/webtest/core-from-cdn-src.js @@ -0,0 +1,11 @@ +import { oom } from 'https://cdn.jsdelivr.net/npm/@notml/core@latest/core.js' + + +oom(document.head, oom + .style({ + '.test': { background: 'darkorange' }, + '.label': { color: 'darkgreen' } + })) +oom(document.body, oom + .div({ class: 'test' }, oom + .span('NotML Core - from CDN (Source)', { class: 'label' }))) diff --git a/webtest/core-from-cdn.html b/webtest/core-from-cdn.html new file mode 100644 index 000000000..02115f510 --- /dev/null +++ b/webtest/core-from-cdn.html @@ -0,0 +1,14 @@ + + + + + + + NotML Core - from CDN (All-in-one) + + + + + + + diff --git a/webtest/core-from-cdn.js b/webtest/core-from-cdn.js new file mode 100644 index 000000000..a9e600d0d --- /dev/null +++ b/webtest/core-from-cdn.js @@ -0,0 +1,11 @@ +import { oom } from 'https://cdn.jsdelivr.net/npm/notml@latest/core.js' + + +oom(document.head, oom + .style({ + '.test': { background: 'darkorange' }, + '.label': { color: 'darkgreen' } + })) +oom(document.body, oom + .div({ class: 'test' }, oom + .span('NotML Core - from CDN (All-in-one)', { class: 'label' }))) diff --git a/webtest/core-min.html b/webtest/core-min.html new file mode 100644 index 000000000..0073ecbc8 --- /dev/null +++ b/webtest/core-min.html @@ -0,0 +1,14 @@ + + + + + + + NotML Core - CDN lib (min) + + + + + + + diff --git a/webtest/core-min.js b/webtest/core-min.js new file mode 100644 index 000000000..f993a9698 --- /dev/null +++ b/webtest/core-min.js @@ -0,0 +1,10 @@ +import { oom } from '../@notml/notml/core.min.js' + +oom(document.head, oom + .style({ + '.test': { background: 'darkorange' }, + '.label': { color: 'darkgreen' } + })) +oom(document.body, oom + .div({ class: 'test' }, oom + .span('NotML Core - CDN lib (min)', { class: 'label' }))) diff --git a/webtest/core.html b/webtest/core.html new file mode 100644 index 000000000..501d87045 --- /dev/null +++ b/webtest/core.html @@ -0,0 +1,14 @@ + + + + + + + NotML Core - CDN lib + + + + + + + diff --git a/webtest/core.js b/webtest/core.js new file mode 100644 index 000000000..aef0d0f03 --- /dev/null +++ b/webtest/core.js @@ -0,0 +1,10 @@ +import { oom } from '../@notml/notml/core.js' + +oom(document.head, oom + .style({ + '.test': { background: 'darkorange' }, + '.label': { color: 'darkgreen' } + })) +oom(document.body, oom + .div({ class: 'test' }, oom + .span('NotML Core - CDN lib', { class: 'label' }))) diff --git a/webtest/css-custom-properties.html b/webtest/css-custom-properties.html new file mode 100644 index 000000000..23a3d2224 --- /dev/null +++ b/webtest/css-custom-properties.html @@ -0,0 +1,14 @@ + + + + + + + NotML Core - CSS Custom Properties + + + + + + + diff --git a/webtest/css-custom-properties.js b/webtest/css-custom-properties.js new file mode 100644 index 000000000..be2a1cc4c --- /dev/null +++ b/webtest/css-custom-properties.js @@ -0,0 +1,20 @@ +import { oom } from '../@notml/core/core.js' + +const style = oom.style({ + ':root': { + '--bg-color': 'red', + '--lbl-color': 'darkgreen' + }, + '.test': { + background: 'var(--bg-color)' + }, + '.label': { color: 'var(--lbl-color)' } +}) + + +style({ ':root': { '--bg-color': 'darkorange' } }) + +oom(document.head, style) +oom(document.body, oom + .div({ class: 'test' }, oom + .span('NotML Core - CSS Custom Properties', { class: 'label' }))) diff --git a/webtest/custom-elements-min.html b/webtest/custom-elements-min.html new file mode 100644 index 000000000..69996a041 --- /dev/null +++ b/webtest/custom-elements-min.html @@ -0,0 +1,14 @@ + + + + + + + NotML Core - ContainerElements (min) + + + + + + + diff --git a/webtest/custom-elements-min.js b/webtest/custom-elements-min.js new file mode 100644 index 000000000..b0b7f480e --- /dev/null +++ b/webtest/custom-elements-min.js @@ -0,0 +1,18 @@ +import { oom } from '../@notml/notml/core.min.js' + + +const { HTMLElement, document } = window + + +/** Тестовый класс со стилями */ +class MySpan extends oom.extends(HTMLElement) { + + static tagName = 'my-span' + static style = oom.style({ '.my_span': { background: 'darkorange' } }) + + template = oom.span('darkorange', { class: 'my_span' }) + +} + +oom.define(MySpan) +oom(document.body, new MySpan()) diff --git a/webtest/iframe-isolation.html b/webtest/iframe-isolation.html new file mode 100644 index 000000000..74dab1b66 --- /dev/null +++ b/webtest/iframe-isolation.html @@ -0,0 +1,16 @@ + + + + + + + NotML Core - iframe isolation + + + + + + + + + diff --git a/webtest/iframe-isolation.js b/webtest/iframe-isolation.js new file mode 100644 index 000000000..21df7ad14 --- /dev/null +++ b/webtest/iframe-isolation.js @@ -0,0 +1,24 @@ +import { oom } from '../@notml/core/core.js' + +const { document } = window +/** @type {HTMLIFrameElement} */ +// @ts-ignore +const frame = document.getElementById('iframe') + +if (frame) { + const fDoc = frame.contentDocument + const script = fDoc.createElement('script') + + script.type = 'module' + script.src = '/webtest/iframe-isolation.js' + fDoc.documentElement.appendChild(script) +} + +oom(document.head, oom + .style({ + '.test': { background: frame ? 'darkorange' : 'darkgreen' }, + '.label': { color: frame ? 'darkgreen' : 'darkorange' } + })) +oom(document.body, oom + .div({ class: 'test' }, oom + .span('NotML Core - CDN lib', { class: 'label' }))) diff --git a/webtest/index.js b/webtest/index.js new file mode 100644 index 000000000..513ee6099 --- /dev/null +++ b/webtest/index.js @@ -0,0 +1,18 @@ +import Fastify from 'fastify' +import FastifyStatic from 'fastify-static' +import { resolve } from 'path' + +const fastify = Fastify({ logger: true }) +const dir = resolve('webtest') + + +fastify + .register(FastifyStatic, { root: resolve('.'), prefix: '/' }) // @ts-ignore + .get('/', (req, reply) => { reply.sendFile('webtest.html', dir) }) + .listen({ host: '0.0.0.0', port: 3000 }, (err, address) => { + if (err) { + fastify.log.error(err) + process.exit(1) + } + fastify.log.info(`server listening on ${address}`) + }) diff --git a/webtest/shadow-root.html b/webtest/shadow-root.html new file mode 100644 index 000000000..9516c87f7 --- /dev/null +++ b/webtest/shadow-root.html @@ -0,0 +1,15 @@ + + + + + + + NotML Core - ShadowRoot + + + + + + + + diff --git a/webtest/shadow-root.js b/webtest/shadow-root.js new file mode 100644 index 000000000..24be914a9 --- /dev/null +++ b/webtest/shadow-root.js @@ -0,0 +1,59 @@ +import { oom } from '../@notml/core/core.js' + +const { HTMLElement, document } = window + + +/** Тестовый класс со стилями */ +class MySpan extends oom.extends(HTMLElement) { + + static tagName = 'my-span' + static style = oom.style({ '.my_shadow': { background: 'darkorange' } }) + + template = oom.span('test1', { class: 'my_shadow' }) + +} + + +/** Тестовый класс со стилями */ +class MySpan2 extends oom.extends(HTMLElement) { + + static tagName = 'my-span2' + static style = oom.style({ '.my_shadow2': { background: 'gold' } }) + + template = oom.span('test2', { class: 'my_shadow2' }) + +} + + +/** Тестовый элемент с теневым DOM */ +class MyShadowRoot extends oom.extends(HTMLElement) { + + static tagName = 'my-shadow-root' + static attachShadow = true + + template = oom()(new MySpan(), new MySpan(), new MySpan2(), new MySpan2()) + +} + +/** Тестовый элемент с теневым DOM */ +class MyShadowRootClosed extends oom.extends(HTMLElement) { + + static tagName = 'my-shadow-root-closed' + static attachShadow = { mode: 'closed' } + + template = oom()(new MySpan(), new MySpan(), new MySpan2(), new MySpan2()) + +} + + +oom.define(MySpan, MySpan2, MyShadowRoot, MyShadowRootClosed) + + +oom(document.head, oom.style({ + 'my-span .my_shadow': { color: 'white' }, + 'my-span2 .my_shadow2': { color: 'white' } +})) + +oom(document.body, oom()(new MySpan(), new MySpan2()) + .br()(new MyShadowRoot()) + .br()(new MyShadowRootClosed())) diff --git a/webtest/types.d.ts b/webtest/types.d.ts new file mode 100644 index 000000000..789f3b6b8 --- /dev/null +++ b/webtest/types.d.ts @@ -0,0 +1,13 @@ +declare module 'https://cdn.jsdelivr.net/npm/@notml/core@latest/core.js' { + export { oom } from '@notml/core' +} + +declare module 'https://cdn.jsdelivr.net/npm/notml@latest/core.js' { + export { oom } from '@notml/core' +} + +declare module 'https://cdn.jsdelivr.net/npm/notml@latest/core.min.js' { + export { oom } from '@notml/core' +} + + diff --git a/webtest/webtest.html b/webtest/webtest.html new file mode 100644 index 000000000..fa262dc09 --- /dev/null +++ b/webtest/webtest.html @@ -0,0 +1,68 @@ + + + + + + + NotML Core + + + + + + + +

+ Compatible NotML Core
+ Compatible NotML Core (min)
+ NotML Core - CDN lib
+ NotML Core - CDN lib (min)
+ NotML Core - from CDN (All-in-one)
+ NotML Core - from CDN (All-in-one - minimized)
+ NotML Core - from CDN (Source)
+ NotML Core - iframe isolation
+ NotML Core - ShadowRoot
+ NotML Core - ContainerElements (min)
+ NotML Core - CSS Custom Properties
+

+ +
+ Example #1 - базовая верстка +
+
+ +
+ Example #2 - переиспользование и копирование +
+
+ +
+ Example #3 - простой кастомный компонент +
+
+ +
+ Example #4 - генерация style is="oom-style" +
+
+ +
+ Example #5 - стилизация CustomElement через static style +
+
+ + + + diff --git a/webtest/webtest.js b/webtest/webtest.js new file mode 100644 index 000000000..fbe2e7869 --- /dev/null +++ b/webtest/webtest.js @@ -0,0 +1,153 @@ +import { oom } from '../@notml/core/core.js' + +const { document } = window + + +/** + * @param {string} name Имя теста + * @param {any} actual Актуальное значение для проверки + * @param {any} expected Фактическое значение для проверки + */ +function assertEqual(name, actual, expected) { + if (actual !== expected) { + console.log(name, ' -> failed') + console.log('actual =>', actual) + console.log('expected =>', expected) + console.error(new Error('actual !== expected')) + } else { + console.log(name, ' -> ok') + } +} + +console.time('webtest') + + +// Example #1 - базовая верстка +const exp1 = document.getElementById('exp1') +const div1 = oom('div', oom + .div({ class: 'header' }) + .div({ class: 'test' }, oom + .span('Name: ', { class: 'test-label' }) + .span('Test', { class: 'test-name' })) + .div({ class: 'footer' })) +const html1 = div1.html + +exp1.append(div1.dom) +assertEqual('Example #1-1', html1, exp1.innerHTML) +assertEqual('Example #1-2', exp1.innerHTML, + '
' + + '
' + + '
' + + 'Name: ' + + 'Test' + + '
' + + '' + + '
') + + +// Example #2 - переиспользование и копирование +const exp2 = document.getElementById('exp2') +const header2 = oom('div', { class: 'header' }, oom + .span('Test Header')) +const block2 = oom() + +block2(oom + .div(oom() + .append(header2.clone()) + .div('div 1'))) +block2(oom + .div(oom() + .append(header2.clone()) + .div('div 2'))) + +const html2 = block2.html + +exp2.append(block2.dom) +assertEqual('Example #2-1', html2, exp2.innerHTML) +assertEqual('Example #2-2', exp2.innerHTML, + '
' + + '
Test Header
' + + '
div 1
' + + '
' + + '
' + + '
Test Header
' + + '
div 2
' + + '
') + + +// Example #3 - простой кастомный компонент +/** Test custom element */ +class MyElementExp3 extends oom.extends(HTMLElement) { + + static tagName = 'my-element-exp3' + + mySpan = oom.span('My element new text') + + template = oom('div', { class: 'MyElement__inner' }) + .append(this.mySpan.clone()) + .append(oom('br')) + .append(this.mySpan) + +} + +oom.define(MyElementExp3) + +const exp3 = document.getElementById('exp3') +const block3 = oom.myElementExp3() +const html3 = block3.html + +exp3.append(block3.dom) +assertEqual('Example #3-1', html3, '') +assertEqual('Example #3-2', exp3.innerHTML, + '' + + '
' + + 'My element new text' + + '
' + + 'My element new text' + + '
' + + '
') + + +// Example #4 - генерация style is="oom-style" +const exp4 = oom(document.getElementById('exp4'), oom + .style({ '.exp4__label': { color: 'darkgreen' } }) + .span('exp4__label', { class: 'exp4__label' })) + +assertEqual('Example #4-1', exp4.html, ` +
+ + exp4__label +
+`.replace(/\s*\n+\s*/g, '')) + + +// Example #5 - стилизация CustomElement через static style +/** Тестовая кнопка */ +class MyButton extends oom.extends(HTMLButtonElement, { caption: '' }) { + + static tagName = 'my-butt' + static extendsTagName = 'button' + + static style = oom.style({ '.my-butt__caption': { color: 'red' } }) + + template = oom.span({ class: 'my-butt__caption' }, this.options.caption) + +} + +oom.define(MyButton) + +const exp5 = oom(document.getElementById('exp5'), + oom(new MyButton({ caption: 'Жми тут' }))) + +assertEqual('Example #5-1', exp5.html, ` +
+ +
+`.replace(/\s*\n+\s*/g, '')) + + +console.timeEnd('webtest')