...
`
123 |
124 | **構造:** `...
`
125 |
126 | `x-data` は新しいコンポーネントスコープを宣言します。フレームワークに、データオブジェクトを使用して新しいコンポーネントを初期化するよう指示します。
127 |
128 | Vue コンポーネントの `data` プロパティのように考えてください。
129 |
130 | **コンポーネントロジックの抽出**
131 |
132 | データ(と動作)を再利用可能な関数に抽出できます:
133 |
134 | ```html
135 |
159 | ```
160 |
161 | ---
162 |
163 | ### `x-init`
164 | **例:** `
`
165 |
166 | **構造:** `
`
167 |
168 | `x-init` はコンポーネントが初期化されると式を実行します。
169 |
170 | Alpine が DOM(VueJS の `mounted()` フックのようなもの)に最初の更新を行った後にコードを実行したい場合、`x-init` からコールバックを返すことができ、その後実行されます:
171 |
172 | `x-init="return () => { // ここで初期化後の DOM ステートにアクセスできます // }"`
173 |
174 | ---
175 |
176 | ### `x-show`
177 | **例:** `
`
178 |
179 | **構造:** `
`
180 |
181 | `x-show` は、式が `true` または `false` のどちらかの結果によって、要素の `display: none;` スタイルを切り替えます。
182 |
183 | ---
184 |
185 | ### `x-bind`
186 |
187 | > 注意: 短い「:」シンタックスを使えます: `:type="..."`
188 |
189 | **例:** `
`
190 |
191 | **構造:** `
`
192 |
193 | `x-bind` は、属性の値を JavaScript 式の結果を設定します。この式は、コンポーネントのデータオブジェクトのすべてのキーにアクセスでき、データが更新されるたびに反映されます。
194 |
195 | > 注意: 属性バインディングは、依存関係が更新されたときにのみ更新されます。このフレームワークは、データの変化を観察し、どのバインディングがそれらを検出するのか最適化されています。
196 |
197 | **`x-bind` for class attributes**
198 |
199 | `x-bind` は、`class` 属性にバインドするときの動作が少し異なります。
200 |
201 | クラスの場合、キーがクラス名であり、値がそれらのクラス名が適用されるかどうかを決定するブール式であるオブジェクトを渡します。
202 |
203 | 例:
204 | `
`
205 |
206 | この例では、「hidden」クラスは、`foo` データ属性値が `true` の場合にのみ適用されます。
207 |
208 | **ブール属性の `x-bind`**
209 |
210 | `x-bind` は、値属性と同じ方法でブール値属性をサポートします。条件として変数を使用するか、`true` または `false` に解決される JavaScript 式を使用します。
211 |
212 | 例:
213 | `
Click me `
214 |
215 | `myVar` が true または false の場合に `disabled` 属性を追加または削除します。
216 |
217 | `readonly`, `required` など、最も一般的なブール属性がサポートされています。
218 |
219 | ---
220 |
221 | ### `x-on`
222 |
223 | > 注意: より短い「@」シンタックスを自由に使用できます: `@click="..."`
224 |
225 | **例:** `
`
226 |
227 | **構造:** `
`
228 |
229 | `x-on` は、イベントリスナを宣言された要素にアタッチします。そのイベントが発行されると、その値として設定された JavaScript 式が実行されます。
230 |
231 | 式でデータが変更されると、このデータに「バインドされた」他の要素属性が更新されます。
232 |
233 | **`keydown` 修飾子**
234 |
235 | **例:** `
`
236 |
237 | `x-on:keydown` ディレクティブに追加された keydown 修飾子を使用し、待ち受けする特定のキーを指定できます。修飾子は `Event.key` 値のケバブケースであることに注意してください。
238 |
239 | 例: `enter`, `escape`, `arrow-up`, `arrow-down`
240 |
241 | > 注意: 次のようなシステム修飾子キーの組み合わせを待ち受けもできます: `x-on:keydown.cmd.enter="foo"`
242 |
243 | **`.away` 修飾子**
244 |
245 | **例:** `
`
246 |
247 | `.away` 修飾子を付与すると、イベントがそれ自体またはその子以外のソースから発生した場合にのみイベントハンドラは実行されます。
248 |
249 | ユーザがクリックしたときにドロップダウンやモーダルを非表示にするのに便利です。
250 |
251 | **`.prevent` 修飾子**
252 | **例:** `
`
253 |
254 | イベントリスナに `.prevent` を追加すると、トリガされたイベントで `preventDefault` が呼び出されます。上記の例では、ユーザがクリックしたときにチェックボックスが実際にチェックされないことを意味します。
255 |
256 | **`.stop` 修飾子**
257 | **例:** `
`
258 |
259 | イベントリスナに `.stop` を追加すると、トリガされたイベントで `stopPropagation` が呼び出されます。上記の例では、ボタンから外側の `
` に「click」イベントが浮上しないことを意味します。言い換えると、ユーザがボタンをクリックしても、`foo` は `'bar'` に設定されません。
260 |
261 | **`.window` 修飾子**
262 | **例:** `
`
263 |
264 | イベントリスナに `.window` を付与すると、それが宣言されている DOM ノードではなく、グローバル window オブジェクトにリスナがインストールされます。これは、resize イベントなど window で何かが変更されたときにコンポーネントの状態を変更する場合に役立ちます。この例では、ウィンドウの幅が768ピクセルを超えた場合、モーダル/ドロップダウンを閉じます。それ以外の場合は同じ状態を維持します。
265 |
266 | > 注意: `.document` 修飾子を使用して、リスナを `window` の代わりに `document` にアタッチすることもできます。
267 |
268 | **`.once` 修飾子**
269 | **例:** `
`
270 |
271 | イベントリスナに `.once` 修飾子を付与すると、リスナが1回だけ処理されることが保証されます。HTML パーシャルの取得など、1度だけ実行したい場合に便利です。
272 |
273 | ---
274 |
275 | ### `x-model`
276 | **例:** `
`
277 |
278 | **構造:** `
`
279 |
280 | `x-model` は要素に「双方向データバインディング」を追加します。つまり、入力要素の値はコンポーネントの項目データの値と同期します。
281 |
282 | > 注意: `x-model` は、テキストインプット、チェックボックス、ラジオボタン、テキストエリア、セレクト、およびマルチセレクトの変更を検出するのに最適です。これらのシナリオで [Vue がどのように](https://vuejs.org/v2/guide/forms.html)動作するのか確認してください。
283 |
284 | ---
285 |
286 | ### `x-text`
287 | **例:** `
`
288 |
289 | **構造:** `
`
297 |
298 | **構造:** `
`
306 |
307 | **構造:** `
`
308 |
309 | `x-ref` は、コンポーネントから生 DOM 要素を取得する便利な方法を提供します。要素に `x-ref` 属性を設定することで、すべてのイベントハンドラで `$refs` というオブジェクト内から利用できるようになります。
310 |
311 | これは、ID を設定し、あらゆる場所で `document.querySelector` を使用する代替手段に役立ちます。
312 |
313 | ---
314 |
315 | ### `x-if`
316 | **例:** `
Some Element
`
317 |
318 | **構造:** `
Some Element
`
319 |
320 | `x-show` では不十分な場合(`x-show` が false の場合、要素を `display: none` に設定します)、`x-if` を使用して DOM から要素を完全に削除できます。
321 |
322 | Alpine は仮想 DOM を使用しないため、`
` タグで `x-if` を使用することが重要です。この実装により、Alpine は堅牢性を保ち、実際の DOM を使用してその仕様を働かせることができます。
323 |
324 | > 注意: `x-if` には、`
` タグ内に単一要素のルートが必要です。
325 |
326 | ---
327 |
328 | ### `x-for`
329 | **例:**
330 | ```html
331 |
332 |
333 |
334 | ```
335 |
336 | `x-for` は、配列の各アイテム毎に新しい DOM ノードを作成する場合に使用します。これは、Vue の `v-for` に似ています。ただし、通常の DOM 要素ではなく、`template` タグに存在する必要があります。
337 |
338 | > 注意: `:key` バインディングはオプションですが、強く推奨しています。
339 |
340 | ---
341 |
342 | ### `x-transition`
343 | **例:**
344 | ```html
345 |
...
354 | ```
355 |
356 | ```html
357 |
358 | ...
366 |
367 | ```
368 |
369 | Alpine は、「非表示」と「表示」の遷移間のさまざまな段階にクラスを要素に適用するための6つの異なるトランジションディレクティブを提供します。これらのディレクティブは、`x-show` と `x-if` の両方で機能します。
370 |
371 | これらは、VueJs のトランジションディレクティブとまったく同じように動作しますが、より理にかなった異なる名前を持っています。
372 |
373 | | ディレクティブ | 説明 |
374 | | --- | --- |
375 | | `:enter` | エンターフェーズ全体に適用されます。 |
376 | | `:enter-start` | 要素が挿入される前に追加され、要素が挿入の1フレーム後に削除されます。 |
377 | | `:enter-end` | 要素が挿入後(`enter-start` が削除されると同時)の1フレームに追加され、トランジション/アニメーションが終了すると削除されます。
378 | | `:leave` | リーブフェーズ全体に適用されます。 |
379 | | `:leave-start` | リーブ遷移がトリガされるとすぐに追加され、1フレーム後に削除されます。 |
380 | | `:leave-end` | リーブ遷移がトリガされた後(同時に `leave-start` が削除される)の1フレームに追加され、トランジション/アニメーションが終了すると削除されます。
381 |
382 | ---
383 |
384 | ### `x-cloak`
385 | **例:** `
`
386 |
387 | Alpine の初期化時に、要素から `x-cloak` 属性が削除されます。これは、事前に初期化された DOM を隠すのに役立ちます。これが機能するためには、通常、次のグローバルスタイルを追加します。:
388 |
389 | ```html
390 |
393 | ```
394 |
395 | ### マジックプロパティ
396 |
397 | ---
398 |
399 | ### `$el`
400 | **例:**
401 | ```html
402 |
403 | "foo"に置換します
404 |
405 | ```
406 |
407 | `$el` は、ルートコンポーネントの DOM ノードを取得するために使用できるマジックプロパティです。
408 |
409 | ### `$refs`
410 | **例:**
411 | ```html
412 |
413 |
414 |
415 | ```
416 |
417 | `$refs` は、コンポーネント内で `x-ref` でマークされた DOM 要素を取得するために使用できるマジックプロパティです。これは、DOM 要素を手動で操作する必要がある場合に便利です。
418 |
419 | ---
420 |
421 | ### `$event`
422 | **例:**
423 | ```html
424 |
425 | ```
426 |
427 | `$event` は、ネイティブブラウザの「Event」オブジェクトを取得するためにイベントリスナ内で使用できるマジックプロパティです。
428 |
429 | ---
430 |
431 | ### `$dispatch`
432 | **例:**
433 | ```html
434 |
435 |
436 |
437 |
438 | ```
439 |
440 | `$dispatch` は、`CustomEvent` を作成し、内部で `.dispatchEvent()` を使用してディスパッチするためのショートカットです。カスタムイベントを使用してコンポーネント間およびコンポーネント間でデータを渡すには、多くの適切なユースケースがあります。基礎となるブラウザの `CustomEvent` システムの詳細については、[こちらをご覧ください。](https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events)
441 |
442 | `$dispatch('some-event', { some: 'data' })` の2番目のパラメータとして渡されるデータは、新しいイベントの「detail」プロパティ: `$event.detail.some` です。カスタムイベントデータを `.detail` プロパティにアタッチすることは、ブラウザの `CustomEvent` の標準的な方法です。詳細は[こちらをご覧ください。](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail)
443 |
444 | また、`$dispatch()` を使用して、`x-model` バインディングのデータ更新をトリガすることもできます。 例えば:
445 |
446 | ```html
447 |
448 |
449 |
450 |
451 |
452 |
453 | ```
454 |
455 | ---
456 |
457 | ### `$nextTick`
458 | **例:**
459 | ```html
460 |
461 |
468 |
469 | ```
470 |
471 | `$nextTick` は、Alpine がリアクティブな DOM 更新を行った後、指定された式のみを実行できるマジックプロパティです。これは、データ更新が反映された後に DOM ステートとやり取りしたい場合に便利です。
472 |
473 | ## ライセンス
474 |
475 | Copyright © 2019-2020 Caleb Porzio and contributors
476 |
477 | MIT ライセンスの下でライセンスされています。詳細については、[LICENSE.md](LICENSE.md) を参照してください。
478 |
--------------------------------------------------------------------------------
/test/on.spec.js:
--------------------------------------------------------------------------------
1 | import Alpine from 'alpinejs'
2 | import { wait, fireEvent } from '@testing-library/dom'
3 | const timeout = ms => new Promise(resolve => setTimeout(resolve, ms))
4 |
5 | global.MutationObserver = class {
6 | observe() {}
7 | }
8 |
9 | test('data modified in event listener updates affected attribute bindings', async () => {
10 | document.body.innerHTML = `
11 |
12 |
13 |
14 |
15 |
16 | `
17 |
18 | Alpine.start()
19 |
20 | expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
21 |
22 | document.querySelector('button').click()
23 |
24 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
25 | })
26 |
27 | test('nested data modified in event listener updates affected attribute bindings', async () => {
28 | document.body.innerHTML = `
29 |
30 |
31 |
32 |
33 |
34 | `
35 |
36 | Alpine.start()
37 |
38 | expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
39 |
40 | document.querySelector('button').click()
41 |
42 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
43 | })
44 |
45 |
46 | test('.stop modifier', async () => {
47 | document.body.innerHTML = `
48 |
49 |
50 |
51 |
52 |
53 | `
54 |
55 | Alpine.start()
56 |
57 | expect(document.querySelector('div').__x.$data.foo).toEqual('bar')
58 |
59 | document.querySelector('span').click()
60 |
61 | await wait(() => {
62 | expect(document.querySelector('div').__x.$data.foo).toEqual('baz')
63 | })
64 | })
65 |
66 | test('.self modifier', async () => {
67 | document.body.innerHTML = `
68 |
74 | `
75 |
76 | Alpine.start()
77 |
78 | expect(document.querySelector('span').innerText).toEqual('bar')
79 |
80 | document.querySelector('button').click()
81 |
82 | await wait(() => {
83 | expect(document.querySelector('span').innerText).toEqual('bar')
84 | })
85 |
86 | document.querySelector('#selfTarget').click()
87 |
88 | await wait(() => {
89 | expect(document.querySelector('span').innerText).toEqual('baz')
90 | })
91 | })
92 |
93 | test('.prevent modifier', async () => {
94 | document.body.innerHTML = `
95 |
96 |
97 |
98 | `
99 |
100 | Alpine.start()
101 |
102 | expect(document.querySelector('input').checked).toEqual(false)
103 |
104 | document.querySelector('input').click()
105 |
106 | expect(document.querySelector('input').checked).toEqual(false)
107 | })
108 |
109 | test('.window modifier', async () => {
110 | document.body.innerHTML = `
111 |
116 | `
117 |
118 | Alpine.start()
119 |
120 | expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
121 |
122 | document.body.click()
123 |
124 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
125 | })
126 |
127 | test('unbind global event handler when element is removed', async () => {
128 | document._callCount = 0
129 |
130 | document.body.innerHTML = `
131 |
134 | `
135 |
136 | Alpine.start()
137 |
138 | document.body.click()
139 |
140 | document.body.innerHTML = ''
141 |
142 | document.body.click()
143 |
144 | await new Promise(resolve => setTimeout(resolve, 1))
145 |
146 | expect(document._callCount).toEqual(1)
147 | })
148 |
149 | test('.document modifier', async () => {
150 | document.body.innerHTML = `
151 |
156 | `
157 |
158 | Alpine.start()
159 |
160 | expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
161 |
162 | document.body.click()
163 |
164 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
165 | })
166 |
167 | test('.once modifier', async () => {
168 | document.body.innerHTML = `
169 |
170 |
171 |
172 |
173 |
174 | `
175 |
176 | Alpine.start()
177 |
178 | expect(document.querySelector('span').getAttribute('foo')).toEqual('0')
179 |
180 | document.querySelector('button').click()
181 |
182 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('1') })
183 |
184 | document.querySelector('button').click()
185 |
186 | await timeout(25)
187 |
188 | expect(document.querySelector('span').getAttribute('foo')).toEqual('1')
189 | })
190 |
191 | test('.once modifier does not remove listener if false is returned', async () => {
192 | document.body.innerHTML = `
193 |
194 |
195 |
196 |
197 |
198 | `
199 |
200 | Alpine.start()
201 |
202 | expect(document.querySelector('span').getAttribute('foo')).toEqual('0')
203 |
204 | document.querySelector('button').click()
205 |
206 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('1') })
207 |
208 | document.querySelector('button').click()
209 |
210 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('2') })
211 |
212 | await timeout(25)
213 |
214 | expect(document.querySelector('span').getAttribute('foo')).toEqual('2')
215 | })
216 |
217 | test('keydown modifiers', async () => {
218 | document.body.innerHTML = `
219 |
220 |
221 |
222 |
223 |
224 | `
225 |
226 | Alpine.start()
227 |
228 | expect(document.querySelector('span').innerText).toEqual(0)
229 |
230 | fireEvent.keyDown(document.querySelector('input'), { key: 'Enter' })
231 |
232 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(2) })
233 |
234 | fireEvent.keyDown(document.querySelector('input'), { key: ' ' })
235 |
236 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(4) })
237 |
238 | fireEvent.keyDown(document.querySelector('input'), { key: 'Spacebar' })
239 |
240 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(6) })
241 |
242 | fireEvent.keyDown(document.querySelector('input'), { key: 'Escape' })
243 |
244 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(7) })
245 | })
246 |
247 | test('keydown combo modifiers', async () => {
248 | document.body.innerHTML = `
249 |
250 |
251 |
252 |
253 |
254 | `
255 |
256 | Alpine.start()
257 |
258 | expect(document.querySelector('span').innerText).toEqual(0)
259 |
260 | fireEvent.keyDown(document.querySelector('input'), { key: 'Enter' })
261 |
262 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(0) })
263 |
264 | fireEvent.keyDown(document.querySelector('input'), { key: 'Enter', metaKey: true })
265 |
266 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(1) })
267 | })
268 |
269 | test('keydown with specified key and stop modifier only stops for specified key', async () => {
270 | document.body.innerHTML = `
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 | `
279 |
280 | Alpine.start()
281 |
282 | expect(document.querySelector('span').innerText).toEqual(0)
283 |
284 | fireEvent.keyDown(document.querySelector('input'), { key: 'Escape' })
285 |
286 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(1) })
287 |
288 | fireEvent.keyDown(document.querySelector('input'), { key: 'Enter' })
289 |
290 | await timeout(25)
291 | expect(document.querySelector('span').innerText).toEqual(1)
292 | })
293 |
294 | test('click away', async () => {
295 | // Because jsDom doesn't support .offsetHeight and offsetWidth, we have to
296 | // make our own implementation using a specific class added to the class. Ugh.
297 | Object.defineProperties(window.HTMLElement.prototype, {
298 | offsetHeight: {
299 | get: function() { return this.classList.contains('hidden') ? 0 : 1 }
300 | },
301 | offsetWidth: {
302 | get: function() { return this.classList.contains('hidden') ? 0 : 1 }
303 | }
304 | });
305 |
306 | document.body.innerHTML = `
307 |
316 | `
317 |
318 | Alpine.start()
319 |
320 | expect(document.querySelector('ul').classList.contains('hidden')).toEqual(false)
321 |
322 | document.querySelector('li').click()
323 |
324 | await wait(() => { expect(document.querySelector('ul').classList.contains('hidden')).toEqual(false) })
325 |
326 | document.querySelector('ul').click()
327 |
328 | await wait(() => { expect(document.querySelector('ul').classList.contains('hidden')).toEqual(false) })
329 |
330 | document.querySelector('#outer').click()
331 |
332 | await wait(() => { expect(document.querySelector('ul').classList.contains('hidden')).toEqual(true) })
333 |
334 | document.querySelector('button').click()
335 |
336 | await wait(() => { expect(document.querySelector('ul').classList.contains('hidden')).toEqual(false) })
337 | })
338 |
339 | test('supports short syntax', async () => {
340 | document.body.innerHTML = `
341 |
342 |
343 |
344 |
345 | `
346 |
347 | Alpine.start()
348 |
349 | expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
350 |
351 | document.querySelector('button').click()
352 |
353 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
354 | })
355 |
356 |
357 | test('event with colon', async () => {
358 | document.body.innerHTML = `
359 |
364 | `
365 |
366 | Alpine.start()
367 |
368 | expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
369 |
370 | var event = new CustomEvent('my:event');
371 |
372 | document.dispatchEvent(event);
373 |
374 | await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
375 | })
376 |
377 | test('prevent default action when an event returns false', async () => {
378 | window.confirm = jest.fn().mockImplementation(() => false)
379 |
380 | document.body.innerHTML = `
381 |
382 |
383 |
384 | `
385 |
386 | Alpine.start()
387 |
388 | expect(document.querySelector('input').checked).toEqual(false)
389 |
390 | document.querySelector('input').click()
391 |
392 | expect(document.querySelector('input').checked).toEqual(false)
393 |
394 | window.confirm = jest.fn().mockImplementation(() => true)
395 |
396 | document.querySelector('input').click()
397 |
398 | expect(document.querySelector('input').checked).toEqual(true)
399 | })
400 |
401 | test('allow method reference to be passed to listeners', async () => {
402 | document.body.innerHTML = `
403 |
404 |
405 |
406 |
407 | `
408 |
409 | Alpine.start()
410 |
411 | expect(document.querySelector('span').innerText).toEqual('bar')
412 |
413 | document.querySelector('button').click()
414 |
415 | await new Promise(resolve => setTimeout(resolve, 1))
416 |
417 | expect(document.querySelector('span').innerText).toEqual('baz')
418 | })
419 |
420 | test('event instance is passed to method reference', async () => {
421 | document.body.innerHTML = `
422 |
423 |
424 |
425 |
426 | `
427 |
428 | Alpine.start()
429 |
430 | expect(document.querySelector('span').innerText).toEqual('bar')
431 |
432 | document.querySelector('button').click()
433 |
434 | await new Promise(resolve => setTimeout(resolve, 1))
435 |
436 | expect(document.querySelector('span').innerText).toEqual('baz')
437 | })
438 |
439 | test('autocomplete event does not trigger keydown with modifier callback', async () => {
440 | document.body.innerHTML = `
441 |
442 |
443 |
444 |
445 |
446 | `
447 |
448 | Alpine.start()
449 |
450 | expect(document.querySelector('span').innerText).toEqual(0)
451 |
452 | const autocompleteEvent = new Event('keydown')
453 |
454 | fireEvent.keyDown(document.querySelector('input'), { key: 'Enter' })
455 |
456 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(0) })
457 |
458 | fireEvent.keyDown(document.querySelector('input'), { key: '?' })
459 |
460 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(1) })
461 |
462 | fireEvent(document.querySelector('input'), autocompleteEvent)
463 |
464 | await wait(() => { expect(document.querySelector('span').innerText).toEqual(1) })
465 | })
466 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 |
2 | // Thanks @stimulus:
3 | // https://github.com/stimulusjs/stimulus/blob/master/packages/%40stimulus/core/src/application.ts
4 | export function domReady() {
5 | return new Promise(resolve => {
6 | if (document.readyState == "loading") {
7 | document.addEventListener("DOMContentLoaded", resolve)
8 | } else {
9 | resolve()
10 | }
11 | })
12 | }
13 |
14 | export function arrayUnique(array) {
15 | return Array.from(new Set(array))
16 | }
17 |
18 | export function isTesting() {
19 | return navigator.userAgent, navigator.userAgent.includes("Node.js")
20 | || navigator.userAgent.includes("jsdom")
21 | }
22 |
23 | export function kebabCase(subject) {
24 | return subject.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[_\s]/, '-').toLowerCase()
25 | }
26 |
27 | export function walk(el, callback) {
28 | if (callback(el) === false) return
29 |
30 | let node = el.firstElementChild
31 |
32 | while (node) {
33 | walk(node, callback)
34 |
35 | node = node.nextElementSibling
36 | }
37 | }
38 |
39 | export function debounce(func, wait) {
40 | var timeout
41 | return function () {
42 | var context = this, args = arguments
43 | var later = function () {
44 | timeout = null
45 | func.apply(context, args)
46 | }
47 | clearTimeout(timeout)
48 | timeout = setTimeout(later, wait)
49 | }
50 | }
51 |
52 | export function saferEval(expression, dataContext, additionalHelperVariables = {}) {
53 | return (new Function(['$data', ...Object.keys(additionalHelperVariables)], `var result; with($data) { result = ${expression} }; return result`))(
54 | dataContext, ...Object.values(additionalHelperVariables)
55 | )
56 | }
57 |
58 | export function saferEvalNoReturn(expression, dataContext, additionalHelperVariables = {}) {
59 | // For the cases when users pass only a function reference to the caller: `x-on:click="foo"`
60 | // Where "foo" is a function. Also, we'll pass the function the event instance when we call it.
61 | if (Object.keys(dataContext).includes(expression)) {
62 | let methodReference = (new Function(['dataContext', ...Object.keys(additionalHelperVariables)], `with(dataContext) { return ${expression} }`))(
63 | dataContext, ...Object.values(additionalHelperVariables)
64 | )
65 |
66 | if (typeof methodReference === 'function') {
67 | return methodReference.call(dataContext, additionalHelperVariables['$event'])
68 | }
69 | }
70 |
71 | return (new Function(['dataContext', ...Object.keys(additionalHelperVariables)], `with(dataContext) { ${expression} }`))(
72 | dataContext, ...Object.values(additionalHelperVariables)
73 | )
74 | }
75 |
76 | const xAttrRE = /^x-(on|bind|data|text|html|model|if|for|show|cloak|transition|ref)\b/
77 |
78 | export function isXAttr(attr) {
79 | const name = replaceAtAndColonWithStandardSyntax(attr.name)
80 | return xAttrRE.test(name)
81 | }
82 |
83 | export function getXAttrs(el, type) {
84 | return Array.from(el.attributes)
85 | .filter(isXAttr)
86 | .map(attr => {
87 | const name = replaceAtAndColonWithStandardSyntax(attr.name)
88 |
89 | const typeMatch = name.match(xAttrRE)
90 | const valueMatch = name.match(/:([a-zA-Z\-:]+)/)
91 | const modifiers = name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
92 |
93 | return {
94 | type: typeMatch ? typeMatch[1] : null,
95 | value: valueMatch ? valueMatch[1] : null,
96 | modifiers: modifiers.map(i => i.replace('.', '')),
97 | expression: attr.value,
98 | }
99 | })
100 | .filter(i => {
101 | // If no type is passed in for filtering, bypass filter
102 | if (! type) return true
103 |
104 | return i.type === type
105 | })
106 | }
107 |
108 | export function isBooleanAttr(attrName) {
109 | // As per HTML spec table https://html.spec.whatwg.org/multipage/indices.html#attributes-3:boolean-attribute
110 | // Array roughly ordered by estimated usage
111 | const booleanAttributes = [
112 | 'disabled','checked','required','readonly','hidden','open', 'selected',
113 | 'autofocus', 'itemscope', 'multiple', 'novalidate','allowfullscreen',
114 | 'allowpaymentrequest', 'formnovalidate', 'autoplay', 'controls', 'loop',
115 | 'muted', 'playsinline', 'default', 'ismap', 'reversed', 'async', 'defer',
116 | 'nomodule'
117 | ]
118 |
119 | return booleanAttributes.includes(attrName)
120 | }
121 |
122 | export function replaceAtAndColonWithStandardSyntax(name) {
123 | if (name.startsWith('@')) {
124 | return name.replace('@', 'x-on:')
125 | } else if (name.startsWith(':')) {
126 | return name.replace(':', 'x-bind:')
127 | }
128 |
129 | return name
130 | }
131 |
132 | export function transitionIn(el, show, forceSkip = false) {
133 | // We don't want to transition on the initial page load.
134 | if (forceSkip) return show()
135 |
136 | const attrs = getXAttrs(el, 'transition')
137 | const showAttr = getXAttrs(el, 'show')[0]
138 |
139 | // If this is triggered by a x-show.transition.
140 | if (showAttr && showAttr.modifiers.includes('transition')) {
141 | let modifiers = showAttr.modifiers
142 |
143 | // If x-show.transition.out, we'll skip the "in" transition.
144 | if (modifiers.includes('out') && ! modifiers.includes('in')) return show()
145 |
146 | const settingBothSidesOfTransition = modifiers.includes('in') && modifiers.includes('out')
147 |
148 | // If x-show.transition.in...out... only use "in" related modifiers for this transition.
149 | modifiers = settingBothSidesOfTransition
150 | ? modifiers.filter((i, index) => index < modifiers.indexOf('out')) : modifiers
151 |
152 | transitionHelperIn(el, modifiers, show)
153 | // Otherwise, we can assume x-transition:enter.
154 | } else if (attrs.length > 0) {
155 | transitionClassesIn(el, attrs, show)
156 | } else {
157 | // If neither, just show that damn thing.
158 | show()
159 | }
160 | }
161 |
162 | export function transitionOut(el, hide, forceSkip = false) {
163 | if (forceSkip) return hide()
164 |
165 | const attrs = getXAttrs(el, 'transition')
166 | const showAttr = getXAttrs(el, 'show')[0]
167 |
168 | if (showAttr && showAttr.modifiers.includes('transition')) {
169 | let modifiers = showAttr.modifiers
170 |
171 | if (modifiers.includes('in') && ! modifiers.includes('out')) return hide()
172 |
173 | const settingBothSidesOfTransition = modifiers.includes('in') && modifiers.includes('out')
174 |
175 | modifiers = settingBothSidesOfTransition
176 | ? modifiers.filter((i, index) => index > modifiers.indexOf('out')) : modifiers
177 |
178 | transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hide)
179 | } else if (attrs.length > 0) {
180 | transitionClassesOut(el, attrs, hide)
181 | } else {
182 | hide()
183 | }
184 | }
185 |
186 | export function transitionHelperIn(el, modifiers, showCallback) {
187 | // Default values inspired by: https://material.io/design/motion/speed.html#duration
188 | const styleValues = {
189 | duration: modifierValue(modifiers, 'duration', 150),
190 | origin: modifierValue(modifiers, 'origin', 'center'),
191 | first: {
192 | opacity: 0,
193 | scale: modifierValue(modifiers, 'scale', 95),
194 | },
195 | second: {
196 | opacity: 1,
197 | scale: 100,
198 | },
199 | }
200 |
201 | transitionHelper(el, modifiers, showCallback, () => {}, styleValues)
202 | }
203 |
204 | export function transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hideCallback) {
205 | // Make the "out" transition .5x slower than the "in". (Visually better)
206 | // HOWEVER, if they explicitly set a duration for the "out" transition,
207 | // use that.
208 | const duration = settingBothSidesOfTransition
209 | ? modifierValue(modifiers, 'duration', 150)
210 | : modifierValue(modifiers, 'duration', 150) / 2
211 |
212 | const styleValues = {
213 | duration: duration,
214 | origin: modifierValue(modifiers, 'origin', 'center'),
215 | first: {
216 | opacity: 1,
217 | scale: 100,
218 | },
219 | second: {
220 | opacity: 0,
221 | scale: modifierValue(modifiers, 'scale', 95),
222 | },
223 | }
224 |
225 | transitionHelper(el, modifiers, () => {}, hideCallback, styleValues)
226 | }
227 |
228 | function modifierValue(modifiers, key, fallback) {
229 | // If the modifier isn't present, use the default.
230 | if (modifiers.indexOf(key) === -1) return fallback
231 |
232 | // If it IS present, grab the value after it: x-show.transition.duration.500ms
233 | const rawValue = modifiers[modifiers.indexOf(key) + 1]
234 |
235 | if (! rawValue) return fallback
236 |
237 | if (key === 'scale') {
238 | // Check if the very next value is NOT a number and return the fallback.
239 | // If x-show.transition.scale, we'll use the default scale value.
240 | // That is how a user opts out of the opacity transition.
241 | if (! isNumeric(rawValue)) return fallback
242 | }
243 |
244 | if (key === 'duration') {
245 | // Support x-show.transition.duration.500ms && duration.500
246 | let match = rawValue.match(/([0-9]+)ms/)
247 | if (match) return match[1]
248 | }
249 |
250 | if (key === 'origin') {
251 | // Support chaining origin directions: x-show.transition.top.right
252 | if (['top', 'right', 'left', 'center', 'bottom'].includes(modifiers[modifiers.indexOf(key) + 2])) {
253 | return [rawValue, modifiers[modifiers.indexOf(key) + 2]].join(' ')
254 | }
255 | }
256 |
257 | return rawValue
258 | }
259 |
260 | export function transitionHelper(el, modifiers, hook1, hook2, styleValues) {
261 | // If the user set these style values, we'll put them back when we're done with them.
262 | const opacityCache = el.style.opacity
263 | const transformCache = el.style.transform
264 | const transformOriginCache = el.style.transformOrigin
265 |
266 | // If no modifiers are present: x-show.transition, we'll default to both opacity and scale.
267 | const noModifiers = ! modifiers.includes('opacity') && ! modifiers.includes('scale')
268 | const transitionOpacity = noModifiers || modifiers.includes('opacity')
269 | const transitionScale = noModifiers || modifiers.includes('scale')
270 |
271 | // These are the explicit stages of a transition (same stages for in and for out).
272 | // This way you can get a birds eye view of the hooks, and the differences
273 | // between them.
274 | const stages = {
275 | start() {
276 | if (transitionOpacity) el.style.opacity = styleValues.first.opacity
277 | if (transitionScale) el.style.transform = `scale(${styleValues.first.scale / 100})`
278 | },
279 | during() {
280 | if (transitionScale) el.style.transformOrigin = styleValues.origin
281 | el.style.transitionProperty = [(transitionOpacity ? `opacity` : ``), (transitionScale ? `transform` : ``)].join(' ').trim()
282 | el.style.transitionDuration = `${styleValues.duration / 1000}s`
283 | el.style.transitionTimingFunction = `cubic-bezier(0.4, 0.0, 0.2, 1)`
284 | },
285 | show() {
286 | hook1()
287 | },
288 | end() {
289 | if (transitionOpacity) el.style.opacity = styleValues.second.opacity
290 | if (transitionScale) el.style.transform = `scale(${styleValues.second.scale / 100})`
291 | },
292 | hide() {
293 | hook2()
294 | },
295 | cleanup() {
296 | if (transitionOpacity) el.style.opacity = opacityCache
297 | if (transitionScale) el.style.transform = transformCache
298 | if (transitionScale) el.style.transformOrigin = transformOriginCache
299 | el.style.transitionProperty = null
300 | el.style.transitionDuration = null
301 | el.style.transitionTimingFunction = null
302 | },
303 | }
304 |
305 | transition(el, stages)
306 | }
307 |
308 | export function transitionClassesIn(el, directives, showCallback) {
309 | const enter = (directives.find(i => i.value === 'enter') || { expression: '' }).expression.split(' ').filter(i => i !== '')
310 | const enterStart = (directives.find(i => i.value === 'enter-start') || { expression: '' }).expression.split(' ').filter(i => i !== '')
311 | const enterEnd = (directives.find(i => i.value === 'enter-end') || { expression: '' }).expression.split(' ').filter(i => i !== '')
312 |
313 | transitionClasses(el, enter, enterStart, enterEnd, showCallback, () => {})
314 | }
315 |
316 | export function transitionClassesOut(el, directives, hideCallback) {
317 | const leave = (directives.find(i => i.value === 'leave') || { expression: '' }).expression.split(' ').filter(i => i !== '')
318 | const leaveStart = (directives.find(i => i.value === 'leave-start') || { expression: '' }).expression.split(' ').filter(i => i !== '')
319 | const leaveEnd = (directives.find(i => i.value === 'leave-end') || { expression: '' }).expression.split(' ').filter(i => i !== '')
320 |
321 | transitionClasses(el, leave, leaveStart, leaveEnd, () => {}, hideCallback)
322 | }
323 |
324 | export function transitionClasses(el, classesDuring, classesStart, classesEnd, hook1, hook2) {
325 | const originalClasses = el.__x_original_classes || []
326 |
327 | const stages = {
328 | start() {
329 | el.classList.add(...classesStart)
330 | },
331 | during() {
332 | el.classList.add(...classesDuring)
333 | },
334 | show() {
335 | hook1()
336 | },
337 | end() {
338 | // Don't remove classes that were in the original class attribute.
339 | el.classList.remove(...classesStart.filter(i => !originalClasses.includes(i)))
340 | el.classList.add(...classesEnd)
341 | },
342 | hide() {
343 | hook2()
344 | },
345 | cleanup() {
346 | el.classList.remove(...classesDuring.filter(i => !originalClasses.includes(i)))
347 | el.classList.remove(...classesEnd.filter(i => !originalClasses.includes(i)))
348 | },
349 | }
350 |
351 | transition(el, stages)
352 | }
353 |
354 | export function transition(el, stages) {
355 | stages.start()
356 | stages.during()
357 |
358 | requestAnimationFrame(() => {
359 | // Note: Safari's transitionDuration property will list out comma separated transition durations
360 | // for every single transition property. Let's grab the first one and call it a day.
361 | let duration = Number(getComputedStyle(el).transitionDuration.replace(/,.*/, '').replace('s', '')) * 1000
362 |
363 | stages.show()
364 |
365 | requestAnimationFrame(() => {
366 | stages.end()
367 |
368 | setTimeout(() => {
369 | stages.hide()
370 |
371 | // Adding an "isConnected" check, in case the callback
372 | // removed the element from the DOM.
373 | if (el.isConnected) {
374 | stages.cleanup()
375 | }
376 | }, duration);
377 | })
378 | });
379 | }
380 |
381 | export function isNumeric(subject){
382 | return ! isNaN(subject)
383 | }
384 |
--------------------------------------------------------------------------------
/src/component.js:
--------------------------------------------------------------------------------
1 | import { walk, saferEval, saferEvalNoReturn, getXAttrs, debounce } from './utils'
2 | import { handleForDirective } from './directives/for'
3 | import { handleAttributeBindingDirective } from './directives/bind'
4 | import { handleTextDirective } from './directives/text'
5 | import { handleHtmlDirective } from './directives/html'
6 | import { handleShowDirective } from './directives/show'
7 | import { handleIfDirective } from './directives/if'
8 | import { registerModelListener } from './directives/model'
9 | import { registerListener } from './directives/on'
10 | import { unwrap, wrap } from './observable'
11 |
12 | export default class Component {
13 | constructor(el, seedDataForCloning = null) {
14 | this.$el = el
15 |
16 | const dataAttr = this.$el.getAttribute('x-data')
17 | const dataExpression = dataAttr === '' ? '{}' : dataAttr
18 | const initExpression = this.$el.getAttribute('x-init')
19 |
20 | this.unobservedData = seedDataForCloning ? seedDataForCloning : saferEval(dataExpression, {})
21 |
22 | /* IE11-ONLY:START */
23 | // For IE11, add our magic properties to the original data for access.
24 | // The Proxy polyfill does not allow properties to be added after creation.
25 | this.unobservedData.$el = null
26 | this.unobservedData.$refs = null
27 | this.unobservedData.$nextTick = null
28 | this.unobservedData.$watch = null
29 | /* IE11-ONLY:END */
30 |
31 | // Construct a Proxy-based observable. This will be used to handle reactivity.
32 | let { membrane, data } = this.wrapDataInObservable(this.unobservedData)
33 | this.$data = data
34 | this.membrane = membrane
35 |
36 | // After making user-supplied data methods reactive, we can now add
37 | // our magic properties to the original data for access.
38 | this.unobservedData.$el = this.$el
39 | this.unobservedData.$refs = this.getRefsProxy()
40 |
41 | this.nextTickStack = []
42 | this.unobservedData.$nextTick = (callback) => {
43 | this.nextTickStack.push(callback)
44 | }
45 |
46 | this.watchers = {}
47 | this.unobservedData.$watch = (property, callback) => {
48 | if (! this.watchers[property]) this.watchers[property] = []
49 |
50 | this.watchers[property].push(callback)
51 | }
52 |
53 | this.showDirectiveStack = []
54 | this.showDirectiveLastElement
55 |
56 | var initReturnedCallback
57 | // If x-init is present AND we aren't cloning (skip x-init on clone)
58 | if (initExpression && ! seedDataForCloning) {
59 | // We want to allow data manipulation, but not trigger DOM updates just yet.
60 | // We haven't even initialized the elements with their Alpine bindings. I mean c'mon.
61 | this.pauseReactivity = true
62 | initReturnedCallback = this.evaluateReturnExpression(this.$el, initExpression)
63 | this.pauseReactivity = false
64 | }
65 |
66 | // Register all our listeners and set all our attribute bindings.
67 | this.initializeElements(this.$el)
68 |
69 | // Use mutation observer to detect new elements being added within this component at run-time.
70 | // Alpine's just so darn flexible amirite?
71 | this.listenForNewElementsToInitialize()
72 |
73 | if (typeof initReturnedCallback === 'function') {
74 | // Run the callback returned from the "x-init" hook to allow the user to do stuff after
75 | // Alpine's got it's grubby little paws all over everything.
76 | initReturnedCallback.call(this.$data)
77 | }
78 | }
79 |
80 | getUnobservedData() {
81 | return unwrap(this.membrane, this.$data)
82 | }
83 |
84 | wrapDataInObservable(data) {
85 | var self = this
86 |
87 | let updateDom = debounce(function () {
88 | self.updateElements(self.$el)
89 | }, 0)
90 |
91 | return wrap(data, (target, key) => {
92 | if (self.watchers[key]) {
93 | // If there's a watcher for this specific key, run it.
94 | self.watchers[key].forEach(callback => callback(target[key]))
95 | } else {
96 | // Let's walk through the watchers with "dot-notation" (foo.bar) and see
97 | // if this mutation fits any of them.
98 | Object.keys(self.watchers)
99 | .filter(i => i.includes('.'))
100 | .forEach(fullDotNotationKey => {
101 | let dotNotationParts = fullDotNotationKey.split('.')
102 |
103 | // If this dot-notation watcher's last "part" doesn't match the current
104 | // key, then skip it early for performance reasons.
105 | if (key !== dotNotationParts[dotNotationParts.length - 1]) return
106 |
107 | // Now, walk through the dot-notation "parts" recursively to find
108 | // a match, and call the watcher if one's found.
109 | dotNotationParts.reduce((comparisonData, part) => {
110 | if (Object.is(target, comparisonData)) {
111 | // Run the watchers.
112 | self.watchers[fullDotNotationKey].forEach(callback => callback(target[key]))
113 | }
114 | return comparisonData[part]
115 | }, self.getUnobservedData())
116 | })
117 | }
118 |
119 | // Don't react to data changes for cases like the `x-created` hook.
120 | if (self.pauseReactivity) return
121 |
122 | updateDom()
123 | })
124 | }
125 |
126 | walkAndSkipNestedComponents(el, callback, initializeComponentCallback = () => {}) {
127 | walk(el, el => {
128 | // We've hit a component.
129 | if (el.hasAttribute('x-data')) {
130 | // If it's not the current one.
131 | if (! el.isSameNode(this.$el)) {
132 | // Initialize it if it's not.
133 | if (! el.__x) initializeComponentCallback(el)
134 |
135 | // Now we'll let that sub-component deal with itself.
136 | return false
137 | }
138 | }
139 |
140 | return callback(el)
141 | })
142 | }
143 |
144 | initializeElements(rootEl, extraVars = () => {}) {
145 | this.walkAndSkipNestedComponents(rootEl, el => {
146 | // Don't touch spawns from for loop
147 | if (el.__x_for_key !== undefined) return false
148 |
149 | // Don't touch spawns from if directives
150 | if (el.__x_inserted_me !== undefined) return false
151 |
152 | this.initializeElement(el, extraVars)
153 | }, el => {
154 | el.__x = new Component(el)
155 | })
156 |
157 | this.executeAndClearRemainingShowDirectiveStack()
158 |
159 | this.executeAndClearNextTickStack(rootEl)
160 | }
161 |
162 | initializeElement(el, extraVars) {
163 | // To support class attribute merging, we have to know what the element's
164 | // original class attribute looked like for reference.
165 | if (el.hasAttribute('class') && getXAttrs(el).length > 0) {
166 | el.__x_original_classes = el.getAttribute('class').split(' ')
167 | }
168 |
169 | this.registerListeners(el, extraVars)
170 | this.resolveBoundAttributes(el, true, extraVars)
171 | }
172 |
173 | updateElements(rootEl, extraVars = () => {}) {
174 | this.walkAndSkipNestedComponents(rootEl, el => {
175 | // Don't touch spawns from for loop (and check if the root is actually a for loop in a parent, don't skip it.)
176 | if (el.__x_for_key !== undefined && ! el.isSameNode(this.$el)) return false
177 |
178 | this.updateElement(el, extraVars)
179 | }, el => {
180 | el.__x = new Component(el)
181 | })
182 |
183 | this.executeAndClearRemainingShowDirectiveStack()
184 |
185 | this.executeAndClearNextTickStack(rootEl)
186 | }
187 |
188 | executeAndClearNextTickStack(el) {
189 | // Skip spawns from alpine directives
190 | if (el === this.$el) {
191 | // Walk through the $nextTick stack and clear it as we go.
192 | while (this.nextTickStack.length > 0) {
193 | this.nextTickStack.shift()()
194 | }
195 | }
196 | }
197 |
198 | executeAndClearRemainingShowDirectiveStack() {
199 | // The goal here is to start all the x-show transitions
200 | // and build a nested promise chain so that elements
201 | // only hide when the children are finished hiding.
202 | this.showDirectiveStack.reverse().map(thing => {
203 | return new Promise(resolve => {
204 | thing(finish => {
205 | resolve(finish)
206 | })
207 | })
208 | }).reduce((nestedPromise, promise) => {
209 | return nestedPromise.then(() => {
210 | return promise.then(finish => finish())
211 | })
212 | }, Promise.resolve(() => {}))
213 |
214 | // We've processed the handler stack. let's clear it.
215 | this.showDirectiveStack = []
216 | this.showDirectiveLastElement = undefined
217 | }
218 |
219 | updateElement(el, extraVars) {
220 | this.resolveBoundAttributes(el, false, extraVars)
221 | }
222 |
223 | registerListeners(el, extraVars) {
224 | getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
225 | switch (type) {
226 | case 'on':
227 | registerListener(this, el, value, modifiers, expression, extraVars)
228 | break;
229 |
230 | case 'model':
231 | registerModelListener(this, el, modifiers, expression, extraVars)
232 | break;
233 | default:
234 | break;
235 | }
236 | })
237 | }
238 |
239 | resolveBoundAttributes(el, initialUpdate = false, extraVars) {
240 | let attrs = getXAttrs(el)
241 | if (el.type !== undefined && el.type === 'radio') {
242 | // If there's an x-model on a radio input, move it to end of attribute list
243 | // to ensure that x-bind:value (if present) is processed first.
244 | const modelIdx = attrs.findIndex((attr) => attr.type === 'model')
245 | if (modelIdx > -1) {
246 | attrs.push(attrs.splice(modelIdx, 1)[0])
247 | }
248 | }
249 |
250 | attrs.forEach(({ type, value, modifiers, expression }) => {
251 | switch (type) {
252 | case 'model':
253 | handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type)
254 | break;
255 |
256 | case 'bind':
257 | // The :key binding on an x-for is special, ignore it.
258 | if (el.tagName.toLowerCase() === 'template' && value === 'key') return
259 |
260 | handleAttributeBindingDirective(this, el, value, expression, extraVars, type)
261 | break;
262 |
263 | case 'text':
264 | var output = this.evaluateReturnExpression(el, expression, extraVars);
265 |
266 | handleTextDirective(el, output, expression)
267 | break;
268 |
269 | case 'html':
270 | handleHtmlDirective(this, el, expression, extraVars)
271 | break;
272 |
273 | case 'show':
274 | var output = this.evaluateReturnExpression(el, expression, extraVars)
275 |
276 | handleShowDirective(this, el, output, modifiers, initialUpdate)
277 | break;
278 |
279 | case 'if':
280 | // If this element also has x-for on it, don't process x-if.
281 | // We will let the "x-for" directive handle the "if"ing.
282 | if (attrs.filter(i => i.type === 'for').length > 0) return
283 |
284 | var output = this.evaluateReturnExpression(el, expression, extraVars)
285 |
286 | handleIfDirective(this, el, output, initialUpdate, extraVars)
287 | break;
288 |
289 | case 'for':
290 | handleForDirective(this, el, expression, initialUpdate, extraVars)
291 | break;
292 |
293 | case 'cloak':
294 | el.removeAttribute('x-cloak')
295 | break;
296 |
297 | default:
298 | break;
299 | }
300 | })
301 | }
302 |
303 | evaluateReturnExpression(el, expression, extraVars = () => {}) {
304 | return saferEval(expression, this.$data, {
305 | ...extraVars(),
306 | $dispatch: this.getDispatchFunction(el),
307 | })
308 | }
309 |
310 | evaluateCommandExpression(el, expression, extraVars = () => {}) {
311 | return saferEvalNoReturn(expression, this.$data, {
312 | ...extraVars(),
313 | $dispatch: this.getDispatchFunction(el),
314 | })
315 | }
316 |
317 | getDispatchFunction (el) {
318 | return (event, detail = {}) => {
319 | el.dispatchEvent(new CustomEvent(event, {
320 | detail,
321 | bubbles: true,
322 | }))
323 | }
324 | }
325 |
326 | listenForNewElementsToInitialize() {
327 | const targetNode = this.$el
328 |
329 | const observerOptions = {
330 | childList: true,
331 | attributes: true,
332 | subtree: true,
333 | }
334 |
335 | const observer = new MutationObserver((mutations) => {
336 | for (let i=0; i < mutations.length; i++){
337 | // Filter out mutations triggered from child components.
338 | const closestParentComponent = mutations[i].target.closest('[x-data]')
339 | if (! (closestParentComponent && closestParentComponent.isSameNode(this.$el))) return
340 |
341 | if (mutations[i].type === 'attributes' && mutations[i].attributeName === 'x-data') {
342 | const rawData = saferEval(mutations[i].target.getAttribute('x-data'), {})
343 |
344 | Object.keys(rawData).forEach(key => {
345 | if (this.$data[key] !== rawData[key]) {
346 | this.$data[key] = rawData[key]
347 | }
348 | })
349 | }
350 |
351 | if (mutations[i].addedNodes.length > 0) {
352 | mutations[i].addedNodes.forEach(node => {
353 | if (node.nodeType !== 1 || node.__x_inserted_me) return
354 |
355 | if (node.matches('[x-data]')) {
356 | node.__x = new Component(node)
357 | return
358 | }
359 |
360 | this.initializeElements(node)
361 | })
362 | }
363 | }
364 | })
365 |
366 | observer.observe(targetNode, observerOptions);
367 | }
368 |
369 | getRefsProxy() {
370 | var self = this
371 |
372 | var refObj = {}
373 |
374 | /* IE11-ONLY:START */
375 | // Add any properties up-front that might be necessary for the Proxy polyfill.
376 | refObj.$isRefsProxy = false;
377 | refObj.$isAlpineProxy = false;
378 |
379 | // If we are in IE, since the polyfill needs all properties to be defined before building the proxy,
380 | // we just loop on the element, look for any x-ref and create a tmp property on a fake object.
381 | this.walkAndSkipNestedComponents(self.$el, el => {
382 | if (el.hasAttribute('x-ref')) {
383 | refObj[el.getAttribute('x-ref')] = true
384 | }
385 | })
386 | /* IE11-ONLY:END */
387 |
388 | // One of the goals of this is to not hold elements in memory, but rather re-evaluate
389 | // the DOM when the system needs something from it. This way, the framework is flexible and
390 | // friendly to outside DOM changes from libraries like Vue/Livewire.
391 | // For this reason, I'm using an "on-demand" proxy to fake a "$refs" object.
392 | return new Proxy(refObj, {
393 | get(object, property) {
394 | if (property === '$isAlpineProxy') return true
395 |
396 | var ref
397 |
398 | // We can't just query the DOM because it's hard to filter out refs in
399 | // nested components.
400 | self.walkAndSkipNestedComponents(self.$el, el => {
401 | if (el.hasAttribute('x-ref') && el.getAttribute('x-ref') === property) {
402 | ref = el
403 | }
404 | })
405 |
406 | return ref
407 | }
408 | })
409 | }
410 | }
411 |
--------------------------------------------------------------------------------
/test/for.spec.js:
--------------------------------------------------------------------------------
1 | import Alpine from 'alpinejs'
2 | import { wait } from '@testing-library/dom'
3 |
4 | global.MutationObserver = class {
5 | observe() {}
6 | }
7 |
8 | test('x-for', async () => {
9 | document.body.innerHTML = `
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | `
18 |
19 | Alpine.start()
20 |
21 | expect(document.querySelectorAll('span').length).toEqual(1)
22 | expect(document.querySelectorAll('span')[0].innerText).toEqual('foo')
23 |
24 | document.querySelector('button').click()
25 |
26 | await wait(() => { expect(document.querySelectorAll('span').length).toEqual(2) })
27 |
28 | expect(document.querySelectorAll('span')[0].innerText).toEqual('foo')
29 | expect(document.querySelectorAll('span')[1].innerText).toEqual('bar')
30 | })
31 |
32 | test('removes all elements when array is empty and previously had one item', async () => {
33 | document.body.innerHTML = `
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | `
42 |
43 | Alpine.start()
44 |
45 | expect(document.querySelectorAll('span').length).toEqual(1)
46 |
47 | document.querySelector('button').click()
48 |
49 | await wait(() => { expect(document.querySelectorAll('span').length).toEqual(0) })
50 | })
51 |
52 | test('removes all elements when array is empty and previously had multiple items', async () => {
53 | document.body.innerHTML = `
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | `
62 |
63 | Alpine.start()
64 |
65 | expect(document.querySelectorAll('span').length).toEqual(3)
66 |
67 | document.querySelector('button').click()
68 |
69 | await wait(() => { expect(document.querySelectorAll('span').length).toEqual(0) })
70 | })
71 |
72 | test('elements inside of loop are reactive', async () => {
73 | document.body.innerHTML = `
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | `
85 |
86 | Alpine.start()
87 |
88 | expect(document.querySelectorAll('span').length).toEqual(1)
89 | expect(document.querySelector('h1').innerText).toEqual('first')
90 | expect(document.querySelector('h2').innerText).toEqual('bar')
91 |
92 | document.querySelector('button').click()
93 |
94 | await wait(() => {
95 | expect(document.querySelector('h1').innerText).toEqual('first')
96 | expect(document.querySelector('h2').innerText).toEqual('baz')
97 | })
98 | })
99 |
100 | test('components inside of loop are reactive', async () => {
101 | document.body.innerHTML = `
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | `
111 |
112 | Alpine.start()
113 |
114 | expect(document.querySelectorAll('div.child').length).toEqual(1)
115 | expect(document.querySelector('span').innerText).toEqual('bar')
116 |
117 | document.querySelector('button').click()
118 |
119 | await wait(() => {
120 | expect(document.querySelector('span').innerText).toEqual('bob')
121 | })
122 | })
123 |
124 | test('components inside a plain element of loop are reactive', async () => {
125 | document.body.innerHTML = `
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 | `
137 |
138 | Alpine.start()
139 |
140 | expect(document.querySelectorAll('ul').length).toEqual(1)
141 | expect(document.querySelector('span').innerText).toEqual('bar')
142 |
143 | document.querySelector('button').click()
144 |
145 | await wait(() => {
146 | expect(document.querySelector('span').innerText).toEqual('bob')
147 | })
148 | })
149 |
150 | test('adding key attribute moves dom nodes properly', async () => {
151 | document.body.innerHTML = `
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 | `
160 |
161 | Alpine.start()
162 |
163 | expect(document.querySelectorAll('span').length).toEqual(2)
164 | const itemA = document.querySelectorAll('span')[0]
165 | itemA.setAttribute('order', 'first')
166 | const itemB = document.querySelectorAll('span')[1]
167 | itemB.setAttribute('order', 'second')
168 |
169 | document.querySelector('button').click()
170 |
171 | await wait(() => { expect(document.querySelectorAll('span').length).toEqual(3) })
172 |
173 | expect(document.querySelectorAll('span')[0].getAttribute('order')).toEqual('second')
174 | expect(document.querySelectorAll('span')[1].getAttribute('order')).toEqual('first')
175 | expect(document.querySelectorAll('span')[2].getAttribute('order')).toEqual(null)
176 | })
177 |
178 | test('can key by index', async () => {
179 | document.body.innerHTML = `
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 | `
188 |
189 | Alpine.start()
190 |
191 | expect(document.querySelectorAll('span').length).toEqual(2)
192 |
193 | document.querySelector('button').click()
194 |
195 | await wait(() => { expect(document.querySelectorAll('span').length).toEqual(3) })
196 | })
197 |
198 | test('can use index inside of loop', async () => {
199 | document.body.innerHTML = `
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 | `
209 |
210 | Alpine.start()
211 |
212 | expect(document.querySelector('h1').innerText).toEqual(0)
213 | expect(document.querySelector('h2').innerText).toEqual(0)
214 | })
215 |
216 | test('can use third iterator param (collection) inside of loop', async () => {
217 | document.body.innerHTML = `
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 | `
227 |
228 | Alpine.start()
229 |
230 | expect(document.querySelector('h1').innerText).toEqual(["foo"])
231 | expect(document.querySelector('h2').innerText).toEqual(["foo"])
232 | })
233 |
234 | test('can use x-if in conjunction with x-for', async () => {
235 | document.body.innerHTML = `
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 | `
244 |
245 | Alpine.start()
246 |
247 | expect(document.querySelectorAll('span').length).toEqual(0)
248 |
249 | document.querySelector('button').click()
250 |
251 | await new Promise(resolve => setTimeout(resolve, 1))
252 |
253 | expect(document.querySelectorAll('span').length).toEqual(2)
254 |
255 | document.querySelector('button').click()
256 |
257 | await new Promise(resolve => setTimeout(resolve, 1))
258 |
259 | expect(document.querySelectorAll('span').length).toEqual(0)
260 | })
261 |
262 | test('listeners in loop get fresh iteration data even though they are only registered initially', async () => {
263 | document.body.innerHTML = `
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 | `
274 |
275 | Alpine.start()
276 |
277 | expect(document.querySelectorAll('span').length).toEqual(1)
278 |
279 | document.querySelector('span').click()
280 |
281 | await wait(() => { expect(document.querySelector('h1').innerText).toEqual('foo') })
282 |
283 | document.querySelector('button').click()
284 |
285 | await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
286 |
287 | document.querySelector('span').click()
288 |
289 | await wait(() => { expect(document.querySelector('h1').innerText).toEqual('bar') })
290 | })
291 |
292 | test('nested x-for', async () => {
293 | document.body.innerHTML = `
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 | `
305 |
306 | Alpine.start()
307 |
308 | await wait(() => { expect(document.querySelectorAll('h1').length).toEqual(1) })
309 | await wait(() => { expect(document.querySelectorAll('h2').length).toEqual(2) })
310 |
311 | expect(document.querySelectorAll('h2')[0].innerText).toEqual('bob')
312 | expect(document.querySelectorAll('h2')[1].innerText).toEqual('lob')
313 |
314 | document.querySelector('button').click()
315 |
316 | await wait(() => { expect(document.querySelectorAll('h2').length).toEqual(3) })
317 |
318 | expect(document.querySelectorAll('h2')[0].innerText).toEqual('bob')
319 | expect(document.querySelectorAll('h2')[1].innerText).toEqual('lob')
320 | expect(document.querySelectorAll('h2')[2].innerText).toEqual('law')
321 | })
322 |
323 | test('x-for updates the right elements when new item are inserted at the beginning of the list', async () => {
324 | document.body.innerHTML = `
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 | `
333 |
334 | Alpine.start()
335 |
336 | expect(document.querySelectorAll('span').length).toEqual(2)
337 | const itemA = document.querySelectorAll('span')[0]
338 | itemA.setAttribute('order', 'first')
339 | const itemB = document.querySelectorAll('span')[1]
340 | itemB.setAttribute('order', 'second')
341 |
342 | document.querySelector('button').click()
343 |
344 | await wait(() => { expect(document.querySelectorAll('span').length).toEqual(3) })
345 |
346 | expect(document.querySelectorAll('span')[0].innerText).toEqual('zero')
347 | expect(document.querySelectorAll('span')[1].innerText).toEqual('one')
348 | expect(document.querySelectorAll('span')[2].innerText).toEqual('two')
349 |
350 | // Make sure states are preserved
351 | expect(document.querySelectorAll('span')[0].getAttribute('order')).toEqual(null)
352 | expect(document.querySelectorAll('span')[1].getAttribute('order')).toEqual('first')
353 | expect(document.querySelectorAll('span')[2].getAttribute('order')).toEqual('second')
354 | })
355 |
356 | test('nested x-for access outer loop variable', async () => {
357 | document.body.innerHTML = `
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 | `
368 |
369 | Alpine.start()
370 |
371 | await wait(() => { expect(document.querySelectorAll('h1').length).toEqual(2) })
372 | await wait(() => { expect(document.querySelectorAll('h2').length).toEqual(4) })
373 |
374 | expect(document.querySelectorAll('h2')[0].innerText).toEqual('foo: bob')
375 | expect(document.querySelectorAll('h2')[1].innerText).toEqual('foo: lob')
376 | expect(document.querySelectorAll('h2')[2].innerText).toEqual('baz: bab')
377 | expect(document.querySelectorAll('h2')[3].innerText).toEqual('baz: lab')
378 | })
379 |
380 | test('nested x-for event listeners', async () => {
381 | document._alerts = []
382 |
383 | document.body.innerHTML = `
384 |
388 |
389 |
390 |
391 |
394 |
395 |
396 |
397 |
398 | `
399 |
400 | Alpine.start()
401 |
402 | await wait(() => { expect(document.querySelectorAll('h1').length).toEqual(2) })
403 | await wait(() => { expect(document.querySelectorAll('h2').length).toEqual(4) })
404 |
405 | expect(document.querySelectorAll('h2')[0].innerText).toEqual('foo: bob = 0')
406 | expect(document.querySelectorAll('h2')[1].innerText).toEqual('foo: lob = 0')
407 | expect(document.querySelectorAll('h2')[2].innerText).toEqual('baz: bab = 0')
408 | expect(document.querySelectorAll('h2')[3].innerText).toEqual('baz: lab = 0')
409 |
410 | expect(document._alerts.length).toEqual(0)
411 |
412 | document.querySelectorAll('h2')[0].click()
413 |
414 | await wait(() => {
415 | expect(document.querySelectorAll('h2')[0].innerText).toEqual('foo: bob = 1')
416 | expect(document.querySelectorAll('h2')[1].innerText).toEqual('foo: lob = 0')
417 | expect(document.querySelectorAll('h2')[2].innerText).toEqual('baz: bab = 0')
418 | expect(document.querySelectorAll('h2')[3].innerText).toEqual('baz: lab = 0')
419 |
420 | expect(document._alerts.length).toEqual(1)
421 | expect(document._alerts[0]).toEqual('foo: bob = 1')
422 | })
423 |
424 | document.querySelectorAll('h2')[2].click()
425 |
426 | await wait(() => {
427 | expect(document.querySelectorAll('h2')[0].innerText).toEqual('foo: bob = 1')
428 | expect(document.querySelectorAll('h2')[1].innerText).toEqual('foo: lob = 0')
429 | expect(document.querySelectorAll('h2')[2].innerText).toEqual('baz: bab = 1')
430 | expect(document.querySelectorAll('h2')[3].innerText).toEqual('baz: lab = 0')
431 |
432 | expect(document._alerts.length).toEqual(2)
433 | expect(document._alerts[0]).toEqual('foo: bob = 1')
434 | expect(document._alerts[1]).toEqual('baz: bab = 1')
435 | })
436 |
437 | document.querySelectorAll('h2')[0].click()
438 |
439 | await wait(() => {
440 | expect(document.querySelectorAll('h2')[0].innerText).toEqual('foo: bob = 2')
441 | expect(document.querySelectorAll('h2')[1].innerText).toEqual('foo: lob = 0')
442 | expect(document.querySelectorAll('h2')[2].innerText).toEqual('baz: bab = 1')
443 | expect(document.querySelectorAll('h2')[3].innerText).toEqual('baz: lab = 0')
444 |
445 | expect(document._alerts.length).toEqual(3)
446 | expect(document._alerts[0]).toEqual('foo: bob = 1')
447 | expect(document._alerts[1]).toEqual('baz: bab = 1')
448 | expect(document._alerts[2]).toEqual('foo: bob = 2')
449 | })
450 | })
451 |
--------------------------------------------------------------------------------
/test/bind.spec.js:
--------------------------------------------------------------------------------
1 | import Alpine from 'alpinejs'
2 | import { fireEvent, wait } from '@testing-library/dom'
3 |
4 | global.MutationObserver = class {
5 | observe() {}
6 | }
7 |
8 | test('attribute bindings are set on initialize', async () => {
9 | document.body.innerHTML = `
10 |
11 |
12 |
13 | `
14 |
15 | Alpine.start()
16 |
17 | expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
18 | })
19 |
20 | test('class attribute bindings are merged by string syntax', async () => {
21 | document.body.innerHTML = `
22 |
23 |
24 |
25 |
26 |
27 | `
28 | Alpine.start()
29 |
30 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
31 | expect(document.querySelector('span').classList.contains('bar')).toBeFalsy()
32 |
33 | document.querySelector('button').click()
34 |
35 | await wait(() => {
36 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
37 | expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
38 | })
39 |
40 | document.querySelector('button').click()
41 |
42 | await wait(() => {
43 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
44 | expect(document.querySelector('span').classList.contains('bar')).toBeFalsy()
45 | })
46 | })
47 |
48 | test('class attribute bindings are merged by array syntax', async () => {
49 | document.body.innerHTML = `
50 |
51 |
52 |
53 |
54 |
55 | `
56 | Alpine.start()
57 |
58 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
59 | expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
60 | expect(document.querySelector('span').classList.contains('baz')).toBeFalsy()
61 |
62 | document.querySelector('button').click()
63 |
64 | await wait(() => {
65 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
66 | expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
67 | expect(document.querySelector('span').classList.contains('baz')).toBeTruthy()
68 | })
69 |
70 | document.querySelector('button').click()
71 |
72 | await wait(() => {
73 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
74 | expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
75 | expect(document.querySelector('span').classList.contains('baz')).toBeFalsy()
76 | })
77 | })
78 |
79 | test('class attribute bindings are removed by object syntax', async () => {
80 | document.body.innerHTML = `
81 |
82 |
83 |
84 | `
85 |
86 | Alpine.start()
87 |
88 | expect(document.querySelector('span').classList.contains('foo')).toBeFalsy()
89 | })
90 |
91 | test('class attribute bindings are added by string syntax', async () => {
92 | document.body.innerHTML = `
93 |
94 |
95 |
96 | `
97 |
98 | Alpine.start()
99 |
100 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
101 | })
102 |
103 | test('class attribute bindings are added by object syntax', async () => {
104 | document.body.innerHTML = `
105 |
106 |
107 |
108 | `
109 |
110 | Alpine.start()
111 |
112 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
113 | })
114 |
115 | test('multiple classes are added by object syntax', async () => {
116 | document.body.innerHTML = `
117 |
118 |
119 |
120 | `
121 |
122 | Alpine.start()
123 |
124 | expect(document.querySelector('span').classList.contains('foo')).toBeFalsy()
125 | expect(document.querySelector('span').classList.contains('bar')).toBeFalsy()
126 | })
127 |
128 | test('multiple classes are removed by object syntax', async () => {
129 | document.body.innerHTML = `
130 |
131 |
132 |
133 | `
134 |
135 | Alpine.start()
136 |
137 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
138 | expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
139 | })
140 |
141 | test('class attribute bindings are added by nested object syntax', async () => {
142 | document.body.innerHTML = `
143 |
144 |
145 |
146 | `
147 |
148 | Alpine.start()
149 |
150 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
151 | })
152 |
153 | test('class attribute bindings are added by array syntax', async () => {
154 | document.body.innerHTML = `
155 |
156 |
157 |
158 | `
159 |
160 | Alpine.start()
161 |
162 | expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
163 | })
164 |
165 | test('class attribute bindings are synced by string syntax', async () => {
166 | document.body.innerHTML = `
167 |
168 |
169 |
170 | `
171 |
172 | Alpine.start()
173 |
174 | expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
175 | expect(document.querySelector('span').classList.contains('baz')).toBeTruthy()
176 | })
177 |
178 | test('boolean attributes set to false are removed from element', async () => {
179 | document.body.innerHTML = `
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
196 |
197 |
203 |
204 |
205 |
206 |
207 |
212 |
213 | `
214 | Alpine.start()
215 |
216 | expect(document.querySelectorAll('input')[0].disabled).toBeFalsy()
217 | expect(document.querySelectorAll('input')[1].checked).toBeFalsy()
218 | expect(document.querySelectorAll('input')[2].required).toBeFalsy()
219 | expect(document.querySelectorAll('input')[3].readOnly).toBeFalsy()
220 | expect(document.querySelectorAll('input')[4].hidden).toBeFalsy()
221 | expect(document.querySelectorAll('details')[0].open).toBeFalsy()
222 | expect(document.querySelectorAll('option')[0].selected).toBeFalsy()
223 | expect(document.querySelectorAll('select')[0].multiple).toBeFalsy()
224 | expect(document.querySelectorAll('textarea')[0].autofocus).toBeFalsy()
225 | expect(document.querySelectorAll('dl')[0].attributes.itemscope).toBeFalsy()
226 | expect(document.querySelectorAll('form')[0].attributes.novalidate).toBeFalsy()
227 | expect(document.querySelectorAll('iframe')[0].attributes.allowfullscreen).toBeFalsy()
228 | expect(document.querySelectorAll('iframe')[0].attributes.allowpaymentrequest).toBeFalsy()
229 | expect(document.querySelectorAll('button')[0].attributes.formnovalidate).toBeFalsy()
230 | expect(document.querySelectorAll('audio')[0].attributes.autoplay).toBeFalsy()
231 | expect(document.querySelectorAll('audio')[0].attributes.controls).toBeFalsy()
232 | expect(document.querySelectorAll('audio')[0].attributes.loop).toBeFalsy()
233 | expect(document.querySelectorAll('audio')[0].attributes.muted).toBeFalsy()
234 | expect(document.querySelectorAll('video')[0].attributes.playsinline).toBeFalsy()
235 | expect(document.querySelectorAll('track')[0].attributes.default).toBeFalsy()
236 | expect(document.querySelectorAll('img')[0].attributes.ismap).toBeFalsy()
237 | expect(document.querySelectorAll('ol')[0].attributes.reversed).toBeFalsy()
238 | expect(document.querySelectorAll('script')[0].attributes.async).toBeFalsy()
239 | expect(document.querySelectorAll('script')[0].attributes.defer).toBeFalsy()
240 | expect(document.querySelectorAll('script')[0].attributes.nomodule).toBeFalsy()
241 | })
242 |
243 | test('boolean attributes set to true are added to element', async () => {
244 | document.body.innerHTML = `
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
260 |
261 |
267 |
268 |
269 |
270 |
271 |
276 |
277 | `
278 |
279 | Alpine.start()
280 |
281 | expect(document.querySelectorAll('input')[0].disabled).toBeTruthy()
282 | expect(document.querySelectorAll('input')[1].checked).toBeTruthy()
283 | expect(document.querySelectorAll('input')[2].required).toBeTruthy()
284 | expect(document.querySelectorAll('input')[3].readOnly).toBeTruthy()
285 | expect(document.querySelectorAll('details')[0].open).toBeTruthy()
286 | expect(document.querySelectorAll('option')[0].selected).toBeTruthy()
287 | expect(document.querySelectorAll('select')[0].multiple).toBeTruthy()
288 | expect(document.querySelectorAll('textarea')[0].autofocus).toBeTruthy()
289 | expect(document.querySelectorAll('dl')[0].attributes.itemscope).toBeTruthy()
290 | expect(document.querySelectorAll('form')[0].attributes.novalidate).toBeTruthy()
291 | expect(document.querySelectorAll('iframe')[0].attributes.allowfullscreen).toBeTruthy()
292 | expect(document.querySelectorAll('iframe')[0].attributes.allowpaymentrequest).toBeTruthy()
293 | expect(document.querySelectorAll('button')[0].attributes.formnovalidate).toBeTruthy()
294 | expect(document.querySelectorAll('audio')[0].attributes.autoplay).toBeTruthy()
295 | expect(document.querySelectorAll('audio')[0].attributes.controls).toBeTruthy()
296 | expect(document.querySelectorAll('audio')[0].attributes.loop).toBeTruthy()
297 | expect(document.querySelectorAll('audio')[0].attributes.muted).toBeTruthy()
298 | expect(document.querySelectorAll('video')[0].attributes.playsinline).toBeTruthy()
299 | expect(document.querySelectorAll('track')[0].attributes.default).toBeTruthy()
300 | expect(document.querySelectorAll('img')[0].attributes.ismap).toBeTruthy()
301 | expect(document.querySelectorAll('ol')[0].attributes.reversed).toBeTruthy()
302 | expect(document.querySelectorAll('script')[0].attributes.async).toBeTruthy()
303 | expect(document.querySelectorAll('script')[0].attributes.defer).toBeTruthy()
304 | expect(document.querySelectorAll('script')[0].attributes.nomodule).toBeTruthy()
305 | })
306 |
307 | test('binding supports short syntax', async () => {
308 | document.body.innerHTML = `
309 |
310 |
311 |
312 | `
313 |
314 | Alpine.start()
315 |
316 | expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
317 | })
318 |
319 | test('checkbox values are set correctly', async () => {
320 | document.body.innerHTML = `
321 |
322 |
323 |
324 |
325 |
326 | `
327 |
328 | Alpine.start()
329 |
330 | expect(document.querySelector('input[name="trueCheckbox"]').value).toEqual('on')
331 | expect(document.querySelector('input[name="falseCheckbox"]').value).toEqual('on')
332 | expect(document.querySelector('input[name="stringCheckbox"]').value).toEqual('foo')
333 | });
334 |
335 | test('radio values are set correctly', async () => {
336 | document.body.innerHTML = `
337 |
338 |
339 |
340 |
341 |
342 |
343 | `
344 |
345 | Alpine.start()
346 |
347 | expect(document.querySelector('#list-1').value).toEqual('1')
348 | expect(document.querySelector('#list-1').checked).toBeFalsy()
349 | expect(document.querySelector('#list-8').value).toEqual('8')
350 | expect(document.querySelector('#list-8').checked).toBeTruthy()
351 | expect(document.querySelector('#list-test').value).toEqual('test')
352 | expect(document.querySelector('#list-test').checked).toBeFalsy()
353 | });
354 |
355 | test('classes are removed before being added', async () => {
356 | document.body.innerHTML = `
357 |
358 |
359 | Span
360 |
361 |
362 |
363 | `
364 |
365 | Alpine.start()
366 |
367 | expect(document.querySelector('span').classList.contains('block')).toBeTruthy()
368 | expect(document.querySelector('span').classList.contains('text-red')).toBeTruthy()
369 |
370 | document.querySelector('button').click()
371 |
372 | await wait(() => {
373 | expect(document.querySelector('span').classList.contains('block')).toBeFalsy()
374 | expect(document.querySelector('span').classList.contains('hidden')).toBeTruthy()
375 | expect(document.querySelector('span').classList.contains('text-red')).toBeTruthy()
376 | })
377 | });
378 |
379 | test('cursor position is preserved on selectable text input', async () => {
380 | document.body.innerHTML = `
381 |
382 |
383 |
384 | `
385 |
386 | Alpine.start()
387 |
388 | document.querySelector('input').focus()
389 |
390 | expect(document.querySelector('input').value).toEqual('bar')
391 | expect(document.querySelector('input').selectionStart).toEqual(0)
392 | expect(document.querySelector('input').selectionEnd).toEqual(0)
393 | expect(document.querySelector('input').selectionDirection).toEqual('none')
394 |
395 | document.querySelector('input').setSelectionRange(0, 3, 'backward')
396 |
397 | await wait(() => {
398 | expect(document.querySelector('input').value).toEqual('baz')
399 | expect(document.querySelector('input').selectionStart).toEqual(0)
400 | expect(document.querySelector('input').selectionEnd).toEqual(3)
401 | expect(document.querySelector('input').selectionDirection).toEqual('backward')
402 | })
403 | })
404 |
405 | // input elements that are not 'text', 'search', 'url', 'password' types
406 | // will throw an exception when calling their setSelectionRange() method
407 | // see issues #401 #404 #405
408 | test('setSelectionRange is not called for inapplicable input types', async () => {
409 | document.body.innerHTML = `
410 |
411 |
412 |
413 | `
414 |
415 | Alpine.start()
416 |
417 | fireEvent.input(document.querySelector('input'), { target: { value: 'baz' } })
418 |
419 | await wait(() => {
420 | expect(document.querySelector('input').value).toEqual('baz')
421 | })
422 | })
423 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Feature
24 | Demo
25 |
26 |
27 |
28 |
29 | Simple x-if
30 |
31 |
32 |
33 | hey
34 |
35 |
36 | Show
37 |
38 |
39 |
40 |
41 |
42 | Dropdown
43 |
44 |
45 |
Open Dropdown
46 |
52 |
53 |
54 |
55 |
56 |
57 | Tabs
58 |
59 |
60 |
Foo
62 |
Bar
64 |
Tab Foo
66 |
Tab Bar
68 |
69 |
70 |
71 |
72 |
73 | Data Binding
74 |
75 |
77 | Text:
78 |
79 |
80 | Checkbox:
81 |
82 |
84 |
86 | Radio:
87 |
88 |
89 |
90 | Select:
91 |
92 |
93 | foo
94 | bar
95 |
96 | Multiple Select:
97 |
98 |
99 | foo
100 | bar
101 |
102 |
103 |
104 |
105 |
106 |
107 | Cast to number
108 |
109 |
113 |
114 |
115 |
116 |
117 | Nested Binding
118 |
119 |
123 |
124 |
125 |
126 |
127 | On Click
128 |
129 |
130 |
Hi There!
131 |
132 |
Show/Hide
133 |
134 |
135 |
136 |
137 |
138 | Refs
139 |
140 |
141 |
Remove Me
142 |
143 |
Remove
145 |
146 |
147 |
148 |
149 |
150 | x-on:click.stop
151 |
152 |
153 |
154 |
155 |
156 | Shouldn't change to baz
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 | x-on:click.prevent
165 |
166 |
170 |
171 |
172 |
173 |
174 | x-on:click.once
175 |
176 |
177 | I've been clicked:
178 |
179 |
180 |
181 |
182 |
183 |
184 | Append DOM
185 |
186 | Click me.
187 |
200 |
201 |
202 |
203 |
204 | Append Nested DOM
205 |
206 | Click me.
207 |
223 |
224 |
225 |
226 |
227 | x-for
228 |
229 |
246 |
247 |
248 |
249 |
250 | Nested x-for
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 | Transitions
270 |
271 |
272 |
273 | Open Modal
274 |
275 |
276 |
279 |
284 |
291 |
292 | hey
293 |
294 |
295 |
296 | Cancel
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 | Transitions (with x-if)
307 |
308 |
309 |
310 | Open Modal
311 |
312 |
313 |
314 |
320 | hey
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 | Nested Transitions (with x-show)
329 |
330 |
331 |
332 | Open Modal
333 |
334 |
335 |
336 | I shouldn't leave until the transition finishes.
337 |
341 | I'm transitioning
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 | Init function callback access refs and mutate data
350 |
351 |
352 |
hey
353 |
354 |
increase
355 |
356 |
357 |
369 |
370 |
371 |
372 |
373 | Dispatch
374 |
375 |
376 |
377 |
378 | Turn 'bar' to 'baz'
379 |
380 |
381 |
382 |
383 |
384 | Cloak
385 |
386 |
387 | I'm cloaked
388 |
389 |
390 |
391 |
392 |
393 |
394 |
395 |
--------------------------------------------------------------------------------