├── .prettierignore ├── .gitignore ├── .prettierrc ├── src ├── index.mjs ├── x-prop-directive.mjs ├── x-widget-data.mjs ├── lazy-evaluator.mjs └── x-widget.mjs ├── examples └── x-slots.html ├── LICENSE ├── package.json ├── README.md └── test ├── x-prop.test.mjs ├── x-widget-data.test.mjs └── x-widget.test.mjs /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80 6 | } -------------------------------------------------------------------------------- /src/index.mjs: -------------------------------------------------------------------------------- 1 | import { xWidgetData } from './x-widget-data.mjs' 2 | import { xPropDirective } from './x-prop-directive.mjs' 3 | import { xWidgetDirective, slotsMagic } from './x-widget.mjs' 4 | 5 | export default function (Alpine) { 6 | Alpine.magic('slots', slotsMagic) 7 | Alpine.directive('widget', xWidgetDirective) 8 | Alpine.directive('prop', xPropDirective) 9 | Alpine.data('xWidget', xWidgetData.bind(Alpine)) 10 | } 11 | 12 | export { xWidgetData, xWidgetDirective, xPropDirective, slotsMagic } 13 | -------------------------------------------------------------------------------- /examples/x-slots.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Panel 2 Content 33 | 34 | 35 | 36 | 37 | 38 |
39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Allain Lalonde 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/x-prop-directive.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | addScopeToNode, 3 | closestDataStack, 4 | mergeProxies 5 | } from 'alpinejs/src/scope.js' 6 | 7 | import { lazyEvaluator } from './lazy-evaluator.mjs' 8 | 9 | const snakeToCamel = (name) => 10 | name.replace(/-([a-zA-Z])/g, (m) => m[1].toUpperCase() + m.substr(2)) 11 | 12 | export function xPropDirective( 13 | el, 14 | { value: attribName, expression }, 15 | { cleanup } 16 | ) { 17 | const propName = snakeToCamel(attribName) 18 | const read = lazyEvaluator(el.parentElement, expression) 19 | 20 | // allow assignment of unsafe left hand side by just using the new value without bubbling up 21 | let unsafeValue 22 | const setter = safeLeftHandSide(el, expression) 23 | ? lazyEvaluator(el.parentElement, `${expression} = __`) 24 | : (_, { scope: { __: newValue } }) => (unsafeValue = newValue) 25 | 26 | const propObj = {} 27 | 28 | Object.defineProperty(propObj, propName, { 29 | get() { 30 | let result 31 | if (typeof unsafeValue !== 'undefined') { 32 | return unsafeValue 33 | } 34 | read((newValue) => (result = newValue)) 35 | return result 36 | }, 37 | set(newValue) { 38 | if (propName !== 'value') { 39 | el[propName] = newValue 40 | } 41 | setter(() => {}, { scope: { __: newValue } }) 42 | } 43 | }) 44 | 45 | if (propName !== 'value') { 46 | el[propName] = propObj[propName] 47 | } 48 | 49 | const removeScope = addScopeToNode(el, propObj) 50 | 51 | cleanup(() => removeScope()) 52 | } 53 | 54 | export function safeLeftHandSide(el, lhs) { 55 | try { 56 | let scope = mergeProxies(closestDataStack(el)) 57 | new Function('scope', `with(scope) {${lhs} = ${lhs}}`)(scope) 58 | return true 59 | } catch { 60 | return false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "x-widget", 3 | "version": "0.2.12", 4 | "description": "Adds the ability to define reusable Widgets (WebComponents) using Alpinejs.", 5 | "main": "./dist/x-widget-all.js", 6 | "module": "./dist/x-widget-all.mjs", 7 | "files": [ 8 | "README.md", 9 | "LICENSE", 10 | "dist/", 11 | "src/", 12 | "test/" 13 | ], 14 | "scripts": { 15 | "build": "zx build", 16 | "format": "prettier --write build './src/**/*.mjs' './test/*.test.mjs'", 17 | "test": "web-test-runner './test/*.test.mjs' --node-resolve", 18 | "test:coverage": "npm run test -- --coverage", 19 | "test:watch": "npm run test -- --watch", 20 | "prepublishOnly": "npm run build" 21 | }, 22 | "exports": { 23 | ".": { 24 | "import": "./dist/x-widget-all.mjs", 25 | "require": "./dist/x-widget-all.js" 26 | }, 27 | "./core": { 28 | "import": "./dist/x-widget.mjs", 29 | "require": "./dist/x-widget.js" 30 | }, 31 | "./data": { 32 | "import": "./dist/x-widget-data.mjs", 33 | "require": "./dist/x-widget-data.js" 34 | } 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/allain/x-widget.git" 39 | }, 40 | "keywords": [ 41 | "Alpinejs", 42 | "web", 43 | "webcomponents" 44 | ], 45 | "author": "Allain Lalonde ", 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/allain/x-widget/issues" 49 | }, 50 | "homepage": "https://github.com/allain/x-widget#readme", 51 | "devDependencies": { 52 | "@esm-bundle/chai": "^4.3.4", 53 | "@types/alpinejs": "^3.7.0", 54 | "@web/test-runner": "^0.13.22", 55 | "alpinejs": "^3.5.1", 56 | "esbuild": "^0.13.15", 57 | "prettier": "^2.5.1", 58 | "zx": "^4.2.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/x-widget-data.mjs: -------------------------------------------------------------------------------- 1 | const camelToSnake = (name) => 2 | name.replace(/[a-z][A-Z]/g, (m) => m[0] + '-' + m[1].toLowerCase()) 3 | 4 | export function xWidgetData(spec) { 5 | const Alpine = this 6 | const propDescriptorEntries = Object.entries( 7 | Object.getOwnPropertyDescriptors(spec) 8 | ) 9 | 10 | return ($el, $data) => { 11 | const widgetEl = findWidget($el) 12 | console.assert(widgetEl, 'widget not found') 13 | 14 | const boundProps = Array.from(widgetEl.attributes) 15 | .filter((attr) => attr.name.startsWith('x-prop:')) 16 | .map(({ name }) => name.substr(7)) 17 | 18 | const observer = new MutationObserver((changes) => { 19 | changes.forEach(({ attributeName, target }) => { 20 | setProp(attributeName, target.getAttribute(attributeName)) 21 | }) 22 | }) 23 | 24 | observer.observe(widgetEl, { 25 | attributes: true, 26 | attributeFilter: Object.keys(spec).map(camelToSnake), 27 | attributeOldValue: false 28 | }) 29 | 30 | const proplessDescriptors = Object.fromEntries( 31 | propDescriptorEntries.filter(([name]) => !boundProps.includes(name)) 32 | ) 33 | 34 | const data = Alpine.reactive( 35 | Object.assign( 36 | Object.create(Object.getPrototypeOf(spec), proplessDescriptors), 37 | { 38 | destroy() { 39 | observer.disconnect() 40 | } 41 | } 42 | ) 43 | ) 44 | 45 | const attribs = [...widgetEl.attributes] 46 | for (const name of Object.getOwnPropertyNames(spec)) { 47 | const attrib = attribs.find((attr) => 48 | attr.name.match( 49 | new RegExp(`^((x-(bind|prop))?:)?${camelToSnake(name)}$`) 50 | ) 51 | ) 52 | if (!attrib) continue 53 | 54 | // is bound using prop defer to internals of x-prop 55 | if (attrib.name.startsWith('x-prop:')) { 56 | Object.defineProperty(data, name, { 57 | configurable: true, // so it can be deleted, 58 | enumerable: true, // so it appears on inspection 59 | get() { 60 | return $data[name] 61 | }, 62 | set(newValue) { 63 | $data[name] = newValue 64 | } 65 | }) 66 | } else { 67 | setProp(name, widgetEl.getAttribute(camelToSnake(name))) 68 | } 69 | } 70 | 71 | // validate attribValue to conform to spec 72 | function setProp(name, attribValue) { 73 | const defaultValue = spec[name] 74 | if (typeof defaultValue === 'boolean') { 75 | if (attribValue === '') { 76 | data[name] = true 77 | } else if (attribValue === 'false') { 78 | data[name] = false 79 | } else { 80 | data[name] = !!attribValue 81 | } 82 | } else if (typeof defaultValue === 'number') { 83 | data[name] = parseFloat(attribValue) 84 | } else if (typeof defaultValue === 'string') { 85 | data[name] = attribValue 86 | } else { 87 | throw new Error('unsupported static attribute: ' + name) 88 | } 89 | } 90 | 91 | return data 92 | } 93 | } 94 | 95 | function findWidget(el) { 96 | while (el && !el.tagName.includes('-')) el = el.parentElement 97 | return el 98 | } 99 | -------------------------------------------------------------------------------- /src/lazy-evaluator.mjs: -------------------------------------------------------------------------------- 1 | import { closestDataStack, mergeProxies } from 'alpinejs/src/scope.js' 2 | import { tryCatch, handleError } from 'alpinejs/src/utils/error.js' 3 | 4 | // Taken from https://github.com/alpinejs/alpine/blob/main/packages/alpinejs/src/evaluator.js and stripped down 5 | // this performs the same as alpine's normal evaluator, except that when it receives a function as a result it doesn't invoke it 6 | export function lazyEvaluator(el, expression) { 7 | let dataStack = closestDataStack(el) 8 | 9 | let evaluator = generateEvaluatorFromString(dataStack, expression, el) 10 | 11 | return tryCatch.bind(null, el, expression, evaluator) 12 | } 13 | 14 | function generateEvaluatorFromString(dataStack, expression, el) { 15 | let func = generateFunctionFromString(expression, el) 16 | 17 | return (receiver = () => {}, { scope = {} /*params = []*/ } = {}) => { 18 | func.result = undefined 19 | func.finished = false 20 | 21 | let completeScope = mergeProxies([scope, ...dataStack]) 22 | 23 | if (typeof func === 'function') { 24 | let promise = func(func, completeScope).catch((error) => 25 | handleError(error, el, expression) 26 | ) 27 | 28 | // Check if the function ran synchronously, 29 | if (func.finished) { 30 | receiver(bindResult(func.result, completeScope)) 31 | func.result = undefined 32 | } else { 33 | // If not, return the result when the promise resolves. 34 | promise 35 | .then((result) => bindResult(result, completeScope)) 36 | .catch((error) => handleError(error, el, expression)) 37 | .finally(() => (func.result = undefined)) 38 | } 39 | } 40 | } 41 | } 42 | 43 | function bindResult(result, scope) { 44 | return typeof result === 'function' ? result.bind(scope) : result 45 | } 46 | 47 | let evaluatorMemo = {} 48 | 49 | function generateFunctionFromString(expression, el) { 50 | if (evaluatorMemo[expression]) { 51 | return evaluatorMemo[expression] 52 | } 53 | 54 | let AsyncFunction = Object.getPrototypeOf(async function () {}).constructor 55 | 56 | // Some expressions that are useful in Alpine are not valid as the right side of an expression. 57 | // Here we'll detect if the expression isn't valid for an assignement and wrap it in a self- 58 | // calling function so that we don't throw an error AND a "return" statement can b e used. 59 | let rightSideSafeExpression = 60 | 0 || 61 | // Support expressions starting with "if" statements like: "if (...) doSomething()" 62 | /^[\n\s]*if.*\(.*\)/.test(expression) || 63 | // Support expressions starting with "let/const" like: "let foo = 'bar'" 64 | /^(let|const)\s/.test(expression) 65 | ? `(() => { ${expression} })()` 66 | : expression 67 | 68 | const safeAsyncFunction = () => { 69 | try { 70 | return new AsyncFunction( 71 | ['__self', 'scope'], 72 | `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;` 73 | ) 74 | } catch (error) { 75 | handleError(error, el, expression) 76 | return Promise.resolve() 77 | } 78 | } 79 | let func = safeAsyncFunction() 80 | 81 | evaluatorMemo[expression] = func 82 | 83 | return func 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # x-widget 2 | 3 | Adds the ability to define widgets using [Alpinejs](https://alpinejs.dev/) 4 | 5 | It's implemented using WebComponents but it favors keeping the component state in the scope of the component rather than embedding it as attributes on the dom nodes. 6 | 7 | ## Usage 8 | 9 | ```html 10 | 11 | 14 | 15 | 16 |
17 | 18 |
19 | ``` 20 | 21 | ## Installing x-widget 22 | 23 | 1. Install x-widget npm package: 24 | 25 | ```bash 26 | npm install x-widget 27 | ``` 28 | 29 | 2. Install xWidget plugin for Alpine.js: 30 | 31 | ```js 32 | import xWidget from 'x-widget' 33 | Alpine.plugin(xWidget) 34 | ``` 35 | 36 | ## Using Slots 37 | 38 | ```html 39 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | ``` 63 | 64 | ## Widget Data 65 | 66 | Widget data is a helper that lets you to define the properties, data types, and the defaults your widget expects. 67 | 68 | It supports giving values for properties using attributes, as well as a new `x-prop` mechanism that makes it easy to have two-way binding of scope data and widget properties. 69 | 70 | In the example below, clicking on "Close" will set `showDropdown` to false. 71 | 72 | ```html 73 | 83 | 84 |
85 | 89 |
90 | ``` 91 | 92 | If you don't like the look of having the widget's spec in the DOM, you can use the following approach too: 93 | 94 | ```html 95 | 106 | 107 | 117 | ``` 118 | -------------------------------------------------------------------------------- /src/x-widget.mjs: -------------------------------------------------------------------------------- 1 | export function slotsMagic(el) { 2 | while (el && !el._x_slots) el = el.parentElement 3 | return el?._x_slots 4 | } 5 | 6 | export function xWidgetDirective(el, { expression, modifiers }, { Alpine }) { 7 | const tagName = expression 8 | 9 | if (window.customElements.get(tagName)) return 10 | 11 | if (modifiers[0]) { 12 | const style = document.createElement('style') 13 | style.innerHTML = `${tagName} { display: ${modifiers[0]}}` 14 | document.head.appendChild(style) 15 | } 16 | 17 | // needed for knowing what widgets have already been defined 18 | if (Alpine._widgets) { 19 | Alpine._widgets.push(tagName) 20 | } else { 21 | Alpine._widgets = [tagName] 22 | } 23 | 24 | const templateContent = el.content.firstElementChild 25 | 26 | window.customElements.define( 27 | tagName, 28 | class extends HTMLElement { 29 | constructor() { 30 | super() 31 | this._slotFills = null 32 | } 33 | connectedCallback() { 34 | let slotFills 35 | if (this._slotFills) { 36 | slotFills = this._slotFills 37 | } else { 38 | slotFills = collectSlotFills(this) 39 | this._slotFills = slotFills 40 | } 41 | const newEl = templateContent.cloneNode(true) 42 | this._x_slots = Object.fromEntries( 43 | [...slotFills.entries()].map(([name, value]) => [name, value]) 44 | ) 45 | 46 | // if (!this.id) { 47 | // this.setAttribute('x-bind:id', Alpine.evaluate(this, `$id('${tagName}')`)) 48 | // } 49 | 50 | const targetSlots = findTargetSlots(newEl) 51 | 52 | for (const targetSlot of targetSlots) { 53 | const slotName = targetSlot.name || 'default' 54 | const fills = slotFills.get(slotName) 55 | if (fills) { 56 | targetSlot.replaceWith(...fills.map((n) => n.cloneNode(true))) 57 | } else { 58 | // shouldn't use cloned children since that might orphan nested slots 59 | targetSlot.replaceWith(...[...targetSlot.childNodes]) 60 | } 61 | } 62 | 63 | requestAnimationFrame(() => { 64 | while (this.firstChild) { 65 | this.removeChild(this.firstChild) 66 | } 67 | this.appendChild(newEl) 68 | }) 69 | 70 | this.dispatchEvent( 71 | new CustomEvent('x-widget:connected', { 72 | bubbles: true 73 | }) 74 | ) 75 | } 76 | } 77 | ) 78 | } 79 | 80 | function findTargetSlots(el) { 81 | let slots = [...el.querySelectorAll('slot')] 82 | if (el.tagName === 'SLOT') slots.unshift(el) 83 | 84 | const templates = el.querySelectorAll('template') 85 | for (const template of templates) { 86 | if (template.getAttribute('x-widget')) continue 87 | for (const child of template.content.children) { 88 | slots.push(...findTargetSlots(child)) 89 | } 90 | } 91 | 92 | return slots 93 | } 94 | 95 | function collectSlotFills(el) { 96 | const slots = new Map() 97 | 98 | function collectForSlot(slotName, nodes) { 99 | if (slots.has(slotName)) { 100 | slots.get(slotName).push(...nodes) 101 | } else { 102 | slots.set(slotName, nodes) 103 | } 104 | } 105 | 106 | for (const child of el.childNodes) { 107 | if (child.tagName === 'TEMPLATE') { 108 | const slotName = child.getAttribute('slot') 109 | 110 | const isSlotFill = 111 | !slotName && (child.getAttribute('x-for') || child.getAttribute('x-if')) 112 | 113 | collectForSlot( 114 | slotName || 'default', 115 | isSlotFill ? [child] : [...child.content.childNodes] 116 | ) 117 | } else if (child.nodeType !== Node.TEXT_NODE || child.textContent.trim()) { 118 | collectForSlot('default', [child]) 119 | } 120 | } 121 | 122 | return slots 123 | } 124 | -------------------------------------------------------------------------------- /test/x-prop.test.mjs: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai/esm/chai.js' 2 | import { xPropDirective, safeLeftHandSide } from '../src/x-prop-directive.mjs' 3 | import Alpine from 'alpinejs' 4 | 5 | const waitUntil = (predicate, timeout = 10000) => 6 | new Promise((resolve, reject) => { 7 | setTimeout(() => reject(new Error('timeout')), timeout) 8 | const waitId = setInterval(() => { 9 | const result = predicate() 10 | 11 | if (result) { 12 | clearInterval(waitId) 13 | resolve(result) 14 | } 15 | }, 1) 16 | }) 17 | 18 | const waitForEl = (selector) => 19 | waitUntil(() => document.querySelector(selector)) 20 | 21 | // so html gets formatted in literals in vscode 22 | const html = String.raw 23 | 24 | describe('x-prop', () => { 25 | before(() => { 26 | document.body.setAttribute('x-data', '') 27 | Alpine.directive('prop', xPropDirective) 28 | window.Alpine = Alpine 29 | Alpine.start() 30 | }) 31 | 32 | beforeEach(() => (document.body.innerHTML = '')) 33 | 34 | it('works in basic case', async () => { 35 | document.body.innerHTML = ` 36 |
37 |
38 | 39 |
40 |
41 | ` 42 | 43 | const spanEl = await waitForEl('span') 44 | 45 | expect(spanEl.innerText).to.equal('10') 46 | 47 | Alpine.evaluate(spanEl, 'y = 20') 48 | 49 | await new Promise((r) => setTimeout(r, 0)) 50 | 51 | const rootEl = await waitForEl('#root') 52 | expect(Alpine.evaluate(rootEl, 'x')).to.equal(20) 53 | }) 54 | 55 | it('sees props in scope', async () => { 56 | document.body.innerHTML = ` 57 |
58 |
59 | 60 |
61 |
62 | ` 63 | 64 | const spanEl = await waitForEl('span') 65 | 66 | expect(spanEl.innerText).to.equal('{"x":10,"y":10}') 67 | }) 68 | 69 | it('sets props on element', async () => { 70 | document.body.innerHTML = ` 71 |
72 | 73 |
74 | ` 75 | 76 | const inputEl = await waitForEl('input') 77 | expect(inputEl.indeterminate).to.be.true 78 | }) 79 | 80 | it('supports deep refs', async () => { 81 | document.body.innerHTML = html` 82 |
83 |
84 |
85 | ` 86 | 87 | const rootEl = await waitForEl('#root') 88 | const innerEl = await waitForEl('.inner') 89 | 90 | await new Promise((r) => setTimeout(r, 0)) 91 | Alpine.evaluate(innerEl, 'x = 20') 92 | await new Promise((r) => setTimeout(r, 0)) 93 | 94 | expect(Alpine.evaluate(rootEl, 'a.b')).to.equal(20) 95 | }) 96 | 97 | it('works with non left hand side expressions', async () => { 98 | document.body.innerHTML = html` 99 |
100 | ` 101 | 102 | const spanEl = await waitForEl('#root') 103 | 104 | expect(spanEl.innerText).to.equal('30') 105 | }) 106 | 107 | it('assigning to non left hand side expressions works', async () => { 108 | document.body.innerHTML = html`
` 109 | 110 | const spanEl = await waitForEl('#root') 111 | Alpine.evaluate(spanEl, 'x = 20') 112 | expect(Alpine.evaluate(spanEl, 'x')).to.equal(20) 113 | }) 114 | }) 115 | 116 | it('can tell if something is safe left hand side', () => { 117 | expect(safeLeftHandSide('x')).to.be.true 118 | expect(safeLeftHandSide('xYz')).to.be.true 119 | expect(safeLeftHandSide('x.y.c')).to.be.true 120 | expect(safeLeftHandSide('x[y]')).to.be.true 121 | expect(safeLeftHandSide('x[y.z][b]')).to.be.true 122 | // TODO: expect(safeLeftHandSide('x[0]')).to.be.true 123 | // TODO: expect(safeLeftHandSide('var')).to.be.false 124 | // expect(safeLeftHandSide('x()')).to.be.false 125 | // expect(safeLeftHandSide('x.0')).to.be.false 126 | // expect(safeLeftHandSide('x.true')).to.be.false 127 | }) 128 | -------------------------------------------------------------------------------- /test/x-widget-data.test.mjs: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai/esm/chai.js' 2 | import plugin from '../src/index.mjs' 3 | import { xWidgetData } from '../src/x-widget-data.mjs' 4 | import Alpine from 'alpinejs' 5 | 6 | const waitUntil = (predicate, timeout = 10000) => 7 | new Promise((resolve, reject) => { 8 | setTimeout(() => reject(new Error('timeout')), timeout) 9 | const waitId = setInterval(() => { 10 | const result = predicate() 11 | 12 | if (result) { 13 | clearInterval(waitId) 14 | resolve(result) 15 | } 16 | }, 1) 17 | }) 18 | 19 | const waitForEl = (selector) => 20 | waitUntil(() => document.querySelector(selector)) 21 | 22 | // so html gets formatted in literals in vscode 23 | const html = String.raw 24 | 25 | describe('x-widget-data', () => { 26 | before(() => { 27 | document.body.setAttribute('x-data', '') 28 | Alpine.plugin(plugin) 29 | Alpine.data('xWidget', xWidgetData.bind(Alpine)) 30 | Alpine.start() 31 | }) 32 | 33 | beforeEach(() => (document.body.innerHTML = '')) 34 | 35 | const tplHtml = `` 42 | 43 | it('uses default if not given', async () => { 44 | document.body.innerHTML = html` 45 | ${tplHtml} 46 | 47 | ` 48 | const c = await waitForEl('.inner') 49 | await new Promise((resolve) => setTimeout(resolve, 10)) 50 | expect(c.innerText).to.contain('SHOW') 51 | }) 52 | 53 | it('supports passing "false"to bool', async () => { 54 | document.body.innerHTML = html` 55 | ${tplHtml} 56 | 57 | ` 58 | const c = await waitForEl('x-c') 59 | expect(c.innerText).not.to.contain('SHOW') 60 | }) 61 | 62 | it('supports using attribute as boolean', async () => { 63 | document.body.innerHTML = html` 64 | ${tplHtml} 65 | 66 | ` 67 | const c = await waitForEl('x-c') 68 | await new Promise((r) => requestAnimationFrame(r)) 69 | 70 | expect(c.innerText).to.contain('SHOW') 71 | }) 72 | 73 | it('supports binding a prop with a scalar value', async () => { 74 | document.body.innerHTML = html` 75 | ${tplHtml} 76 | 77 | ` 78 | const c = await waitForEl('x-c') 79 | 80 | await new Promise((r) => requestAnimationFrame(r)) 81 | 82 | expect(c.innerText).to.contain('SHOW') 83 | }) 84 | 85 | it.skip('supports refs when lhs is complex', async () => { 86 | document.body.innerHTML = html` 87 |
90 |
91 | ` 92 | const c = await waitForEl('x-c') 93 | 94 | expect(c.innerText).to.contain('SHOW') 95 | }) 96 | 97 | it('supports binding a prop with an expression from parent scope', async () => { 98 | document.body.innerHTML = html` 99 | ${tplHtml} 100 |
101 | 102 |
103 | ` 104 | const rootEl = await waitForEl('#root') 105 | 106 | const c = await waitForEl('x-c') 107 | await new Promise((r) => requestAnimationFrame(r)) 108 | expect(c.innerText).to.contain('SHOW') 109 | 110 | Alpine.evaluate(rootEl, 'rootShow = false') 111 | await new Promise((r) => setTimeout(r, 0)) 112 | expect(Alpine.evaluate(c, 'show')).to.equal(false) 113 | expect(c.innerText).not.to.contain('SHOW') 114 | 115 | const cTest = await waitForEl('x-c .t') 116 | 117 | // test reverse direction. x-props two way bind 118 | Alpine.evaluate(cTest, 'show = true') 119 | await new Promise((r) => setTimeout(r, 1)) 120 | expect(Alpine.evaluate(rootEl, 'rootShow')).to.be.true 121 | }) 122 | 123 | it('supports binding an attribute with a scalar value', async () => { 124 | document.body.innerHTML = html` 125 | ${tplHtml} 126 |
127 | 128 | 129 |
130 | ` 131 | const cA = await waitForEl('#a') 132 | await new Promise((r) => requestAnimationFrame(r)) 133 | expect(cA.innerText).to.contain('SHOW') 134 | 135 | const cB = await waitForEl('#b') 136 | expect(cB.innerText).not.to.contain('SHOW') 137 | }) 138 | 139 | it('supports binding attribute value with an expression from parent scope', async () => { 140 | document.body.innerHTML = html` 141 | ${tplHtml} 142 |
143 | 144 |
145 | ` 146 | const rootEl = await waitForEl('#root') 147 | 148 | const c = await waitForEl('x-c') 149 | await new Promise((r) => requestAnimationFrame(r)) 150 | expect(c.innerText).to.contain('SHOW') 151 | 152 | Alpine.evaluate(rootEl, 'rootShow=false') 153 | await new Promise((r) => setTimeout(r, 3)) 154 | 155 | expect(c.innerText).not.to.contain('SHOW') 156 | 157 | const c7Test = await waitForEl('x-c .t') 158 | 159 | // test reverse direction attribute bindings don't two way bind 160 | Alpine.evaluate(c7Test, 'show = true') 161 | await new Promise((r) => setTimeout(r, 1)) 162 | expect(Alpine.evaluate(rootEl, 'rootShow')).to.be.false 163 | }) 164 | 165 | it('supports methods on data', async () => { 166 | document.body.innerHTML = html` 167 | 180 | 181 | 182 | ` 183 | 184 | const xAction = await waitForEl('x-action') 185 | expect(xAction.innerText).not.to.contain('SHOW') 186 | 187 | const button = await waitForEl('button') 188 | button.click() 189 | 190 | await new Promise((r) => setTimeout(r, 1)) 191 | expect(xAction.innerText).to.contain('SHOW') 192 | }) 193 | 194 | it('supports getters and setters', async () => { 195 | document.body.innerHTML = html` 196 | 219 | 220 | 221 | ` 222 | 223 | const xGetSet = await waitForEl('x-getset') 224 | const xGetSetInner = await waitForEl('x-getset #inner') 225 | 226 | await new Promise((r) => setTimeout(r, 1)) 227 | expect(xGetSet.innerText).to.contain('Untested') 228 | 229 | Alpine.evaluate(xGetSetInner, 'test()') 230 | await new Promise((r) => setTimeout(r, 100)) 231 | 232 | expect(xGetSet.innerText).to.contain('Tested') 233 | }) 234 | 235 | it('supports binding a camelcase prop', async () => { 236 | document.body.innerHTML = html` 237 | ${tplHtml} 238 |
239 | 240 |
241 | ` 242 | const c = await waitForEl('x-c') 243 | await new Promise((r) => setTimeout(r, 100)) 244 | expect(c.innerText).to.contain('John') 245 | }) 246 | }) 247 | -------------------------------------------------------------------------------- /test/x-widget.test.mjs: -------------------------------------------------------------------------------- 1 | import { expect } from '@esm-bundle/chai/esm/chai.js' 2 | import plugin from '../src/index.mjs' 3 | 4 | import { slotsMagic } from '../src/x-widget.mjs' 5 | 6 | import Alpine from 'alpinejs' 7 | 8 | const waitUntil = (predicate, timeout = 10000) => 9 | new Promise((resolve, reject) => { 10 | setTimeout(() => reject(new Error('timeout')), timeout) 11 | const waitId = setInterval(() => { 12 | const result = predicate() 13 | 14 | if (result) { 15 | clearInterval(waitId) 16 | resolve(result) 17 | } 18 | }, 1) 19 | }) 20 | 21 | const waitForEl = (selector) => 22 | waitUntil(() => document.querySelector(selector)) 23 | 24 | // so html gets formatted in literals in vscode 25 | const html = String.raw 26 | 27 | before(() => { 28 | document.body.setAttribute('x-data', '{rows: [1,2,3] , columns: [1,2]}') 29 | Alpine.plugin(plugin) 30 | Alpine.start() 31 | }) 32 | 33 | beforeEach(() => (document.body.innerHTML = '')) 34 | 35 | it('works in basic case', async () => { 36 | document.body.innerHTML = ` 37 | 40 | 41 | 42 | ` 43 | 44 | const innerEl = await waitForEl('.inner') 45 | 46 | expect(innerEl.parentElement.tagName).to.equal('X-TEST1') 47 | }) 48 | 49 | // need to investigate the best way to handle this 50 | it.skip('automatically assigns an id to widgets', async () => { 51 | document.body.innerHTML = ` 52 | 55 | 56 | 57 | ` 58 | 59 | const xTest1 = await waitForEl('x-test1') 60 | 61 | await waitForEl('.inner') 62 | 63 | expect(xTest1.id).not.to.equal('') 64 | }) 65 | 66 | it('fires event when component is connected', async () => { 67 | let connectedEvent = new Promise((resolve) => 68 | document.body.addEventListener('x-widget:connected', resolve, { 69 | once: true 70 | }) 71 | ) 72 | document.body.innerHTML = ` 73 | 76 | 77 | 78 | ` 79 | 80 | await waitForEl('.inner') 81 | const event = await connectedEvent 82 | 83 | expect(event.type).to.equal('x-widget:connected') 84 | expect(event.target).to.equal(document.body.querySelector('x-test1')) 85 | }) 86 | 87 | it('supports default slot', async () => { 88 | document.body.innerHTML = html` 89 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 108 | 109 | 110 | 111 | 112 | 113 | ` 114 | 115 | // text 116 | { 117 | const innerEl = await waitForEl('#text .inner') 118 | expect(innerEl.innerHTML.trim()).to.equal('Hello World') 119 | } 120 | 121 | /* // single html element 122 | { 123 | const innerEl = await waitForEl('#html .inner') 124 | expect(innerEl.innerHTML.trim()).to.equal('
Hello World
') 125 | } 126 | 127 | // many html elements 128 | { 129 | const innerEl = await waitForEl('#html2 .inner') 130 | expect(innerEl.querySelectorAll('div')).to.have.length(2) 131 | } 132 | 133 | // with named default slot 134 | { 135 | const innerEl = await waitForEl('#named .inner') 136 | expect(innerEl.innerHTML.trim()).to.equal('
Hello World
') 137 | }*/ 138 | }) 139 | 140 | it('supports default slot without template tag', async () => { 141 | document.body.innerHTML = html` 142 | 147 | 148 | 149 | Hello 150 |
World
151 |
152 | ` 153 | 154 | const innerEl = await waitForEl('#test .inner') 155 | expect(innerEl.innerText.replace(/\s+/g, ' ').trim()).to.equal('Hello World') 156 | }) 157 | 158 | it('supports named slots', async () => { 159 | document.body.innerHTML = html` 160 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | ` 194 | 195 | // text 196 | { 197 | const headerEl = await waitForEl('#text .header') 198 | expect(headerEl.innerHTML.trim()).to.equal('Header') 199 | 200 | const defaultEl = await waitForEl('#text .default') 201 | expect(defaultEl.innerHTML.trim()).to.equal('Default') 202 | } 203 | 204 | // html elements 205 | { 206 | const headerEl = await waitForEl('#html .header') 207 | expect(headerEl.querySelectorAll('div')).to.have.length(2) 208 | 209 | const defaultEl = await waitForEl('#html .default') 210 | expect(defaultEl.innerHTML.trim()).to.equal('
Default
') 211 | } 212 | 213 | // duplicate slots get merged 214 | { 215 | const headerEl = await waitForEl('#duplicates .header') 216 | expect(headerEl.querySelectorAll('div')).to.have.length(2) 217 | expect(headerEl.innerText).to.match(/^\s*Header 1\s*Header 2\s*$/) 218 | } 219 | 220 | // uses default if not given 221 | { 222 | const headerEl = await waitForEl('#defaults .header') 223 | expect(headerEl.innerText).to.equal('Default Header') 224 | } 225 | 226 | // can get slot element if slot given 227 | { 228 | const inspectEl = await waitForEl('#inspection') 229 | expect(slotsMagic(inspectEl).header).to.have.lengthOf(1) 230 | } 231 | }) 232 | 233 | it('expose slot DOM in $slots', async () => { 234 | document.body.innerHTML = html` 235 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | ` 253 | 254 | // can get slot element if slot given 255 | { 256 | const slotEl = await waitForEl('#slot') 257 | expect(slotsMagic(slotEl).default).to.have.lengthOf(2) 258 | expect(slotsMagic(slotEl).header).to.have.lengthOf(2) 259 | } 260 | }) 261 | 262 | it('supports nested slots', async () => { 263 | document.body.innerHTML = html` 264 | 277 | 278 | 279 | 280 | 281 | ` 282 | 283 | // can get slot element if slot given 284 | { 285 | const innerEl = await waitForEl('#slot .inner') 286 | await new Promise((r) => setTimeout(r, 1000)) 287 | expect(innerEl.innerText).to.have.string('Inner') 288 | } 289 | }) 290 | --------------------------------------------------------------------------------