Skip to content

Commit 141c4af

Browse files
committed
Add Alpine.js input binding directive.
1 parent 05ecb0f commit 141c4af

File tree

3 files changed

+328
-0
lines changed

3 files changed

+328
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ with.
1010

1111
## My JavaScript Demos - I Love JavaScript!
1212

13+
* [An "x-input" Property Binding Directive In Alpine.js](https://bennadel.github.io/JavaScript-Demos/demos/input-directive-alpine)
1314
* [Creating A Tri-State Switch In Alpine.js](https://bennadel.github.io/JavaScript-Demos/demos/tri-state-toggle)
1415
* [Highlighting Dynamic Parts Of A Pretty-Printed JSON Value](https://bennadel.github.io/JavaScript-Demos/demos/highlighting-json)
1516
* [Exploring Randomness In JavaScript](https://bennadel.github.io/JavaScript-Demos/demos/web-crypto-rand)
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>
7+
An "x-input" Property Binding Directive In Alpine.js
8+
</title>
9+
<link rel="stylesheet" type="text/css" href="./main.css" />
10+
</head>
11+
<body>
12+
13+
<h1>
14+
An "x-input" Property Binding Directive In Alpine.js
15+
</h1>
16+
17+
<div x-data="OuterComponent">
18+
19+
<button @click="increment()">
20+
Increment: <span x-text="counter"></span>
21+
</button>
22+
23+
<!--
24+
Note that I'm using the "x-input" directive to map / translate outer component
25+
scope values onto inner scope values. This way, the logic within the inner
26+
component isn't coupled to the NAMES or ARCHITECTURE of the outer component
27+
scope references.
28+
--
29+
innerA => counter * 2
30+
innerB => counter * 3
31+
-->
32+
<ul
33+
x-data="InnerComponent"
34+
x-input:inner-a.camel="( counter * 2 )"
35+
x-input:inner-b.camel="( counter * 3 )">
36+
<li>
37+
<strong>Inner A:</strong>
38+
<span x-text="innerA"></span>
39+
</li>
40+
<li>
41+
<strong>Inner B:</strong>
42+
<span x-text="innerB"></span>
43+
</li>
44+
<li>
45+
<strong>Total:</strong>
46+
<span x-text="total"></span>
47+
</li>
48+
</ul>
49+
50+
</div>
51+
52+
<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
53+
<script type="text/javascript">
54+
55+
/**
56+
* I control the outer component.
57+
*/
58+
function OuterComponent() {
59+
60+
return {
61+
counter: 10,
62+
63+
/**
64+
* I increment the counter. The "x-input" directive(s) will then map the
65+
* outer value onto several inner properties of the inner component.
66+
*/
67+
increment() {
68+
69+
this.counter++;
70+
71+
}
72+
};
73+
74+
}
75+
76+
/**
77+
* I control the inner component.
78+
*/
79+
function InnerComponent() {
80+
81+
// The "x-input" directives have ALREADY been bound on the "this" scope by the
82+
// time the "x-data" directive is executed. As such, we could make use of
83+
// "this.innerA" and "this.innerB" values within the component constructor if
84+
// we wanted to. But, I'm deferring to the init() life-cycle hook.
85+
return {
86+
total: 0, // ( this.innerA + this.innerB ) ... would have worked as well.
87+
88+
/**
89+
* I get called once to initialize the component.
90+
*/
91+
init() {
92+
93+
this.setTotal();
94+
95+
},
96+
97+
/**
98+
* I get called when any of the "x-input" bindings change.
99+
*/
100+
inputChanges( newInputs, oldInputs ) {
101+
102+
this.setTotal();
103+
104+
// Logging changes to see that this is actually working.
105+
console.group( "Inputs Changed" );
106+
console.log( `InnerA: ${ oldInputs.innerA } to ${ newInputs.innerA }.` )
107+
console.log( `InnerB: ${ oldInputs.innerB } to ${ newInputs.innerB }.` )
108+
console.groupEnd();
109+
110+
},
111+
112+
/**
113+
* I set the total based on the current "x-input" bindings.
114+
*/
115+
setTotal() {
116+
117+
this.total = ( this.innerA + this.innerB );
118+
119+
}
120+
};
121+
122+
}
123+
124+
// --------------------------------------------------------------------------- //
125+
// --------------------------------------------------------------------------- //
126+
127+
document.addEventListener(
128+
"alpine:init",
129+
() => {
130+
131+
// NOTE: We need the "x-input" directive to run BEFORE the "x-data"
132+
// directive so that the input bindings are available on the dataStack
133+
// at the time the "x-data" component has been evaluated (which is
134+
// inherently before the component's init() method executes).
135+
Alpine.directive( "input", InputDirective )
136+
.before( "data" )
137+
;
138+
139+
}
140+
);
141+
142+
function InputDirective( element, metadata, framework ) {
143+
144+
// Note: Even though the "x-input" directive is executing with a higher
145+
// priority than the "x-data" directive, the .closestRoot() method works by
146+
// walking up the DOM and looking for the "x-data" attribute. Therefore, even
147+
// though the "x-data" directive hasn't been initialized on this element yet,
148+
// as long as the "x-data" attribute is present, this closest-root check will
149+
// still be valid.
150+
if ( element !== Alpine.closestRoot( element ) ) {
151+
152+
throw( new Error( "The [x-input] directive can only be used on an element that has an [x-data] directive." ) );
153+
154+
}
155+
156+
var inputScope = getInputScope();
157+
var inputName = metadata.value;
158+
var inputExpression = metadata.expression;
159+
var inputEvaluator = framework.evaluateLater( inputExpression );
160+
161+
// Allow inputs like "x-input:my-name" to mapped locally as "myName".
162+
if ( metadata.modifiers.includes( "camel" ) ) {
163+
164+
inputName = toCamelCase( inputName );
165+
166+
}
167+
168+
// Every time the outer value (expression) changes, we need to persist that
169+
// value to the input scope. This will always be executed BEFORE the
170+
// "inputChanges()" hook is called below.
171+
Alpine.effect(
172+
() => {
173+
174+
inputEvaluator(
175+
( inputValue ) => {
176+
177+
inputScope[ inputName ] = inputValue;
178+
179+
}
180+
);
181+
182+
}
183+
);
184+
185+
/**
186+
* I get the input scope bound to the current element (or wire it up if it
187+
* hasn't yet been created).
188+
*/
189+
function getInputScope() {
190+
191+
var domProperty = "_x_inputScope";
192+
var inputScope = element[ domProperty ];
193+
194+
// Return the existing scope if possible.
195+
if ( inputScope ) {
196+
197+
return inputScope;
198+
199+
}
200+
201+
// If no input scope exists, we need to add it to the dataStack for this
202+
// element. Since the "x-input" directive is being evaluated BEFORE the
203+
// "x-data" directive, it will live one level up from the "x-data" scope
204+
// (but within the same element).
205+
Alpine.addScopeToNode(
206+
element,
207+
inputScope = element[ domProperty ] = Alpine.reactive( Object.create( null ) )
208+
);
209+
210+
var previousScope = Object.create( null );
211+
var fullScopeProxy = null;
212+
213+
// We want to watch for all changes to the input scope so that we can
214+
// invoke the "changes" method as needed.
215+
var effectReference = framework.effect(
216+
() => {
217+
218+
// This effect runs for the first time when the first "x-input"
219+
// directive is evaluated. However, at this time, the subsequent
220+
// "x-data" directive hasn't run yet (let alone any of the other
221+
// "x-input" directives that might be declared on this element).
222+
// As such, we have to wait for the length of the scope to change
223+
// before we initialize the full scope proxy. If we do this too
224+
// early, the "x-data" scope hasn't been "unshifted" onto the head
225+
// of data stack yet (and won't be included in the merged proxy).
226+
if ( ! Object.entries( inputScope ).length ) {
227+
228+
return;
229+
230+
}
231+
232+
// If the length of the input scope has changed, it means that the
233+
// subsequent "x-input" directives have all been evaluated for the
234+
// first time; which also means that the "x-data" directive has
235+
// also been evaluated and added to the data stack. As such, we
236+
// can now define our full scope merge, which will included the
237+
// "x-data" entry.
238+
if ( ! fullScopeProxy ) {
239+
240+
fullScopeProxy = Alpine.mergeProxies( Alpine.closestDataStack( element ) );
241+
Object.assign( previousScope, inputScope );
242+
// We're going to assume that any initial consumption of the
243+
// bound inputs will happen within the init() method of the
244+
// associated component. As such, we'll just short-circuit the
245+
// second run of this effect (which is the first run with all
246+
// of the initialized input values).
247+
return;
248+
249+
}
250+
251+
fullScopeProxy?.inputChanges( inputScope, previousScope );
252+
Object.assign( previousScope, inputScope );
253+
254+
}
255+
);
256+
257+
// Note: I'm not entirely sure how much stuff needs to be cleaned up in
258+
// this world of merged proxies and reactive scopes. As such, I may very
259+
// well be doing more here I have to.
260+
framework.cleanup(
261+
() => {
262+
263+
// Remove reactivity.
264+
Alpine.release( effectReference );
265+
// Remove DOM expando property.
266+
delete element[ domProperty ];
267+
// Free-up other memory references ???? Probably unnecessary.
268+
delete fullScopeProxy;
269+
delete inputScope;
270+
delete previousScope;
271+
272+
}
273+
);
274+
275+
return inputScope;
276+
277+
}
278+
279+
/**
280+
* I convert the given dashed value to a camel-case value.
281+
*/
282+
function toCamelCase( value ) {
283+
284+
return value.toLowerCase().replace(
285+
/-(\w)/g,
286+
( $0, $1 ) => {
287+
288+
return $1.toUpperCase();
289+
290+
}
291+
);
292+
293+
}
294+
295+
}
296+
297+
</script>
298+
299+
</body>
300+
</html>

demos/input-directive-alpine/main.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
2+
html {
3+
box-sizing: border-box ;
4+
}
5+
html *,
6+
html *:before,
7+
html *:after {
8+
box-sizing: inherit ;
9+
}
10+
11+
body {
12+
font-family: monospace ;
13+
font-size: 18px ;
14+
line-height: 1.4 ;
15+
}
16+
17+
button {
18+
font-family: inherit ;
19+
font-size: inherit ;
20+
line-height: inherit ;
21+
}
22+
23+
ul {
24+
border: 2px dashed red ;
25+
line-height: 1.6 ;
26+
padding: 10px 10px 10px 30px ;
27+
}

0 commit comments

Comments
 (0)