mirror of
https://github.com/archtechx/alpine-reactive.git
synced 2025-12-12 00:14:03 +00:00
initial commit
This commit is contained in:
commit
f0834bcfdb
4 changed files with 244 additions and 0 deletions
86
README.md
Normal file
86
README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# alpine-reactive
|
||||||
|
|
||||||
|
This package provides a reactivity layer for Alpine 2.x.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When you create a component that uses a value defined outside of it, you can modify the value from Alpine, but not vice versa:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div x-data="{ counter: window.clickCount "}>
|
||||||
|
<button @click="counter++">+</button>
|
||||||
|
Click count: <span x-text="count" />
|
||||||
|
<button @click="counter++">--</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking the buttons **will** update `window.clickCount`. However when `window.clickCount` is modified outside of the component, Alpine won't notice, and won't re-render the DOM.
|
||||||
|
|
||||||
|
Only after something else triggers a re-render, Alpine will show the correct click count again.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
This package provides a reactive proxy wrapper for these objects. The syntax is very similar to Vue 3 — you just wrap the variable in `reactive()` and all changes will be tracked.
|
||||||
|
|
||||||
|
One difference between this package's solution and Vue's `reactive()` is that Alpine requires the calls to be **component-specific**. Meaning, the `reactive()` helper also needs the component instance/element. To simplify that, the package also provides a magic Alpine property, `$reactive`.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
window.counter = reactive({
|
||||||
|
count: 10,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div x-data="{ counter: $reactive(window.counter) }">
|
||||||
|
<button @click="counter.count--">-</button>
|
||||||
|
Click count: <span x-text="counter.count"></span>
|
||||||
|
<button @click="counter.count++">+</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood, this creates a proxy that forwards everything to `window.clickCount`, but
|
||||||
|
|
||||||
|
## Full API
|
||||||
|
|
||||||
|
### reactive(target, componentEl = null): Proxy for target
|
||||||
|
|
||||||
|
This creates a reactive proxy for `object`. If `componentEl` is passed, all writes to this proxy will trigger `updateElements()` on `componentEl`'s Alpine instance.
|
||||||
|
|
||||||
|
### ref(val): { value: val }
|
||||||
|
|
||||||
|
This turns `foo` into `{ value: foo }`, which allows for `foo` — a primitive type in this example — to be used with proxies.
|
||||||
|
|
||||||
|
### isRef(value): bool
|
||||||
|
|
||||||
|
Checks if a value is a ref.
|
||||||
|
|
||||||
|
### unRef(value)
|
||||||
|
|
||||||
|
Syntactic sugar for `isRef(value) ? value.value : value`.
|
||||||
|
|
||||||
|
### isReactiveProxy(proxy): bool
|
||||||
|
|
||||||
|
Checks whether the passed variable is a value returned by `reactive()`.
|
||||||
|
|
||||||
|
### watch(target, (key, value) => void) or watch(target, value => void), property)
|
||||||
|
|
||||||
|
Watches a reactive proxy, or a property on the reactive proxy.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```js
|
||||||
|
watch(window.counter, (key, value) => console.log(`${key} was changed to ${value}`)); // Watch proxy
|
||||||
|
watch(window.counter, count => console.log(count), 'count'); // Watch property
|
||||||
|
```
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
The package provides a `$reactive` magic property for Alpine. That property is syntactic sugar for `reactive(target, $el)`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- counter: reactive(window.counter, $el)
|
||||||
|
+ counter: $reactive(window.counter)
|
||||||
|
```
|
||||||
|
|
||||||
|
The magic property is added using the exported `addMagicProperty()` function, which is called once Alpine is available by the `register()` function. `register()` is called automatically for friendly CDN imports.
|
||||||
23
demo.html
Normal file
23
demo.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<style>
|
||||||
|
/* Makes the demo code cleaner, but damn writing CSS without Tailwind awful. */
|
||||||
|
body { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; } div { display: flex; align-items: center; margin-bottom: 20px; } button { padding: 8px; margin: 24px; } * { font-size: 1.4rem ;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { reactive, watch } from './src/index.js'
|
||||||
|
|
||||||
|
import 'https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js'
|
||||||
|
|
||||||
|
window.counter = reactive({
|
||||||
|
count: 10,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div x-data="{ counter: $reactive(window.counter) }">
|
||||||
|
<button @click="counter.count--">-</button>
|
||||||
|
Click count: <span x-text="counter.count"></span>
|
||||||
|
<button @click="counter.count++">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span>Try using <code>counter.count = 25</code> in the console.</span>
|
||||||
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "alpine-reactive",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Reactivity layer for Alpine 2.x",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/archtechx/alpine-reactive.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"alpine.js",
|
||||||
|
"reactivity"
|
||||||
|
],
|
||||||
|
"author": "Samuel Štancl <samuel@archte.ch>",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/archtechx/alpine-reactive/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/archtechx/alpine-reactive#readme"
|
||||||
|
}
|
||||||
112
src/index.js
Normal file
112
src/index.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
let proxies = window.__ALPINE_REACTIVE_PROPERTIES__ = new WeakMap
|
||||||
|
let refs = window.__ALPINE_REFS__ = new WeakSet
|
||||||
|
|
||||||
|
let reactiveHandler = () => ({
|
||||||
|
__alpine_componentEls: [],
|
||||||
|
__watchers: [],
|
||||||
|
|
||||||
|
get (target, key, value, receiver) {
|
||||||
|
if (key === '__alpine_componentEls' || key === '__watchers') {
|
||||||
|
return this[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = Reflect.get(target, key, value, receiver)
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
set(target, key, value, receiver) {
|
||||||
|
Reflect.set(target, key, value, receiver)
|
||||||
|
|
||||||
|
for (const el of this.__alpine_componentEls) {
|
||||||
|
if (el && document.contains(el) && el.__x) {
|
||||||
|
setTimeout(() => el.__x.updateElements(el), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const watcher of this.__watchers) {
|
||||||
|
watcher(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function isRef(value) {
|
||||||
|
return refs.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unRef(value) {
|
||||||
|
return isRef(value) ? value.value : value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ref(value) {
|
||||||
|
const obj = { value }
|
||||||
|
|
||||||
|
refs.add(obj)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReactiveProxy(proxy) {
|
||||||
|
return proxy && proxy.__alpine_componentEls !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reactive(value, componentEl = null) {
|
||||||
|
let result
|
||||||
|
|
||||||
|
if (isReactiveProxy(value)) {
|
||||||
|
// The value is a reactive() proxy, so we use it immediately
|
||||||
|
result = value
|
||||||
|
} else if (proxies.has(value)) {
|
||||||
|
// The value is not a reactive() proxy, but we found that there
|
||||||
|
// is a registered proxy for the object in value, so we get it.
|
||||||
|
result = proxies.get(value)
|
||||||
|
} else {
|
||||||
|
// There's no proxy available, so we create one.
|
||||||
|
result = new Proxy(value, reactiveHandler())
|
||||||
|
proxies.set(value, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentEl && ! result.__alpine_componentEls.includes(componentEl)) {
|
||||||
|
result.__alpine_componentEls.push(componentEl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watch(proxy, callback, key = null) {
|
||||||
|
if (! proxy || ! proxy.__watchers) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
proxy.__watchers.push((k, v) => {
|
||||||
|
if (k === key) {
|
||||||
|
callback(v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
proxy.__watchers.push(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMagicProperty() {
|
||||||
|
window.Alpine.addMagicProperty('reactive', el => {
|
||||||
|
return function (property) {
|
||||||
|
return reactive(property, el)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
const deferrer = window.deferLoadingAlpine || function (callback) { callback() }
|
||||||
|
|
||||||
|
window.deferLoadingAlpine = function (callback) {
|
||||||
|
addMagicProperty()
|
||||||
|
|
||||||
|
deferrer(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue