diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..7216fd2146 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,43 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.0.1-beta", + "configurations": [ + { + "name": "Launch Source", + "type": "firefox", + "request": "launch", + "reAttach": false, + "reloadOnChange": { + "watch": ["${workspaceFolder}/src/**/*.js", "${workspaceFolder}/**/*.html"], + "ignore": "**/node_modules/**" + }, + "internalConsoleOptions": "openOnSessionStart", + "firefoxArgs": ["-devtools"], + "url": "${workspaceFolder}/index.html", + "webRoot": "${workspaceFolder}/src" + }, + { + "name": "Launch Test", + "type": "firefox", + "request": "launch", + "reAttach": false, + "reloadOnChange": { + "watch": ["${workspaceFolder}/test/**/*.js", "${workspaceFolder}/test/**/*.html"], + "ignore": "**/node_modules/**" + }, + "internalConsoleOptions": "openOnSessionStart", + "url": "${workspaceFolder}/test/index.html", + "webRoot": "${workspaceFolder}/test" + }, + { + "name": "Launch TestChrome", + "type": "chrome", + "request": "launch", + "internalConsoleOptions": "openOnSessionStart", + "url": "http://localhost/${workspaceFolder}/test/index.html", + "webRoot": "${workspaceFolder}/test" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..7c049862ed --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + ], + "editor.tabSize": 2, + "editor.detectIndentation": false +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..90aa933923 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Silvan StrĂ¼bi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json new file mode 100644 index 0000000000..c1fce1c5d6 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "event-driven-web-components-realworld-example-app", + "version": "0.0.1-beta", + "description": "Exemplary real world application built with Vanilla JS Web Components in an Event Driven Architecture", + "main": "./src/index.html", + "scripts": { + "test": "standard --fix" + }, + "author": "weedshaker@gmail.com", + "license": "MIT", + "devDependencies": { + "standard": "^14.3.3" + } +} diff --git a/test/es/Test.js b/test/es/Test.js new file mode 100644 index 0000000000..a2a9a06bf0 --- /dev/null +++ b/test/es/Test.js @@ -0,0 +1,157 @@ +// @ts-check + +/* global customElements */ +/* global self */ + +export default class Test { + /** + * Creates an instance of Test + * @param {string} name + * @param {string} [namespace = ''] // is very important if the same test runs more than once in the same session + * @memberof Test + */ + constructor (name, namespace = '') { + this.namespace = namespace + + this.summaries = document.createElement('div') + this.summaries.innerHTML = `${name} had test runs done from which passed and failed` + document.getElementById('summary').appendChild(this.summaries) + this.summarySpaceDone = this.summaries.getElementsByClassName('hasDone')[0] + this.summarySpacePassed = this.summaries.getElementsByClassName('hasPassed')[0] + this.summarySpaceFailed = this.summaries.getElementsByClassName('hasFailed')[0] + this.counter = 0 + this.passedCounter = 0 + this.failedCounter = 0 + + const results = document.createElement('div') + results.innerHTML = ` +
+ +

Results: ${name}

+
+
+
+

Test Artifacts

+
+
+ ` + document.getElementById('results').appendChild(results) + document.getElementById('results').appendChild(document.createElement('hr')) + this.resultSpace = results.getElementsByClassName('result')[0] + this.testSpace = results.getElementsByClassName('test')[0] + + self.onerror = (message, source, lineno, colno, error) => { + const errorEl = document.createElement('div') + errorEl.classList.add(errorEl.textContent = 'failed') + errorEl.textContent += `message: ${message}, source: ${source}, lineno: ${lineno}, colno: ${colno}, error: ${error}}` + this.summaries.appendChild(errorEl) + } + } + + /** + * runs a web-component test by first importing the needed module, define it and test it + * + * @param {string} testName + * @param {string} [moduleName='default'] + * @param {string} modulePath + * @param {(HTMLElement)=>boolean} testFunction + * @param {string} [attributes=''] + * @param {(Function)=>Function} [extendsFunction=(Function)=>Function] + * @memberof Test + * @return {Promise} + */ + runTest (testName, moduleName = 'default', modulePath, testFunction, attributes = '', extendsFunction = func => func) { + testName = this.namespace ? `${testName}-${this.namespace}` : testName + return import(modulePath).then(module => { + // test shadowRoot + try { + if (!customElements.get(testName)) customElements.define(testName, extendsFunction(!module[moduleName].toString().includes('=>') ? class extends module[moduleName] {} : module[moduleName]())) + } catch (error) { + console.error(`Note! testName: ${testName} must be lower case with hyphen separated!`, error) + } + return this.test(testName, testFunction, attributes, null) + }) + } + + /** + * test a web-component + * + * @param {string} testName + * @param {(HTMLElement)=>boolean} testFunction + * @param {string} [attributes=''] + * @param {Element} [testEl = null] + * @param {boolean} hidden + * @return {Element | null} + */ + test (testName, testFunction, attributes = '', testEl = null, hidden = false) { + if (!testEl) { + const container = document.createElement('div') + container.innerHTML = `<${testName} ${attributes}><${testName}>` + testEl = container.getElementsByTagName(testName)[0] + this.testSpace.appendChild(testEl) + // @ts-ignore + testEl.hidden = hidden + } else { + const placeHolder = document.createElement('div') + placeHolder.textContent = `reused: <${testEl.tagName.toLowerCase()}> for ${testName} test` + placeHolder.classList.add('placeHolder') + this.testSpace.appendChild(placeHolder) + placeHolder.hidden = hidden + } + if (testEl) { + const resultEl = document.createElement('div') + if (testFunction(testEl)) { + resultEl.classList.add(resultEl.textContent = 'passed') + if (!hidden) this.passedCounter++ + } else { + resultEl.classList.add(resultEl.textContent = 'failed') + if (!hidden) this.failedCounter++ + } + testEl.className = '' + testEl.classList.add(resultEl.textContent) + resultEl.textContent += `: ${testName.replace(`-${this.namespace}`, '')}` + this.resultSpace.appendChild(resultEl) + resultEl.hidden = hidden + if (hidden) console.info(`${testFunction(testEl) ? 'passed' : 'failed'} hidden test: ${testName} on element: ${testEl.tagName.toLowerCase()}`) + } + if (!hidden) this.counter++ + this.updateSummary() + return testEl + } + + updateSummary () { + if (Number(this.summarySpaceFailed.textContent) > 0) this.summaries.classList.add('failed') + if (Number(this.summarySpacePassed.textContent) === Number(this.summarySpaceDone.textContent)) { + this.summaries.classList.add('passed') + } else { + this.summaries.classList.remove('passed') + } + } + + set counter (number) { + this._counter = number + this.summarySpaceDone.textContent = number + } + + get counter () { + return this._counter + } + + set passedCounter (number) { + this._passedCounter = number + this.summarySpacePassed.textContent = number + } + + get passedCounter () { + return this._passedCounter + } + + set failedCounter (number) { + this._failedCounter = number + this.summarySpaceFailed.textContent = number + } + + get failedCounter () { + return this._failedCounter + } +} diff --git a/test/es/tests/components/prototypes/MasterIntersectionObserver.js b/test/es/tests/components/prototypes/MasterIntersectionObserver.js new file mode 100644 index 0000000000..ef9fa24479 --- /dev/null +++ b/test/es/tests/components/prototypes/MasterIntersectionObserver.js @@ -0,0 +1,52 @@ +// @ts-nocheck + +/* global CustomEvent */ +/* global self */ + +import Test from '../../../Test.js' + +let counter = 0 + +/** + * MasterIntersectionObserver Tests + * + * @param {string} testTitle + * @param {string} moduleName + * @param {string} modulePath + * @param {string} [namespace = ''] + */ +export const test = (testTitle = 'MasterIntersectionObserver', moduleName = 'MasterIntersectionObserver', modulePath = '../../src/es/components/prototypes/MasterIntersectionObserver.js', namespace = counter) => { + // test modulePath must be from Test.js perspective + const test = new Test(testTitle, namespace) + + // INTERSECTION ----------------------------------------------------------------------------------------------- + let eventDispatched = false + let eventReceived = false + test.runTest('intersection-observer-setup', moduleName, modulePath, + el => el, + undefined, + (subclass) => class extends subclass { + constructor (masterArgs = {}, ...args) { + super(Object.assign(masterArgs, { intersectionObserverInit: {} }), ...args) + this.css = `this{ + position: absolute; + top: 2000px; + }` + } + } + ).then(el => { + document.body.addEventListener('HelloFromComponent', event => (eventDispatched = true)) + el.addCustomEventListener(document.body, 'HelloFromBody', event => (eventReceived = true)) + el.dispatchCustomEvent(el.getCustomEvent('HelloFromComponent')) + document.body.dispatchEvent(new CustomEvent('HelloFromBody', { bubbles: true, cancelable: true, detail: null, composed: true })) + test.test('intersection-observer-not-dispatched-nor-received', el => !eventDispatched && !eventReceived, undefined, el) + self.scrollTo(0, document.body.scrollHeight + 200) + setTimeout(() => { + test.test('intersection-observer-dispatched-received', el => eventDispatched && eventReceived, undefined, el) + el.css = '' + self.scrollTo(0, 0) + }, 50) + }) + // ------------------------------------------------------------------------------------------------------------ + counter++ +} diff --git a/test/es/tests/components/prototypes/MasterMutationObserver.js b/test/es/tests/components/prototypes/MasterMutationObserver.js new file mode 100644 index 0000000000..e16cd5fd2b --- /dev/null +++ b/test/es/tests/components/prototypes/MasterMutationObserver.js @@ -0,0 +1,86 @@ +// @ts-nocheck + +import Test from '../../../Test.js' + +let counter = 0 + +/** + * MasterMutationObserver Tests + * + * @param {string} testTitle + * @param {string} moduleName + * @param {string} modulePath + * @param {string} [namespace = ''] + */ +export const test = (testTitle = 'MasterMutationObserver', moduleName = 'MasterMutationObserver', modulePath = '../../src/es/components/prototypes/MasterMutationObserver.js', namespace = counter) => { + // test modulePath must be from Test.js perspective + const test = new Test(testTitle, namespace) + + // MUTATION------------------------------------------------------------------------------------------------------ + let gotAttributeMutation = false + let gotChildListMutation = false + test.runTest('mutation-observer-by-attribute-setup', moduleName, modulePath, + el => el, + `mutationObserverInit="{ + 'attributes': true, + 'characterData': true, + 'childList': true + }"`, + (subclass) => class extends subclass { + constructor (masterArgs = {}, ...args) { + super(masterArgs, ...args) + } + + connectedCallback () { + super.connectedCallback() + this.setAttribute('hello', 'world') + this.root.appendChild(document.createElement('span')) + } + + mutationCallback (mutationList, observer) { + super.mutationCallback(mutationList, observer) + mutationList.forEach(mutation => { + if (mutation.type === 'attributes' && mutation.attributeName === 'hello') gotAttributeMutation = true + if (mutation.type === 'childList') gotChildListMutation = true + }) + } + } + ).then(el => { + test.test('mutation-observer-by-attribute', el => gotAttributeMutation && gotChildListMutation, undefined, el) + }) + let gotAttributeMutation2 = false + let gotChildListMutation2 = false + test.runTest('mutation-observer-by-extends-setup', moduleName, modulePath, + el => el, + undefined, + (subclass) => class extends subclass { + constructor (masterArgs = {}, ...args) { + super(Object.assign(masterArgs, { + mutationObserverInit: { + attributes: true, + characterData: true, + childList: true + } + }), ...args) + } + + connectedCallback () { + super.connectedCallback() + this.setAttribute('hello', 'world') + this.root.appendChild(document.createElement('span')) + } + + mutationCallback (mutationList, observer) { + super.mutationCallback(mutationList, observer) + mutationList.forEach(mutation => { + if (mutation.type === 'attributes' && mutation.attributeName === 'hello') gotAttributeMutation2 = true + if (mutation.type === 'childList') gotChildListMutation2 = true + }) + } + } + ).then(el => { + test.test('mutation-observer-by-extends', el => gotAttributeMutation2 && gotChildListMutation2, undefined, el) + }) + // ------------------------------------------------------------------------------------------------------------ + counter++ +} diff --git a/test/es/tests/components/prototypes/MasterShadow.js b/test/es/tests/components/prototypes/MasterShadow.js new file mode 100644 index 0000000000..cf475a0c46 --- /dev/null +++ b/test/es/tests/components/prototypes/MasterShadow.js @@ -0,0 +1,152 @@ +// @ts-nocheck + +import Test from '../../../Test.js' + +let counter = 0 + +/** + * MasterShadow Tests + * + * @param {string} testTitle + * @param {string} moduleName + * @param {string} modulePath + * @param {string} [namespace = ''] + */ +export const test = (testTitle = 'MasterShadow', moduleName = 'MasterShadow', modulePath = '../../src/es/components/prototypes/MasterShadow.js', namespace = counter) => { + // test modulePath must be from Test.js perspective + const test = new Test(testTitle, namespace) + + // MODE ------------------------------------------------------------------------------------------------------- + // MODE: OPEN + const shadowRootOpenFunc = el => el.shadowRoot !== null && el.shadow !== null && el.shadow === el.shadowRoot && el.hasShadow && el.cssSelector === ':host' + test.runTest('shadow-root-by-attribute-open', moduleName, modulePath, + shadowRootOpenFunc, + 'mode=open' + ) + test.runTest('shadow-root-by-extends-open', moduleName, modulePath, + shadowRootOpenFunc, + undefined, + (subclass) => class extends subclass { + constructor (masterArgs = {}, ...args) { + super(Object.assign(masterArgs, { mode: 'open' }), ...args) + } + } + ) + // MODE: CLOSED + const shadowRootClosedFunc = el => el.shadowRoot === null && el.shadow !== null && el.shadow !== el.shadowRoot && el.hasShadow && el.cssSelector === ':host' + test.runTest('shadow-root-by-attribute-closed', moduleName, modulePath, + shadowRootClosedFunc, + 'mode=closed' + ) + test.runTest('shadow-root-by-extends-closed', moduleName, modulePath, + shadowRootClosedFunc, + undefined, + (subclass) => class extends subclass { + constructor (masterArgs = {}, ...args) { + super(Object.assign(masterArgs, { mode: 'closed' }), ...args) + } + } + ) + // MODE: FALSE + const shadowRootFalseFunc = el => el.shadowRoot === null && el.shadow === null && el.shadow === el.shadowRoot && !el.hasShadow && el.cssSelector.includes('shadow-root-'.toUpperCase()) + test.runTest('shadow-root-by-attribute-false', moduleName, modulePath, + shadowRootFalseFunc, + 'mode=false' + ) + test.runTest('shadow-root-by-extends-false', moduleName, modulePath, + shadowRootFalseFunc, + undefined, + (subclass) => class extends subclass { + constructor (masterArgs = {}, ...args) { + super(Object.assign(masterArgs, { mode: 'false' }), ...args) + } + } + ) + // ------------------------------------------------------------------------------------------------------------ + // CSS -------------------------------------------------------------------------------------------------------- + test.runTest('shadow-root-css-setup', moduleName, modulePath, + el => el + ).then(el => { + el.css = `this { + height: 200px; + /* reset all css inherited from Test css */ + border: 0 !important; + margin: 0 !important; + padding: 0 !important; + }` + test.test('shadow-root-css-height', el => el.offsetHeight === 200, undefined, el) + el.css = `this { + width: 300px; + }` + test.test('shadow-root-css-width', el => el.offsetWidth === 300, undefined, el) + el.css = '' + test.test('shadow-root-css-cleanup1', el => el.root.textContent === ``, undefined, el) + // safari does not update the css on dom until the html got mutated, should not be an issue in production + // test.test('shadow-root-css-cleanup-safari', el => el.offsetHeight !== 200 && el.offsetWidth !== 300, undefined, el, true) + test.test('shadow-root-css-cleanup2', el => el.offsetHeight !== 200 && el.offsetWidth !== 300, undefined, el) + }) + // ------------------------------------------------------------------------------------------------------------ + // HTML ------------------------------------------------------------------------------------------------------- + const htmlString = 'Hello World' + let oldInnerHTML = '' + test.runTest('shadow-root-html-setup', moduleName, modulePath, + el => { + oldInnerHTML = el.html + return true + } + ).then(el => { + el.html = '' + test.test('shadow-root-html-empty', el => el.root.innerHTML === '', undefined, el) + el.html = htmlString + test.test('shadow-root-html-string', el => el.root.innerHTML === htmlString, undefined, el) + el.html = '' + el.html = [document.createElement('span'), document.createElement('span')] + test.test('shadow-root-html-collection', el => el.root.innerHTML === '', undefined, el) + el.html = oldInnerHTML + // IMPORT ----------------------------------------------------------------------------------------------------- + el.constructor.importCustomElementsAndDefine([ + { + path: '../../components/prototypes/MasterShadow.js', + name: 'shadow-root-import', + moduleName: 'MasterShadow' + } + ])[0].then(elements => elements.forEach(element => test.test('shadow-root-import', el => el.nodeName === 'shadow-root-import'.toUpperCase(), undefined, element))) + // ------------------------------------------------------------------------------------------------------------ + }) + // ------------------------------------------------------------------------------------------------------------ + // timeout ---------------------------------------------------------------------------------------------------- + test.runTest('shadow-root-add-event-listener-setup', moduleName, modulePath, + el => el + ).then(el => { + // 'shadow-root-add-event-listener-initial-timeout' + let container1 = [] + el.addEventListenerInitialTimeout(document.body, 'TestListenerEvent1', (event, events) => { + container1 = [event, events] + }, undefined, 100) + document.body.dispatchEvent(el.getCustomEvent('TestListenerEvent1')) + document.body.dispatchEvent(el.getCustomEvent('TestListenerEvent1')) + document.body.dispatchEvent(el.getCustomEvent('TestListenerEvent1')) + setTimeout(() => { + test.test('shadow-root-add-event-listener-initial-timeout1', () => !container1[0] && !container1[1], undefined, el) + }, 10) + setTimeout(() => { + test.test('shadow-root-add-event-listener-initial-timeout2', () => container1[0] && container1[1].length === 3, undefined, el) + }, 120) + // 'shadow-root-add-event-listener-initial-once' + let container2 = [] + el.addEventListenerInitialOnce(document.body, 'TestListenerEvent2', (event, events) => { + container2 = [event, events] + }, undefined, 100) + document.body.dispatchEvent(el.getCustomEvent('TestListenerEvent2')) + document.body.dispatchEvent(el.getCustomEvent('TestListenerEvent2')) + document.body.dispatchEvent(el.getCustomEvent('TestListenerEvent2')) + setTimeout(() => { + test.test('shadow-root-add-event-listener-initial-once1', () => container2[0] && !container2[1], undefined, el) + }, 10) + setTimeout(() => { + test.test('shadow-root-add-event-listener-initial-once2', () => container2[0] && container2[1].length === 2, undefined, el) + }, 120) + }) + // ------------------------------------------------------------------------------------------------------------ + counter++ +} diff --git a/test/index.html b/test/index.html new file mode 100644 index 0000000000..3de0f33e02 --- /dev/null +++ b/test/index.html @@ -0,0 +1,81 @@ + + + + + Tests + + + + +

Summary:

+
+
+
+ + \ No newline at end of file