├── .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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Header
27 | Panel 1 Content
28 |
29 |
30 |
31 | Header
32 | Panel 2 Content
33 |
34 |
35 |
36 | Panel 3 Content
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 |
12 |
13 |
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 |
40 |
41 |
42 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | Panel Header
56 |
57 |
58 |
59 | Panel Content
60 |
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 |
74 |
75 |
76 | Close
77 |
78 |
79 |
80 |
81 |
82 |
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 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
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 |
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 |
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 |
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 = `
36 |
41 | `
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 |
168 |
179 |
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 |
197 |
214 |
Untest
215 |
Test
216 |
217 |
218 |
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 |
38 | Hello World
39 |
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 |
53 | Hello World
54 |
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 |
74 | Hello World
75 |
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 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | Hello World
97 |
98 |
99 |
100 | Hello World
101 |
102 |
103 |
104 | Hello
106 | World
108 |
109 |
110 |
111 | Hello World
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 |
143 |
144 |
145 |
146 |
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 |
161 |
162 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 | Header
173 | Default
174 |
175 |
176 |
177 | Header
179 | Header
181 | Default
182 |
183 |
184 |
185 | Header 1
186 | Header 2
187 |
188 |
189 |
190 |
191 | Header
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 |
236 |
237 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 | Header1
248 | Header2
249 | Default1
250 | Default2
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 |
265 |
266 |
267 |
268 |
269 |
270 | Hello
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 | Inner
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 |
--------------------------------------------------------------------------------