Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: alpinejs/alpine
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v3.13.2
Choose a base ref
...
head repository: alpinejs/alpine
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v3.13.3
Choose a head ref

Commits on Oct 18, 2023

  1. wip

    calebporzio committed Oct 18, 2023
    Copy the full SHA
    79ad90b View commit details

Commits on Oct 23, 2023

  1. wip

    calebporzio committed Oct 23, 2023
    Copy the full SHA
    d352364 View commit details

Commits on Oct 31, 2023

  1. Fix teleport morphing

    calebporzio committed Oct 31, 2023
    Copy the full SHA
    51af642 View commit details
  2. Copy the full SHA
    61714ff View commit details
  3. wip

    calebporzio committed Oct 31, 2023
    Copy the full SHA
    a8b70cb View commit details
  4. wip

    calebporzio committed Oct 31, 2023
    Copy the full SHA
    3bb1e55 View commit details

Commits on Nov 1, 2023

  1. wip

    calebporzio committed Nov 1, 2023
    Copy the full SHA
    fd83d03 View commit details
  2. wip

    calebporzio committed Nov 1, 2023
    Copy the full SHA
    e67d428 View commit details
  3. Fix teleport morphing (#3841)

    * Fix teleport morphing
    
    * Make x-trap more friendly with transitions
    
    * wip
    
    * wip
    
    * wip
    
    * wip
    calebporzio authored Nov 1, 2023
    Copy the full SHA
    6344404 View commit details
  4. Copy the full SHA
    11a122d View commit details
  5. Write documentation

    calebporzio committed Nov 1, 2023
    Copy the full SHA
    eb76852 View commit details
  6. wip

    calebporzio committed Nov 1, 2023
    Copy the full SHA
    9e1ec53 View commit details
  7. wip

    calebporzio committed Nov 1, 2023
    Copy the full SHA
    4f2c5b9 View commit details

Commits on Nov 2, 2023

  1. Fix menu morphing

    calebporzio committed Nov 2, 2023
    Copy the full SHA
    be4b419 View commit details
  2. Tag anchor plugin

    calebporzio committed Nov 2, 2023
    Copy the full SHA
    898702c View commit details
  3. Bump UI package

    calebporzio committed Nov 2, 2023
    Copy the full SHA
    2d8a838 View commit details
  4. wip

    calebporzio committed Nov 2, 2023
    Copy the full SHA
    77afb83 View commit details

Commits on Nov 3, 2023

  1. wip

    calebporzio committed Nov 3, 2023
    Copy the full SHA
    d9f0439 View commit details
  2. wip

    calebporzio committed Nov 3, 2023
    Copy the full SHA
    980f2fd View commit details
  3. wip

    calebporzio committed Nov 3, 2023
    Copy the full SHA
    6960327 View commit details

Commits on Nov 4, 2023

  1. Copy the full SHA
    69b2fbf View commit details

Commits on Nov 6, 2023

  1. Jlb/fix combobox bugs (#3854)

    * Account for null values when comparing "by" property
    
    * More fixes
    
    * Fix aria-selected attribute
    
    * Fix home/end/page up/page down + shift keydown handling
    
    * Add some additional tests
    
    * Fix more aria-selected tests
    
    * wip
    
    ---------
    
    Co-authored-by: Jason Beggs <jason@roasted.dev>
    calebporzio and jasonlbeggs authored Nov 6, 2023
    Copy the full SHA
    05b583e View commit details
  2. Copy the full SHA
    6559da1 View commit details
  3. Update anchor.md (#3847)

    allenjd3 authored Nov 6, 2023
    Copy the full SHA
    7fb58f8 View commit details

Commits on Nov 9, 2023

  1. wip

    calebporzio committed Nov 9, 2023
    Copy the full SHA
    cd6cf78 View commit details
  2. Copy the full SHA
    4c57b81 View commit details
  3. fix

    calebporzio committed Nov 9, 2023
    Copy the full SHA
    e7cf6f4 View commit details
  4. wip

    calebporzio committed Nov 9, 2023
    Copy the full SHA
    3a19b0f View commit details

Commits on Nov 11, 2023

  1. x-model.boolean modifier (#3532)

    * Add x-model.boolean
    
    * fix
    
    ---------
    
    Co-authored-by: Caleb Porzio <calebporzio@gmail.com>
    gdebrauwer and calebporzio authored Nov 11, 2023
    Copy the full SHA
    95b4b7f View commit details
  2. Copy the full SHA
    2a566bb View commit details

Commits on Nov 19, 2023

  1. Copy the full SHA
    995f7fc View commit details
  2. Fix tests

    calebporzio committed Nov 19, 2023
    Copy the full SHA
    333f5a2 View commit details
  3. Copy the full SHA
    5c461c4 View commit details
  4. wip

    calebporzio committed Nov 19, 2023
    Copy the full SHA
    ac0b741 View commit details
  5. bump version

    calebporzio committed Nov 19, 2023
    Copy the full SHA
    eed29f0 View commit details
Showing with 965 additions and 370 deletions.
  1. +7 −283 index.html
  2. +41 −8 package-lock.json
  3. +2 −1 package.json
  4. +1 −1 packages/alpinejs/package.json
  5. +2 −1 packages/alpinejs/src/alpine.js
  6. +8 −23 packages/alpinejs/src/clone.js
  7. +1 −0 packages/alpinejs/src/directives.js
  8. +25 −1 packages/alpinejs/src/directives/x-data.js
  9. +5 −2 packages/alpinejs/src/directives/x-effect.js
  10. +30 −12 packages/alpinejs/src/directives/x-model.js
  11. +1 −1 packages/alpinejs/src/directives/x-transition.js
  12. +1 −1 packages/alpinejs/src/lifecycle.js
  13. +17 −1 packages/alpinejs/src/utils/bind.js
  14. +5 −0 packages/anchor/builds/cdn.js
  15. +3 −0 packages/anchor/builds/module.js
  16. +17 −0 packages/anchor/package.json
  17. +77 −0 packages/anchor/src/index.js
  18. +1 −1 packages/collapse/package.json
  19. +1 −1 packages/docs/package.json
  20. +13 −0 packages/docs/src/en/directives/model.md
  21. +1 −1 packages/docs/src/en/essentials/installation.md
  22. +213 −0 packages/docs/src/en/plugins/anchor.md
  23. +1 −1 packages/docs/src/en/plugins/morph.md
  24. +1 −1 packages/focus/package.json
  25. +5 −4 packages/focus/src/index.js
  26. +1 −1 packages/intersect/package.json
  27. +1 −1 packages/mask/package.json
  28. +1 −1 packages/morph/package.json
  29. +18 −4 packages/morph/src/morph.js
  30. +1 −1 packages/persist/package.json
  31. +1 −1 packages/ui/package.json
  32. +11 −3 packages/ui/src/combobox.js
  33. +4 −0 packages/ui/src/list-context.js
  34. +18 −7 packages/ui/src/menu.js
  35. +1 −0 scripts/build.js
  36. +6 −0 scripts/release.js
  37. +81 −1 tests/cypress/integration/directives/x-model.spec.js
  38. +13 −0 tests/cypress/integration/plugins/anchor.spec.js
  39. +67 −0 tests/cypress/integration/plugins/morph.spec.js
  40. +261 −6 tests/cypress/integration/plugins/ui/combobox.spec.js
  41. +1 −0 tests/cypress/spec.html
290 changes: 7 additions & 283 deletions index.html
Original file line number Diff line number Diff line change
@@ -5,292 +5,16 @@
<script src="./packages/focus/dist/cdn.js"></script>
<script src="./packages/mask/dist/cdn.js"></script>
<script src="./packages/ui/dist/cdn.js" defer></script> -->
<script src="./packages/anchor/dist/cdn.js" defer></script>
<script src="./packages/alpinejs/dist/cdn.js" defer></script>
<!-- <script src="//cdn.tailwindcss.com"></script> -->
<!-- <script src="//cdn.tailwindcss.com"></script> -->

<div x-data="{
my_array: [{x:'x'},{x:'y'}],
click() {
this.my_array = [{x:'a'},{x:'b'}];
}
}">

<!-- Loop with plain div -->
<template x-for="item in my_array">
<div x-text="item.x"></div>
</template>

<!-- Loop with div nested inside component -->
<template x-for="item in my_array">
<div x-data="some_component" >
<div x-text="item.x"></div>
</div>
</template>

<button @click="click">Click me</button>

<div x-data="{ val: true }"
>
<input type="text" x-model.boolean="val">
<input type="checkbox" x-model.boolean="val">
<input type="radio" name="foo" value="true" x-model.boolean="val">
<input type="radio" name="foo" value="false" x-model.boolean="val">
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('some_component', () => ({}));
});

</script>




<button wire:click.prefetch"...">
Do something
</button>










<div x-data="{ count: $url(1) }">
<button @click="count++">+</button>
<button @click="count--">-</button>

<h1 x-text="count"></h1>
</div>





















<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>
<br>


<div x-data="{ users: [{ name: 'lebowski' }] }">
<template x-for="(user, idx) in users">
<span x-text="users[idx].name" x-yo></span>
</template>

<button @click="users = []">Reset</button>
</div>

<div x-data="{ foo: undefined }">
Yo: <input type="text" x-model="foo">
</div>

<!-- Play around here... -->

<div class="relative">
<div>Query: <span x-text="query"></span></div>
<span class="inline-block w-full rounded-md shadow-sm">
<div class="relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-2 pr-10 text-left transition duration-150 ease-in-out focus-within:border-blue-700 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 sm:text-sm sm:leading-5">
<span class="block flex flex-wrap gap-2">
<span x-show="activePersons.length === 0" class="p-0.5">Empty</span>
<template x-for="person in activePersons" :key="person.id">
<span class="flex items-center gap-1 rounded bg-blue-50 px-2 py-0.5">
<span x-text="person.name"></span>
<svg class="h-4 w-4 cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" @click.stop.prevent="removePerson(person)">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
</template>
<input x-combobox:input @change="query = $event.target.value" class="border-none p-0 focus:ring-0" placeholder="Search..." />
</span>
<button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</span>

<div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<ul x-combobox:options hold class="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
<template
x-for="person in people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase())
)"
:key="person.id"
>
<li x-combobox:option :value="person" class="relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none" :class="$comboboxOption.isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'">
<span x-text="person.name" class="block truncate" :class="{ 'font-semibold': $comboboxOption.isSelected, 'font-normal': !$comboboxOption.isSelected }">
</span>
<span x-show="$comboboxOption.isSelected" class="absolute inset-y-0 right-0 flex items-center pr-4" :class="{ 'text-white': $comboboxOption.isActive, 'text-indigo-600': !$comboboxOption.isActive }">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</span>
</li>
</template>

<!-- <template x-if="queryPerson">
<li x-combobox:option :value="queryPerson" class="relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none" :class="$comboboxOption.isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'">
<span x-text="'Create ' + queryPerson.name" class="block truncate" :class="{ 'font-semibold': $comboboxOption.isSelected, 'font-normal': !$comboboxOption.isSelected }">
</span>
<span x-show="$comboboxOption.isSelected" class="absolute inset-y-0 right-0 flex items-center pr-4" :class="{ 'text-white': $comboboxOption.isActive, 'text-indigo-600': !$comboboxOption.isActive }">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</span>
</li>
</template> -->
</ul>
</div>
</div>
</div>
<button class="mt-2 inline-flex items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
Submit
</button>
</form>
</div>
</div>
</div>

<div
x-data="{
query: '',
selected: null,
frameworks: [
{
id: 1,
name: 'Laravel',
disabled: false,
},
{
id: 2,
name: 'Ruby on Rails',
disabled: false,
},
{
id: 3,
name: 'Django',
disabled: false,
},
{
id: 4,
name: 'Express',
disabled: false,
},
{
id: 5,
name: 'Phoenix',
disabled: false,
},
{
id: 6,
name: 'Adonis',
disabled: false,
},
{
id: 7,
name: 'NextJS',
disabled: false,
},
],
get filteredFrameworks() {
return this.query === ''
? this.frameworks
: this.frameworks.filter((framework) => {
return framework.name.toLowerCase().includes(this.query.toLowerCase())
})
}
}"

class="flex h-full w-screen justify-center bg-gray-50 p-12"
>
<div x-combobox x-model="selected">
<label x-combobox:label class="block text-sm text-gray-600">
Select framework
</label>

<div class="mt-1 relative">
<div class="flex items-center justify-between gap-2 w-64 bg-white pl-5 pr-3 py-2.5 rounded-md shadow">
<input
x-combobox:input
:display-value="framework => framework.name"
@change="query = $event.target.value"
class="border-none p-0 focus:outline-none focus:ring-0"
placeholder="Search..."
/>
<button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
<!-- Heroicons up/down -->
<svg class="shrink-0 w-5 h-5 text-gray-500" viewBox="0 0 20 20" fill="none" stroke="currentColor"><path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
</button>
</div>

<div x-combobox:options x-cloak class="absolute right-0 w-64 max-h-60 mt-2 z-10 origin-top-right overflow-hidden bg-white border border-gray-200 rounded-md shadow-md outline-none" x-transition>
<ul class="divide-y divide-gray-100">
<template
x-for="framework in filteredFrameworks"
:key="framework.id"
hidden
>
<li
x-combobox:option
:value="framework"
:disabled="framework.disabled"
:class="{
'bg-cyan-500/10 text-gray-900': $comboboxOption.isActive,
'text-gray-600': ! $comboboxOption.isActive,
'opacity-50 cursor-not-allowed': $comboboxOption.isDisabled,
}"
class="flex items-center cursor-default justify-between gap-2 w-full px-4 py-2 text-sm"
>
<span x-text="framework.name"></span>

<span x-show="$comboboxOption.isSelected" class="text-cyan-600 font-bold">&check;</span>
</li>
</template>
</ul>

<p x-show="filteredFrameworks.length == 0" class="px-4 py-2 text-sm text-gray-600">No frameworks match your query.</p>
</div>
</div>
<div>local selected: <span x-text="selected?.name"></span></div>
<div>internal selected: <span x-text="$combobox.value?.name"></span></div>
<article x-text="$combobox.activeIndex"></article>
</div>
</div>




</html>
49 changes: 41 additions & 8 deletions package-lock.json
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -8,14 +8,15 @@
"chalk": "^4.1.1",
"cypress": "^7.0.0",
"cypress-plugin-tab": "^1.0.5",
"@floating-ui/dom": "^1.5.3",
"dot-json": "^1.2.2",
"esbuild": "~0.16.17",
"jest": "^26.6.3"
},
"scripts": {
"build": "node ./scripts/build.js",
"watch": "node ./scripts/build.js --watch",
"test": "cypress run",
"test": "cypress run --quiet",
"cypress": "cypress open",
"jest": "jest test",
"update-docs": "node ./scripts/update-docs.js",
2 changes: 1 addition & 1 deletion packages/alpinejs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "alpinejs",
"version": "3.13.1",
"version": "3.13.3",
"description": "The rugged, minimal JavaScript framework",
"homepage": "https://alpinejs.dev",
"repository": {
3 changes: 2 additions & 1 deletion packages/alpinejs/src/alpine.js
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { onElRemoved, onAttributeRemoved, onAttributesAdded, mutateDom, deferMut
import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
import { transition } from './directives/x-transition'
import { clone, cloneNode, skipDuringClone, onlyDuringClone } from './clone'
import { clone, cloneNode, skipDuringClone, onlyDuringClone, interceptClone } from './clone'
import { interceptor } from './interceptor'
import { getBinding as bound, extractProp } from './utils/bind'
import { debounce } from './utils/debounce'
@@ -39,6 +39,7 @@ let Alpine = {
onlyDuringClone,
addRootSelector,
addInitSelector,
interceptClone,
addScopeToNode,
deferMutations,
mapAttributes,
31 changes: 8 additions & 23 deletions packages/alpinejs/src/clone.js
Original file line number Diff line number Diff line change
@@ -12,18 +12,15 @@ export function onlyDuringClone(callback) {
return (...args) => isCloning && callback(...args)
}

let interceptors = []

export function interceptClone(callback) {
interceptors.push(callback)
}

export function cloneNode(from, to)
{
// Transfer over existing runtime Alpine state from
// the existing dom tree over to the new one...
if (from._x_dataStack) {
to._x_dataStack = from._x_dataStack

// Set a flag to signify the new tree is using
// pre-seeded state (used so x-data knows when
// and when not to initialize state)...
to.setAttribute('data-has-alpine-state', true)
}
interceptors.forEach(i => i(from, to))

isCloning = true

@@ -41,7 +38,7 @@ export function cloneNode(from, to)
isCloning = false
}

let isCloningLegacy = false
export let isCloningLegacy = false

/** deprecated */
export function clone(oldEl, newEl) {
@@ -90,15 +87,3 @@ function dontRegisterReactiveSideEffects(callback) {

overrideEffect(cache)
}

// If we are cloning a tree, we only want to evaluate x-data if another
// x-data context DOESN'T exist on the component.
// The reason a data context WOULD exist is that we graft root x-data state over
// from the live tree before hydrating the clone tree.
export function shouldSkipRegisteringDataDuringClone(el) {
if (! isCloning) return false
if (isCloningLegacy) return true

return el.hasAttribute('data-has-alpine-state')
}

1 change: 1 addition & 0 deletions packages/alpinejs/src/directives.js
Original file line number Diff line number Diff line change
@@ -203,6 +203,7 @@ let directiveOrder = [
'ref',
'data',
'id',
'anchor',
'bind',
'init',
'for',
26 changes: 25 additions & 1 deletion packages/alpinejs/src/directives/x-data.js
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import { directive, prefix } from '../directives'
import { initInterceptors } from '../interceptor'
import { injectDataProviders } from '../datas'
import { addRootSelector } from '../lifecycle'
import { shouldSkipRegisteringDataDuringClone } from '../clone'
import { interceptClone, isCloning, isCloningLegacy } from '../clone'
import { addScopeToNode } from '../scope'
import { injectMagics, magic } from '../magics'
import { reactive } from '../reactivity'
@@ -41,3 +41,27 @@ directive('data', ((el, { expression }, { cleanup }) => {
undo()
})
}))

interceptClone((from, to) => {
// Transfer over existing runtime Alpine state from
// the existing dom tree over to the new one...
if (from._x_dataStack) {
to._x_dataStack = from._x_dataStack

// Set a flag to signify the new tree is using
// pre-seeded state (used so x-data knows when
// and when not to initialize state)...
to.setAttribute('data-has-alpine-state', true)
}
})

// If we are cloning a tree, we only want to evaluate x-data if another
// x-data context DOESN'T exist on the component.
// The reason a data context WOULD exist is that we graft root x-data state over
// from the live tree before hydrating the clone tree.
function shouldSkipRegisteringDataDuringClone(el) {
if (! isCloning) return false
if (isCloningLegacy) return true

return el.hasAttribute('data-has-alpine-state')
}
7 changes: 5 additions & 2 deletions packages/alpinejs/src/directives/x-effect.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { skipDuringClone } from '../clone'
import { directive } from '../directives'
import { evaluateLater } from '../evaluator'
import { evaluate, evaluateLater } from '../evaluator'

directive('effect', (el, { expression }, { effect }) => effect(evaluateLater(el, expression)))
directive('effect', skipDuringClone((el, { expression }, { effect }) => {
effect(evaluateLater(el, expression))
}))
42 changes: 30 additions & 12 deletions packages/alpinejs/src/directives/x-model.js
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import { evaluateLater } from '../evaluator'
import { directive } from '../directives'
import { mutateDom } from '../mutation'
import { nextTick } from '../nextTick'
import bind from '../utils/bind'
import bind, { safeParseBoolean } from '../utils/bind'
import on from '../utils/on'
import { warn } from '../utils/warn'
import { isCloning } from '../clone'
@@ -46,7 +46,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
})
}
}

if (typeof expression === 'string' && el.type === 'radio') {
// Radio buttons only work properly when they share a name attribute.
// People might assume we take care of that for them, because
@@ -69,7 +69,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
let removeListener = isCloning ? () => {} : on(el, event, modifiers, (e) => {
setValue(getInputValue(el, modifiers, e, getValue()))
})

if (modifiers.includes('fill'))
if ([null, ''].includes(getValue())
|| (el.type === 'checkbox' && Array.isArray(getValue()))) {
@@ -138,26 +138,44 @@ function getInputValue(el, modifiers, event, currentValue) {
else if (el.type === 'checkbox') {
// If the data we are binding to is an array, toggle its value inside the array.
if (Array.isArray(currentValue)) {
let newValue = modifiers.includes('number') ? safeParseNumber(event.target.value) : event.target.value
let newValue = null;

if (modifiers.includes('number')) {
newValue = safeParseNumber(event.target.value)
} else if (modifiers.includes('boolean')) {
newValue = safeParseBoolean(event.target.value)
} else {
newValue = event.target.value
}

return event.target.checked ? currentValue.concat([newValue]) : currentValue.filter(el => ! checkedAttrLooseCompare(el, newValue))
} else {
return event.target.checked
}
} else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
return modifiers.includes('number')
? Array.from(event.target.selectedOptions).map(option => {
if (modifiers.includes('number')) {
return Array.from(event.target.selectedOptions).map(option => {
let rawValue = option.value || option.text
return safeParseNumber(rawValue)
})
: Array.from(event.target.selectedOptions).map(option => {
return option.value || option.text
} else if (modifiers.includes('boolean')) {
return Array.from(event.target.selectedOptions).map(option => {
let rawValue = option.value || option.text
return safeParseBoolean(rawValue)
})
}

return Array.from(event.target.selectedOptions).map(option => {
return option.value || option.text
})
} else {
let rawValue = event.target.value
return modifiers.includes('number')
? safeParseNumber(rawValue)
: (modifiers.includes('trim') ? rawValue.trim() : rawValue)
if (modifiers.includes('number')) {
return safeParseNumber(event.target.value)
} else if (modifiers.includes('boolean')) {
return safeParseBoolean(event.target.value)
}

return modifiers.includes('trim') ? event.target.value.trim() : event.target.value
}
})
}
2 changes: 1 addition & 1 deletion packages/alpinejs/src/directives/x-transition.js
Original file line number Diff line number Diff line change
@@ -156,7 +156,7 @@ window.Element.prototype._x_toggleAndCascadeWithTransitions = function (el, valu
? new Promise((resolve, reject) => {
el._x_transition.out(() => {}, () => resolve(hide))

el._x_transitioning.beforeCancel(() => reject({ isFromCancelledTransition: true }))
el._x_transitioning && el._x_transitioning.beforeCancel(() => reject({ isFromCancelledTransition: true }))
})
: Promise.resolve(hide)

2 changes: 1 addition & 1 deletion packages/alpinejs/src/lifecycle.js
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ export function start() {
})

let outNestedComponents = el => ! closestRoot(el.parentElement, true)
Array.from(document.querySelectorAll(allSelectors()))
Array.from(document.querySelectorAll(allSelectors().join(',')))
.filter(outNestedComponents)
.forEach(el => {
initTree(el)
18 changes: 17 additions & 1 deletion packages/alpinejs/src/utils/bind.js
Original file line number Diff line number Diff line change
@@ -49,7 +49,11 @@ function bindInputValue(el, value) {

// @todo: yuck
if (window.fromModel) {
el.checked = checkedAttrLooseCompare(el.value, value)
if (typeof value === 'boolean') {
el.checked = safeParseBoolean(el.value) === value
} else {
el.checked = checkedAttrLooseCompare(el.value, value)
}
}
} else if (el.type === 'checkbox') {
// If we are explicitly binding a string to the :value, set the string,
@@ -130,6 +134,18 @@ function checkedAttrLooseCompare(valueA, valueB) {
return valueA == valueB
}

export function safeParseBoolean(rawValue) {
if ([1, '1', 'true', 'on', 'yes', true].includes(rawValue)) {
return true
}

if ([0, '0', 'false', 'off', 'no', false].includes(rawValue)) {
return false
}

return rawValue ? Boolean(rawValue) : null
}

function isBooleanAttr(attrName) {
// As per HTML spec table https://html.spec.whatwg.org/multipage/indices.html#attributes-3:boolean-attribute
// Array roughly ordered by estimated usage
5 changes: 5 additions & 0 deletions packages/anchor/builds/cdn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import anchor from '../src/index.js'

document.addEventListener('alpine:init', () => {
window.Alpine.plugin(anchor)
})
3 changes: 3 additions & 0 deletions packages/anchor/builds/module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import anchor from '../src/index.js'

export default anchor
17 changes: 17 additions & 0 deletions packages/anchor/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@alpinejs/anchor",
"version": "3.13.3",
"description": "Anchor an element's position relative to another",
"homepage": "https://alpinejs.dev/plugins/anchor",
"repository": {
"type": "git",
"url": "https://github.com/alpinejs/alpine.git",
"directory": "packages/anchor"
},
"author": "Caleb Porzio",
"license": "MIT",
"main": "dist/module.cjs.js",
"module": "dist/module.esm.js",
"unpkg": "dist/cdn.min.js",
"dependencies": {}
}
77 changes: 77 additions & 0 deletions packages/anchor/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { computePosition, autoUpdate, flip, offset, shift } from '@floating-ui/dom'

export default function (Alpine) {
Alpine.magic('anchor', el => {
if (! el._x_anchor) throw 'Alpine: No x-anchor directive found on element using $anchor...'

return el._x_anchor
})

Alpine.interceptClone((from, to) => {
if (from && from._x_anchor && ! to._x_anchor) {
to._x_anchor = from._x_anchor
}
})

Alpine.directive('anchor', Alpine.skipDuringClone((el, { expression, modifiers, value }, { cleanup, evaluate }) => {
let { placement, offsetValue, unstyled } = getOptions(modifiers)

el._x_anchor = Alpine.reactive({ x: 0, y: 0 })

let reference = evaluate(expression)

if (! reference) throw 'Alpine: no element provided to x-anchor...'

let compute = () => {
let previousValue

computePosition(reference, el, {
placement,
middleware: [flip(), shift({padding: 5}), offset(offsetValue)],
}).then(({ x, y }) => {
unstyled || setStyles(el, x, y)

// Only trigger Alpine reactivity when the value actually changes...
if (JSON.stringify({ x, y }) !== previousValue) {
el._x_anchor.x = x
el._x_anchor.y = y
}

previousValue = JSON.stringify({ x, y })
})
}

let release = autoUpdate(reference, el, () => compute())

cleanup(() => release())
},

// When cloning (or "morphing"), we will graft the style and position data from the live tree...
(el, { expression, modifiers, value }, { cleanup, evaluate }) => {
let { placement, offsetValue, unstyled } = getOptions(modifiers)

if (el._x_anchor) {
unstyled || setStyles(el, el._x_anchor.x, el._x_anchor.y)
}
}))
}

function setStyles(el, x, y) {
Object.assign(el.style, {
left: x+'px', top: y+'px', position: 'absolute',
})
}

function getOptions(modifiers) {
let positions = ['top', 'top-start', 'top-end', 'right', 'right-start', 'right-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'left-start', 'left-end']
let placement = positions.find(i => modifiers.includes(i))
let offsetValue = 0
if (modifiers.includes('offset')) {
let idx = modifiers.findIndex(i => i === 'offset')

offsetValue = modifiers[idx + 1] !== undefined ? Number(modifiers[idx + 1]) : offsetValue
}
let unstyled = modifiers.includes('no-style')

return { placement, offsetValue, unstyled }
}
2 changes: 1 addition & 1 deletion packages/collapse/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/collapse",
"version": "3.13.1",
"version": "3.13.3",
"description": "Collapse and expand elements with robust animations",
"homepage": "https://alpinejs.dev/plugins/collapse",
"repository": {
2 changes: 1 addition & 1 deletion packages/docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/docs",
"version": "3.13.1-revision.1",
"version": "3.13.3-revision.1",
"description": "The documentation for Alpine",
"author": "Caleb Porzio",
"license": "MIT"
13 changes: 13 additions & 0 deletions packages/docs/src/en/directives/model.md
Original file line number Diff line number Diff line change
@@ -307,6 +307,19 @@ By default, any data stored in a property via `x-model` is stored as a string. T
<span x-text="typeof age"></span>
```

<a name="boolean"></a>
### `.boolean`

By default, any data stored in a property via `x-model` is stored as a string. To force Alpine to store the value as a JavaScript boolean, add the `.boolean` modifier. Both integers (1/0) and strings (true/false) are valid boolean values.

```alpine
<select x-model.boolean="isActive">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
<span x-text="typeof isActive"></span>
```

<a name="debounce"></a>
### `.debounce`

2 changes: 1 addition & 1 deletion packages/docs/src/en/essentials/installation.md
Original file line number Diff line number Diff line change
@@ -33,7 +33,7 @@ This is by far the simplest way to get started with Alpine. Include the followin
Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.

```alpine
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.1/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
```

That's it! Alpine is now available for use inside your page.
213 changes: 213 additions & 0 deletions packages/docs/src/en/plugins/anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
---
order: 5
title: Anchor
description: Anchor an element's positioning to another element on the pageg
graph_image: https://alpinejs.dev/social_anchor.jpg
---

# Anchor Plugin

Alpine's Anchor plugin allows you easily anchor an element's positioning to another element on the page.

This functionality is useful when creating dropdown menus, popovers, dialogs, and tooltips with Alpine.

The "anchoring" functionality used in this plugin is provided by the [Floating UI](https://floating-ui.com/) project.

<a name="installation"></a>
## Installation

You can use this plugin by either including it from a `<script>` tag or installing it via NPM:

### Via CDN

You can include the CDN build of this plugin as a `<script>` tag, just make sure to include it BEFORE Alpine's core JS file.

```alpine
<!-- Alpine Plugins -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/anchor@3.x.x/dist/cdn.min.js"></script>
<!-- Alpine Core -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
```

### Via NPM

You can install Anchor from NPM for use inside your bundle like so:

```shell
npm install @alpinejs/anchor
```

Then initialize it from your bundle:

```js
import Alpine from 'alpinejs'
import anchor from '@alpinejs/anchor'

Alpine.plugin(anchor)

...
```

<a name="x-anchor"></a>
## x-anchor

The primary API for using this plugin is the `x-anchor` directive.

To use this plugin, add the `x-anchor` directive to any element and pass it a reference to the element you want to anchor it's position to (often a button on the page).

By default, `x-anchor` will set the the element's CSS to `position: absolute` and the appropriate `top` and `left` values. If the anchored element is normally displayed below the reference element but doesn't have room on the page, it's styling will be adjusted to render above the element.

For example, here's a simple dropdown anchored to the button that toggles it:

```alpine
<div x-data="{ open: false }">
<button x-ref="button" @click="open = ! open">Toggle</button>
<div x-show="open" x-anchor="$refs.button">
Dropdown content
</div>
</div>
```

<!-- START_VERBATIM -->
<div x-data="{ open: false }" class="demo overflow-hidden">
<div class="flex justify-center">
<button x-ref="button" @click="open = ! open">Toggle</button>
</div>

<div x-show="open" x-anchor="$refs.button" class="bg-white rounded p-4 border shadow z-10">
Dropdown content
</div>
</div>
<!-- END_VERBATIM -->

<a name="positioning"></a>
## Positioning

`x-anchor` allows you to customize the positioning of the anchored element using the following modifiers:

* Bottom: `.bottom`, `.bottom-start`, `.bottom-end`
* Top: `.top`, `.top-start`, `.top-end`
* Left: `.left`, `.left-start`, `.left-end`
* Right: `.right`, `.right-start`, `.right-end`

Here is an example of using `.bottom-start` to position a dropdown below and to the right of the reference element:

```alpine
<div x-data="{ open: false }">
<button x-ref="button" @click="open = ! open">Toggle</button>
<div x-show="open" x-anchor.bottom-start="$refs.button">
Dropdown content
</div>
</div>
```

<!-- START_VERBATIM -->
<div x-data="{ open: false }" class="demo overflow-hidden">
<div class="flex justify-center">
<button x-ref="button" @click="open = ! open">Toggle</button>
</div>

<div x-show="open" x-anchor.bottom-start="$refs.button" class="bg-white rounded p-4 border shadow z-10">
Dropdown content
</div>
</div>
<!-- END_VERBATIM -->

<a name="offset"></a>
## Offset

You can add an offset to your anchored element using the `.offset.[px value]` modifier like so:

```alpine
<div x-data="{ open: false }">
<button x-ref="button" @click="open = ! open">Toggle</button>
<div x-show="open" x-anchor.offset.10="$refs.button">
Dropdown content
</div>
</div>
```

<!-- START_VERBATIM -->
<div x-data="{ open: false }" class="demo overflow-hidden">
<div class="flex justify-center">
<button x-ref="button" @click="open = ! open">Toggle</button>
</div>

<div x-show="open" x-anchor.offset.10="$refs.button" class="bg-white rounded p-4 border shadow z-10">
Dropdown content
</div>
</div>
<!-- END_VERBATIM -->

<a name="manual-styling"></a>
## Manual styling

By default, `x-anchor` applies the positioning styles to your element under the hood. If you'd prefer full control over styling, you can pass the `.no-style` modifer and use the `$anchor` magic to access the values inside another Alpine expression.

Below is an example of bypassing `x-anchor`'s internal styling and instead applying the styles yourself using `x-bind:style`:

```alpine
<div x-data="{ open: false }">
<button x-ref="button" @click="open = ! open">Toggle</button>
<div
x-show="open"
x-anchor.no-style="$refs.button"
x-bind:style="{ position: 'absolute', top: $anchor.y+'px', left: $anchor.x+'px' }"
>
Dropdown content
</div>
</div>
```

<!-- START_VERBATIM -->
<div x-data="{ open: false }" class="demo overflow-hidden">
<div class="flex justify-center">
<button x-ref="button" @click="open = ! open">Toggle</button>
</div>

<div
x-show="open"
x-anchor.no-style="$refs.button"
x-bind:style="{ position: 'absolute', top: $anchor.y+'px', left: $anchor.x+'px' }"
class="bg-white rounded p-4 border shadow z-10"
>
Dropdown content
</div>
</div>
<!-- END_VERBATIM -->

<a name="from-id"></a>
## Anchor to an ID

The examples thus far have all been anchoring to other elements using Alpine refs.

Because `x-anchor` accepts a reference to any DOM element, you can use utilities like `document.getElementById()` to anchor to an element by its `id` attribute:

```alpine
<div x-data="{ open: false }">
<button id="trigger" @click="open = ! open">Toggle</button>
<div x-show="open" x-anchor="document.getElementById('#trigger')">
Dropdown content
</div>
</div>
```

<!-- START_VERBATIM -->
<div x-data="{ open: false }" class="demo overflow-hidden">
<div class="flex justify-center">
<button class="trigger" @click="open = ! open">Toggle</button>
</div>


<div x-show="open" x-anchor="document.querySelector('.trigger')">
Dropdown content
</div>
</div>
<!-- END_VERBATIM -->

2 changes: 1 addition & 1 deletion packages/docs/src/en/plugins/morph.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
order: 5
order: 6
title: Morph
description: Morph an element into the provided HTML
graph_image: https://alpinejs.dev/social_morph.jpg
2 changes: 1 addition & 1 deletion packages/focus/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/focus",
"version": "3.13.1",
"version": "3.13.3",
"description": "Manage focus within a page",
"homepage": "https://alpinejs.dev/plugins/focus",
"repository": {
9 changes: 5 additions & 4 deletions packages/focus/src/index.js
Original file line number Diff line number Diff line change
@@ -134,12 +134,13 @@ export default function (Alpine) {

// Start trapping.
if (value && ! oldValue) {
setTimeout(() => {
if (modifiers.includes('inert')) undoInert = setInert(el)
if (modifiers.includes('noscroll')) undoDisableScrolling = disableScrolling()
if (modifiers.includes('noscroll')) undoDisableScrolling = disableScrolling()
if (modifiers.includes('inert')) undoInert = setInert(el)

// Activate the trap after a generous tick. (Needed to play nice with transitions...)
setTimeout(() => {
trap.activate()
});
}, 15)
}

// Stop trapping.
2 changes: 1 addition & 1 deletion packages/intersect/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/intersect",
"version": "3.13.1",
"version": "3.13.3",
"description": "Trigger JavaScript when an element enters the viewport",
"homepage": "https://alpinejs.dev/plugins/intersect",
"repository": {
2 changes: 1 addition & 1 deletion packages/mask/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/mask",
"version": "3.13.1",
"version": "3.13.3",
"description": "An Alpine plugin for input masking",
"homepage": "https://alpinejs.dev/plugins/mask",
"repository": {
2 changes: 1 addition & 1 deletion packages/morph/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/morph",
"version": "3.13.1",
"version": "3.13.3",
"description": "Diff and patch a block of HTML on a page with an HTML template",
"homepage": "https://alpinejs.dev/plugins/morph",
"repository": {
22 changes: 18 additions & 4 deletions packages/morph/src/morph.js
Original file line number Diff line number Diff line change
@@ -120,13 +120,22 @@ export function morph(from, toHtml, options) {
}

function patchChildren(from, to) {
// If we hit a <template x-teleport="body">,
// let's use the teleported nodes for this patch...
if (from._x_teleport) from = from._x_teleport
if (to._x_teleport) to = to._x_teleport

let fromKeys = keyToMap(from.children)
let fromKeyHoldovers = {}

let currentTo = getFirstNode(to)
let currentFrom = getFirstNode(from)

while (currentTo) {
// If the "from" element has a dynamically bound "id" (x-bind:id="..."),
// Let's transfer it to the "to" element so that there isn't a key mismatch...
seedingMatchingId(currentTo, currentFrom)

let toKey = getKey(currentTo)
let fromKey = getKey(currentFrom)

@@ -444,10 +453,6 @@ function getFirstNode(parent) {
}

function getNextSibling(parent, reference) {
if (reference._x_teleport) {
return reference._x_teleport
}

let next

if (parent instanceof Block) {
@@ -485,3 +490,12 @@ function monkeyPatchDomSetAttributeToAllowAtSymbols() {
this.setAttributeNode(attr)
}
}

function seedingMatchingId(to, from) {
let fromId = from && from._x_bindings && from._x_bindings.id

if (! fromId) return

to.setAttribute('id', fromId)
to.id = fromId
}
2 changes: 1 addition & 1 deletion packages/persist/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/persist",
"version": "3.13.1",
"version": "3.13.3",
"description": "Persist Alpine data across page loads",
"homepage": "https://alpinejs.dev/plugins/persist",
"repository": {
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/ui",
"version": "3.13.1-beta.0",
"version": "3.13.2-beta.1",
"description": "Headless UI components for Alpine",
"homepage": "https://alpinejs.dev/components#headless",
"repository": {
14 changes: 11 additions & 3 deletions packages/ui/src/combobox.js
Original file line number Diff line number Diff line change
@@ -246,7 +246,15 @@ function handleRoot(el, Alpine) {

if (typeof by === 'string') {
let property = by
by = (a, b) => a[property] === b[property]
by = (a, b) => {
// Handle null values
if ((! a || typeof a !== 'object') || (! b || typeof b !== 'object')) {
return Alpine.raw(a) === Alpine.raw(b)
}


return a[property] === b[property];
}
}

return by(a, b)
@@ -432,9 +440,9 @@ function handleOption(el, Alpine) {

// Only the active element should have aria-selected="true"...
'x-effect'() {
this.$comboboxOption.isActive
this.$comboboxOption.isSelected
? el.setAttribute('aria-selected', true)
: el.removeAttribute('aria-selected')
: el.setAttribute('aria-selected', false)
},

':aria-disabled'() { return this.$comboboxOption.isDisabled },
4 changes: 4 additions & 0 deletions packages/ui/src/list-context.js
Original file line number Diff line number Diff line change
@@ -292,6 +292,8 @@ export function generateContext(Alpine, multiple, orientation, activateSelectedO
break;
case 'Home':
case 'PageUp':
if (e.key == 'Home' && e.shiftKey) return;

e.preventDefault(); e.stopPropagation()
setIsTyping(false)
this.reorderKeys(); hasActive = this.hasActive()
@@ -300,6 +302,8 @@ export function generateContext(Alpine, multiple, orientation, activateSelectedO

case 'End':
case 'PageDown':
if (e.key == 'End' && e.shiftKey) return;

e.preventDefault(); e.stopPropagation()
setIsTyping(false)
this.reorderKeys(); hasActive = this.hasActive()
25 changes: 18 additions & 7 deletions packages/ui/src/menu.js
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ export default function (Alpine) {
return $data.__activeEl == $data.__itemEl
},
get isDisabled() {
return el.__isDisabled.value
return $data.__itemEl.__isDisabled.value
},
}
})
@@ -29,14 +29,19 @@ function handleRoot(el, Alpine) {
__itemEls: [],
__activeEl: null,
__isOpen: false,
__open() {
__open(activationStrategy) {
this.__isOpen = true

// Safari needs more of a "tick" for focusing after x-show for some reason.
// Probably because Alpine adds an extra tick when x-showing for @click.outside
let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))

nextTick(() => this.$refs.__items.focus({ preventScroll: true }))
nextTick(() => {
this.$refs.__items.focus({ preventScroll: true })

// Activate the first item every time the menu is open...
activationStrategy && activationStrategy(Alpine, this.$refs.__items, el => el.__activate())
})
},
__close(focusAfter = true) {
this.__isOpen = false
@@ -67,12 +72,18 @@ function handleButton(el, Alpine) {
'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
'@click'() { this.$data.__open() },
'@keydown.down.stop.prevent'() { this.$data.__open() },
'@keydown.up.stop.prevent'() { this.$data.__open(dom.Alpine, last) },
'@keydown.up.stop.prevent'() { this.$data.__open(dom.last) },
'@keydown.space.stop.prevent'() { this.$data.__open() },
'@keydown.enter.stop.prevent'() { this.$data.__open() },
})
}

// When patching children:
// The child isn't initialized until it is reached. This is normally fine
// except when something like this happens where an "id" is added during the initializing phase
// because the "to" element hasn't initialized yet, it doesn't have the ID, so there is a "key" mismatch


function handleItems(el, Alpine) {
Alpine.bind(el, {
'x-ref': '__items',
@@ -153,10 +164,10 @@ function handleItem(el, Alpine) {
},
'x-id'() { return ['alpine-menu-item'] },
':id'() { return this.$id('alpine-menu-item') },
':tabindex'() { return this.$el.__isDisabled.value ? false : '-1' },
':tabindex'() { return this.__itemEl.__isDisabled.value ? false : '-1' },
'role': 'menuitem',
'@mousemove'() { this.$el.__isDisabled.value || this.$menuItem.isActive || this.$el.__activate() },
'@mouseleave'() { this.$el.__isDisabled.value || ! this.$menuItem.isActive || this.$el.__deactivate() },
'@mousemove'() { this.__itemEl.__isDisabled.value || this.$menuItem.isActive || this.__itemEl.__activate() },
'@mouseleave'() { this.__itemEl.__isDisabled.value || ! this.$menuItem.isActive || this.__itemEl.__deactivate() },
}
})
}
1 change: 1 addition & 0 deletions scripts/build.js
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ let zlib = require('zlib');
'intersect',
'persist',
'collapse',
'anchor',
'morph',
'focus',
'mask',
6 changes: 6 additions & 0 deletions scripts/release.js
Original file line number Diff line number Diff line change
@@ -51,6 +51,9 @@ function writeNewAlpineVersion() {
writeToPackageDotJson('collapse', 'version', version)
console.log('Bumping @alpinejs/collapse package.json: '+version)

writeToPackageDotJson('anchor', 'version', version)
console.log('Bumping @alpinejs/anchor package.json: '+version)

writeToPackageDotJson('morph', 'version', version)
console.log('Bumping @alpinejs/morph package.json: '+version)

@@ -89,6 +92,9 @@ function publish() {
console.log('Publishing @alpinejs/collapse on NPM...');
runFromPackage('collapse', 'npm publish --access public')

console.log('Publishing @alpinejs/anchor on NPM...');
runFromPackage('anchor', 'npm publish --access public')

console.log('Publishing @alpinejs/morph on NPM...');
runFromPackage('morph', 'npm publish --access public')

82 changes: 81 additions & 1 deletion tests/cypress/integration/directives/x-model.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { haveData, haveText, haveValue, html, test } from '../../utils'
import { beChecked, haveData, haveText, haveValue, html, notBeChecked, test } from '../../utils'

test('The name of the test',
html`<h1 x-data x-text="'HEY'"></h1>`,
@@ -79,6 +79,86 @@ test('x-model with number modifier returns: null if empty, original value if cas
}
)

test('x-model casts value to boolean initially for radios',
html`
<div x-data="{ foo: true }">
<input id="1" type="radio" value="true" name="foo" x-model.boolean="foo">
<input id="2" type="radio" value="false" name="foo" x-model.boolean="foo">
</div>
`,
({ get }) => {
get('div').should(haveData('foo', true))
get('#1').should(beChecked())
get('#2').should(notBeChecked())
get('#2').click()
get('div').should(haveData('foo', false))
get('#1').should(notBeChecked())
get('#2').should(beChecked())
}
)

test('x-model casts value to boolean if boolean modifier is present',
html`
<div x-data="{ foo: null, bar: null, baz: [] }">
<input type="text" x-model.boolean="foo"></input>
<input type="checkbox" x-model.boolean="foo"></input>
<input type="radio" name="foo" x-model.boolean="foo" value="true"></input>
<input type="radio" name="foo" x-model.boolean="foo" value="false"></input>
<select x-model.boolean="bar">
<option value="true">yes</option>
<option value="false">no</option>
</select>
</div>
`,
({ get }) => {
get('input[type=text]').type('1')
get('div').should(haveData('foo', true))
get('input[type=text]').clear().type('0')
get('div').should(haveData('foo', false))

get('input[type=checkbox]').check()
get('div').should(haveData('foo', true))
get('input[type=checkbox]').uncheck()
get('div').should(haveData('foo', false))

get('input[type=radio][value="true"]').should(notBeChecked())
get('input[type=radio][value="false"]').should(beChecked())
get('input[type=radio][value="true"]').check()
get('div').should(haveData('foo', true))
get('input[type=radio][value="false"]').check()
get('div').should(haveData('foo', false))

get('select').select('false')
get('div').should(haveData('bar', false))
get('select').select('true')
get('div').should(haveData('bar', true))
}
)

test('x-model with boolean modifier returns: null if empty, original value if casting fails, numeric value if casting passes',
html`
<div x-data="{ foo: 0, bar: '' }">
<input x-model.boolean="foo"></input>
</div>
`,
({ get }) => {
get('input').clear()
get('div').should(haveData('foo', null))
get('input').clear().type('bar')
get('div').should(haveData('foo', true))
get('input').clear().type('1')
get('div').should(haveData('foo', true))
get('input').clear().type('1').clear()
get('div').should(haveData('foo', null))
get('input').clear().type('0')
get('div').should(haveData('foo', false))
get('input').clear().type('bar')
get('div').should(haveData('foo', true))
get('input').clear().type('0').clear()
get('div').should(haveData('foo', null))
}
)

test('x-model trims value if trim modifier is present',
html`
<div x-data="{ foo: '' }">
13 changes: 13 additions & 0 deletions tests/cypress/integration/plugins/anchor.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { haveAttribute, haveComputedStyle, html, notHaveAttribute, test } from '../../utils'

test('can anchor an element',
[html`
<div x-data>
<button x-ref="foo">toggle</button>
<h1 x-anchor="$refs.foo">contents</h1>
</div>
`],
({ get }, reload) => {
get('h1').should(haveComputedStyle('position', 'absolute'))
},
)
67 changes: 67 additions & 0 deletions tests/cypress/integration/plugins/morph.spec.js
Original file line number Diff line number Diff line change
@@ -337,6 +337,29 @@ test('can morph using different keys',
},
)

test('can morph elements with dynamic ids',
[html`
<ul>
<li x-data x-bind:id="'1'" >foo<input></li>
</ul>
`],
({ get }, reload, window, document) => {
let toHtml = html`
<ul>
<li x-data x-bind:id="'1'" >foo<input></li>
</ul>
`

get('input').type('foo')

get('ul').then(([el]) => window.Alpine.morph(el, toHtml, {
key(el) { return el.id }
}))

get('li:nth-of-type(1) input').should(haveValue('foo'))
},
)

test('can morph different inline nodes',
[html`
<div id="from">
@@ -470,3 +493,47 @@ test('can morph @event handlers', [
get('button').should(haveText('buzz'));
}
);

test('can morph menu',
[html`
<main x-data>
<article x-menu>
<button data-trigger x-menu:button x-text="'ready'"></button>
<div x-menu:items>
<button x-menu:item href="#edit">
Edit
<input>
</button>
</div>
</article>
</main>
`],
({ get }, reload, window, document) => {
let toHtml = html`
<main x-data>
<article x-menu>
<button data-trigger x-menu:button x-text="'ready'"></button>
<div x-menu:items>
<button x-menu:item href="#edit">
Edit
<input>
</button>
</div>
</article>
</main>
`

get('[data-trigger]').should(haveText('ready'));
get('button[data-trigger').click()

get('input').type('foo')

get('main').then(([el]) => window.Alpine.morph(el, toHtml, {
key(el) { return el.id }
}))

get('input').should(haveValue('foo'))
},
)
267 changes: 261 additions & 6 deletions tests/cypress/integration/plugins/ui/combobox.spec.js
Original file line number Diff line number Diff line change
@@ -708,6 +708,155 @@ test('"multiple" and "name" props together',
},
);

test('"by" prop with string value',
[html`
<div
x-data="{
people: [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
{ id: 5, name: 'Tanya Fox', disabled: true },
{ id: 6, name: 'Hellen Schmidt' },
{ id: 7, name: 'Caroline Schultz' },
{ id: 8, name: 'Mason Heaney' },
{ id: 9, name: 'Claudie Smitham' },
{ id: 10, name: 'Emil Schaefer' },
]
}"
x-combobox
by="id"
>
<label x-combobox:label>Assigned to</label>
<input x-combobox:input :display-value="(person) => person" type="text">
<button x-combobox:button x-text="$combobox.value ? $combobox.value : 'Select People'"></button>
<ul x-combobox:options>
<template x-for="person in people" :key="person.id">
<li
:option="person.id"
x-combobox:option
:value="person.id"
:disabled="person.disabled"
:class="{
'selected': $comboboxOption.isSelected,
'active': $comboboxOption.isActive,
}"
>
<span x-text="person.name"></span>
</li>
</template>
</ul>
</div>
`],
({ get }) => {
get('ul').should(notBeVisible())
get('button').click()
get('ul').should(beVisible())
get('button').click()
get('ul').should(notBeVisible())
get('button').click()
get('[option="2"]').click()
get('ul').should(notBeVisible())
get('input').should(haveValue('2'))
get('button').should(haveText('2'))
get('button').click()
get('ul').should(contain('Wade Cooper'))
.should(contain('Arlene Mccoy'))
.should(contain('Devon Webb'))
get('[option="3"]').click()
get('ul').should(notBeVisible())
get('input').should(haveValue('3'))
get('button').should(haveText('3'))
get('button').click()
get('ul').should(contain('Wade Cooper'))
.should(contain('Arlene Mccoy'))
.should(contain('Devon Webb'))
get('[option="1"]').click()
get('ul').should(notBeVisible())
get('input').should(haveValue('1'))
get('button').should(haveText('1'))
},
);

test('"by" prop with string value and "nullable"',
[html`
<div
x-data="{
people: [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
{ id: 5, name: 'Tanya Fox', disabled: true },
{ id: 6, name: 'Hellen Schmidt' },
{ id: 7, name: 'Caroline Schultz' },
{ id: 8, name: 'Mason Heaney' },
{ id: 9, name: 'Claudie Smitham' },
{ id: 10, name: 'Emil Schaefer' },
]
}"
x-combobox
by="id"
default-value="5"
nullable
>
<label x-combobox:label>Assigned to</label>
<input x-combobox:input :display-value="(person) => person?.name" type="text">
<button x-combobox:button x-text="$combobox.value ? $combobox.value.name : 'Select People'"></button>
<ul x-combobox:options>
<template x-for="person in people" :key="person.id">
<li
:option="person.id"
x-combobox:option
:value="person"
:disabled="person.disabled"
:class="{
'selected': $comboboxOption.isSelected,
'active': $comboboxOption.isActive,
}"
>
<span x-text="person.name"></span>
</li>
</template>
</ul>
</div>
`],
({ get }) => {
get('ul').should(notBeVisible())
get('button').click()
get('ul').should(beVisible())
get('button').click()
get('ul').should(notBeVisible())
get('button').click()
get('[option="2"]').click()
get('ul').should(notBeVisible())
get('input').should(haveValue('Arlene Mccoy'))
get('button').should(haveText('Arlene Mccoy'))
get('button').click()
get('ul').should(contain('Wade Cooper'))
.should(contain('Arlene Mccoy'))
.should(contain('Devon Webb'))
get('[option="3"]').click()
get('ul').should(notBeVisible())
get('input').should(haveValue('Devon Webb'))
get('button').should(haveText('Devon Webb'))
get('button').click()
get('ul').should(contain('Wade Cooper'))
.should(contain('Arlene Mccoy'))
.should(contain('Devon Webb'))
get('[option="1"]').click()
get('ul').should(notBeVisible())
get('input').should(haveValue('Wade Cooper'))
get('button').should(haveText('Wade Cooper'))
},
);


test('keyboard controls',
[html`
<div
@@ -913,13 +1062,13 @@ test('has accessibility attributes',
.should(haveAttribute('role', 'option'))
.should(haveAttribute('id', 'alpine-combobox-option-1'))
.should(haveAttribute('tabindex', '-1'))
.should(haveAttribute('aria-selected', 'true'))
.should(haveAttribute('aria-selected', 'false'))

get('[option="2"]')
.should(haveAttribute('role', 'option'))
.should(haveAttribute('id', 'alpine-combobox-option-2'))
.should(haveAttribute('tabindex', '-1'))
.should(notHaveAttribute('aria-selected'))
.should(haveAttribute('aria-selected', 'false'))

get('input')
.should(haveAttribute('role', 'combobox'))
@@ -931,9 +1080,13 @@ test('has accessibility attributes',
.should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-1'))
.type('{downarrow}')
.should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-2'))
.type('{enter}')

get('[option="2"]')
.should(haveAttribute('aria-selected', 'true'))

get('[option="1"]')
.should(haveAttribute('aria-selected', 'false'))
},
)

@@ -1271,6 +1424,7 @@ test('active element logic when opening a combobox',
:option="person.id"
:value="person"
:disabled="person.disabled"
:class="$comboboxOption.isActive ? 'active' : ''"
x-text="person.name"
>
</li>
@@ -1287,19 +1441,120 @@ test('active element logic when opening a combobox',
get('button').click()
// First option is selected on opening if no preselection
get('ul').should(beVisible())
get('[option="1"]').should(haveAttribute('aria-selected', 'true'))
get('[option="1"]').should(haveAttribute('aria-selected', 'false'))
get('[option="1"]').should(haveClasses(['active']))
// First match is selected while typing
get('[option="4"]').should(notHaveAttribute('aria-selected'))
get('[option="4"]').should(haveAttribute('aria-selected', 'false'))
get('[option="4"]').should(notHaveClasses(['active']))
get('input').type('T')
get('input').trigger('change')
get('[option="4"]').should(haveAttribute('aria-selected', 'true'))
get('[option="4"]').should(haveAttribute('aria-selected', 'false'))
get('[option="4"]').should(haveClasses(['active']))
// Reset state and select option 3
get('button').click()
get('button').click()
get('[option="3"]').click()
// Previous selection is selected
get('button').click()
get('[option="4"]').should(notHaveAttribute('aria-selected'))
get('[option="4"]').should(haveAttribute('aria-selected', 'false'))
get('[option="3"]').should(haveAttribute('aria-selected', 'true'))
}
)

test('can remove an option without other options getting removed',
[html`<div
x-data="{
query: '',
selected: [],
frameworks: [
{
id: 1,
name: 'Laravel',
disabled: false,
},
{
id: 2,
name: 'Ruby on Rails',
disabled: false,
},
{
id: 3,
name: 'Django',
disabled: false,
},
],
get filteredFrameworks() {
return this.query === ''
? this.frameworks
: this.frameworks.filter((framework) => {
return framework.name.toLowerCase().includes(this.query.toLowerCase())
})
},
remove(framework) {
this.selected = this.selected.filter((i) => i !== framework)
}
}"
>
<div x-combobox x-model="selected" by="id" multiple>
<div x-show="selected.length">
<template x-for="selectedFramework in selected" :key="selectedFramework.id">
<button x-on:click.prevent="remove(selectedFramework)" :remove-option="selectedFramework.id">
<span x-text="selectedFramework.name"></span>
</button>
</template>
</div>
<div>
<div>
<input
x-combobox:input
@change="query = $event.target.value;"
placeholder="Search..."
/>
<button x-combobox:button>
Show options
</button>
</div>
<div x-combobox:options x-cloak x-transition.out.opacity>
<ul>
<template
x-for="framework in filteredFrameworks"
:key="framework.id"
hidden
>
<li
x-combobox:option
:option="framework.id"
:value="framework"
:disabled="framework.disabled"
>
<span x-text="framework.name"></span>
<span x-show="$comboboxOption.isSelected" :check="framework.id">&check;</span>
</li>
</template>
</ul>
<p x-show="filteredFrameworks.length == 0">No frameworks match your query.</p>
</div>
</div>
</div>
</div>
`],
({ get }) => {
get('input').type('a').trigger('input')
cy.wait(100)
get('[option="1"]').click()
get('[option="2"]').click()
get('[option="3"]').click()
get('[remove-option="3"]').click()
get('[option="1"]').should(haveAttribute('aria-selected', 'true'))
get('[option="2"]').should(haveAttribute('aria-selected', 'true'))
get('[option="3"]').should(haveAttribute('aria-selected', 'false'))
get('input').type('a').trigger('input')
get('[check="1"]').should(beVisible())
get('[check="2"]').should(beVisible())
get('[check="3"]').should(notBeVisible())
},
);
1 change: 1 addition & 0 deletions tests/cypress/spec.html
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@
<script src="/../../packages/focus/dist/cdn.js"></script>
<script src="/../../packages/intersect/dist/cdn.js"></script>
<script src="/../../packages/collapse/dist/cdn.js"></script>
<script src="/../../packages/anchor/dist/cdn.js"></script>
<script src="/../../packages/mask/dist/cdn.js"></script>
<script src="/../../packages/ui/dist/cdn.js"></script>
<script>