## Contribution Guide:
@@ -47,12 +47,10 @@ Each package should at least have: a "cdn" build that is self-initializing and c
The bundling for Alpine V3 is handled exclusively by ESBuild. All of the configuration for these builds is stored in the `scripts/build.js` file.
### Testing
-There are 2 different testing tools used in this repo: Cypress (for integration tests), and Jest (for unit tests).
+There are 2 different testing tools used in this repo: Cypress (for integration tests), and Vitest (for unit tests).
-All tests are stored inside the `/tests` folder under `/tests/cypress` and `/tests/jest`.
+All tests are stored inside the `/tests` folder under `/tests/cypress` and `/tests/vitest`.
-You can run them both from the command line using: `npm run test`
+If you wish to only run Cypress and open it's user interface (recommended during development), you can run: `npm run cypress`
-If you wish to only run cypress and open it's user interface (recommended during development), you can run: `npm run cypress`
-
-If you wish to only run Jest tests, you can run `npm run jest` like normal and target specific tests. You can specify command line config options to forward to the jest command with `--` like so: `npm run jest -- --watch`
+If you wish to only run Vitest tests, you can run `npm run vitest` like normal and target specific tests.
diff --git a/benchmarks/giant.html b/benchmarks/giant.html
index 6007f565e..115e95a08 100644
--- a/benchmarks/giant.html
+++ b/benchmarks/giant.html
@@ -1689,7 +1689,7 @@
Fork livewire
Comparing changes
-
Choose two branches to see what’s changed or to start a
+
Choose two branches to see what's changed or to start a
new pull request.
If you need to, you can also .
@@ -4749,11 +4749,11 @@
@@ -38,6 +35,7 @@
-
+
+
+```
+
+### Via NPM
+
+You can alternatively install this build from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/csp
```
-
-### Module import
+Then initialize it from your bundle:
```js
import Alpine from '@alpinejs/csp'
window.Alpine = Alpine
-window.Alpine.start()
+
+Alpine.start()
```
-
-## Restrictions
+
+## Basic Example
-Since Alpine can no longer interpret strings as plain JavaScript, it has to parse and construct JavaScript functions from them manually.
+Here's a working counter component using Alpine's CSP build. Notice how most expressions work exactly like regular Alpine:
-Due to this limitation, you must use `Alpine.data` to register your `x-data` objects, and must reference properties and methods from it by key only.
+```alpine
+
+
+
+
+
+
+
+
+
+
+
+
+ Count is greater than 5!
+
+
+
+```
-For example, an inline component like this will not work.
+
+## What's Supported
+The CSP build supports most JavaScript expressions you'd want to use in Alpine:
+
+### Object and Array Literals
```alpine
-
-
-
+
+
+
+
+
+```
-
+### Basic Operations
+```alpine
+
+
+
+
+
+
+
```
-However, breaking out the expressions into external APIs, the following is valid with the CSP build:
+### Assignments and Updates
+```alpine
+
+
+
+
+
+
+```
+### Method Calls
```alpine
-
-
-
+
+
+
+
+
+```
-
+### Global Variables and Functions
+```alpine
+
+
+```
+
+
+## When to Extract Logic
+
+While the CSP build supports simple inline expressions, you'll want to extract complex logic into dedicated functions or Alpine.data() components for better organization:
+
+```alpine
+
+
+```
+
+```alpine
+
+
+
+
```
+
+This approach makes your code more readable, testable, and maintainable, especially for complex applications.
+
+
+## CSP Headers
+
+Here's an example CSP header that works with Alpine's CSP build:
+
+```
+Content-Security-Policy: default-src 'self'; script-src 'nonce-[random]' 'strict-dynamic';
+```
+
+The key is removing `'unsafe-eval'` from your `script-src` directive while still allowing your nonce-based scripts to run.
\ No newline at end of file
diff --git a/packages/docs/src/en/advanced/extending.md b/packages/docs/src/en/advanced/extending.md
index d75948c90..d90896204 100644
--- a/packages/docs/src/en/advanced/extending.md
+++ b/packages/docs/src/en/advanced/extending.md
@@ -1,5 +1,5 @@
---
-order: 2
+order: 3
title: Extending
---
@@ -224,6 +224,24 @@ Alpine.directive('...', (el, {}, { cleanup }) => {
Now if the directive is removed from this element or the element is removed itself, the event listener will be removed as well.
+
+### Custom order
+
+By default, any new directive will run after the majority of the standard ones (with the exception of `x-teleport`). This is usually acceptable but some times you might need to run your custom directive before another specific one.
+This can be achieved by chaining the `.before() function to `Alpine.directive()` and specifying which directive needs to run after your custom one.
+
+```js
+Alpine.directive('foo', (el, { value, modifiers, expression }) => {
+ Alpine.addScopeToNode(el, {foo: 'bar'})
+}).before('bind')
+```
+```alpine
+
+
+
+```
+> Note, the directive name must be written without the `x-` prefix (or any other custom prefix you may use).
+
## Custom magics
diff --git a/packages/docs/src/en/advanced/reactivity.md b/packages/docs/src/en/advanced/reactivity.md
index ddbb601fd..1650953cd 100644
--- a/packages/docs/src/en/advanced/reactivity.md
+++ b/packages/docs/src/en/advanced/reactivity.md
@@ -1,5 +1,5 @@
---
-order: 1
+order: 2
title: Reactivity
---
diff --git a/packages/docs/src/en/directives/bind.md b/packages/docs/src/en/directives/bind.md
index 357e0252b..9bcaa4f9e 100644
--- a/packages/docs/src/en/directives/bind.md
+++ b/packages/docs/src/en/directives/bind.md
@@ -11,7 +11,7 @@ For example, here's a component where we will use `x-bind` to set the placeholde
```alpine
-
+
```
@@ -24,6 +24,8 @@ If `x-bind:` is too verbose for your liking, you can use the shorthand: `:`. For
```
+> Despite not being included in the above snippet, `x-bind` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
+
## Binding classes
@@ -33,11 +35,11 @@ Here's a simple example of a simple dropdown toggle, but instead of using `x-sho
```alpine
-
+
-
- Dropdown Contents...
-
+
+ Dropdown Contents...
+
```
@@ -162,7 +164,7 @@ And like most expressions in Alpine, you can always use the result of a JavaScri
The object keys can be anything you would normally write as an attribute name in Alpine. This includes Alpine directives and modifiers, but also plain HTML attributes. The object values are either plain strings, or in the case of dynamic Alpine directives, callbacks to be evaluated by Alpine.
```alpine
-
+
Dropdown Contents
diff --git a/packages/docs/src/en/directives/cloak.md b/packages/docs/src/en/directives/cloak.md
index e7616a8b3..3aa00ee84 100644
--- a/packages/docs/src/en/directives/cloak.md
+++ b/packages/docs/src/en/directives/cloak.md
@@ -15,7 +15,13 @@ For `x-cloak` to work however, you must add the following CSS to the page.
[x-cloak] { display: none !important; }
```
-Now, the following example will hide the `` tag until Alpine has set its text content to the `message` property.
+The following example will hide the `` tag until its `x-show` is specifically set to true, preventing any "blip" of the hidden element onto screen as Alpine loads.
+
+```alpine
+This will not 'blip' onto screen at any point
+```
+
+`x-cloak` doesn't just work on elements hidden by `x-show` or `x-if`: it also ensures that elements containing data are hidden until the data is correctly set. The following example will hide the `` tag until Alpine has set its text content to the `message` property.
```alpine
@@ -23,6 +29,8 @@ Now, the following example will hide the `` tag until Alpine has set its t
When Alpine loads on the page, it removes all `x-cloak` property from the element, which also removes the `display: none;` applied by CSS, therefore showing the element.
+## Alternative to global syntax
+
If you'd like to achieve this same behavior, but avoid having to include a global style, you can use the following cool, but admittedly odd trick:
```alpine
diff --git a/packages/docs/src/en/directives/data.md b/packages/docs/src/en/directives/data.md
index 7705370df..45fbacdb2 100644
--- a/packages/docs/src/en/directives/data.md
+++ b/packages/docs/src/en/directives/data.md
@@ -86,9 +86,9 @@ Let's refactor our component to use a getter called `isOpen` instead of accessin
```alpine
diff --git a/packages/docs/src/en/directives/for.md b/packages/docs/src/en/directives/for.md
index 31c423205..97df4d23c 100644
--- a/packages/docs/src/en/directives/for.md
+++ b/packages/docs/src/en/directives/for.md
@@ -25,10 +25,34 @@ Alpine's `x-for` directive allows you to create DOM elements by iterating throug
+You may also pass objects to `x-for`.
+
+```alpine
+
+
+
+ :
+
+
+
+```
+
+
+
+
+
+
+ :
+
+
+
+
+
+
There are two rules worth noting about `x-for`:
-* `x-for` MUST be declared on a `` element
-* That `` element MUST have only one root element
+> `x-for` MUST be declared on a `` element.
+> That `` element MUST contain only one root element
## Keys
@@ -85,3 +109,27 @@ If you need to simply loop `n` number of times, rather than iterate through an a
```
`i` in this case can be named anything you like.
+
+> Despite not being included in the above snippet, `x-for` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
+
+
+## Contents of a ``
+
+As mentioned above, an `` tag must contain only one root element.
+
+For example, the following code will not work:
+
+```alpine
+
+ The next color is
+
+```
+
+but this code will work:
+```alpine
+
+
+ The next color is
+
+
+```
diff --git a/packages/docs/src/en/directives/id.md b/packages/docs/src/en/directives/id.md
index b1faedd50..df86aa957 100644
--- a/packages/docs/src/en/directives/id.md
+++ b/packages/docs/src/en/directives/id.md
@@ -4,6 +4,7 @@ title: id
---
# x-id
+
`x-id` allows you to declare a new "scope" for any new IDs generated using `$id()`. It accepts an array of strings (ID names) and adds a suffix to each `$id('...')` generated within it that is unique to other IDs on the page.
`x-id` is meant to be used in conjunction with the `$id(...)` magic.
@@ -30,4 +31,4 @@ Here's a brief example of this directive in use:
```
-
+> Despite not being included in the above snippet, `x-id` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
diff --git a/packages/docs/src/en/directives/if.md b/packages/docs/src/en/directives/if.md
index b8300f330..03429923d 100644
--- a/packages/docs/src/en/directives/if.md
+++ b/packages/docs/src/en/directives/if.md
@@ -15,6 +15,10 @@ Because of this difference in behavior, `x-if` should not be applied directly to
```
-> Unlike `x-show`, `x-if`, does NOT support transitioning toggles with `x-transition`.
+> Despite not being included in the above snippet, `x-if` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
-> Remember: `` tags can only contain one root level element.
+## Caveats
+
+Unlike `x-show`, `x-if`, does NOT support transitioning toggles with `x-transition`.
+
+`` tags can only contain one root element.
diff --git a/packages/docs/src/en/directives/init.md b/packages/docs/src/en/directives/init.md
index 9e4d2eb87..2a3983a15 100644
--- a/packages/docs/src/en/directives/init.md
+++ b/packages/docs/src/en/directives/init.md
@@ -72,3 +72,18 @@ Alpine.data('dropdown', () => ({
},
}))
```
+
+If you have both an `x-data` object containing an `init()` method and an `x-init` directive, the `x-data` method will be called before the directive.
+
+```alpine
+
+ ...
+
+```
diff --git a/packages/docs/src/en/directives/model.md b/packages/docs/src/en/directives/model.md
index 8b11def6a..4c75276f0 100644
--- a/packages/docs/src/en/directives/model.md
+++ b/packages/docs/src/en/directives/model.md
@@ -13,7 +13,7 @@ Here's a simple example of using `x-model` to bind the value of a text field to
-
+
```
@@ -62,6 +62,7 @@ Now when the `
+> Despite not being included in the above snippet, `x-model` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
+
## Textarea inputs
@@ -282,6 +285,26 @@ Color:
+
+## Range inputs
+
+```alpine
+
+
+
+```
+
+
+
+
+
+
+
+
+
+
+
+
## Modifiers
@@ -307,6 +330,19 @@ By default, any data stored in a property via `x-model` is stored as a string. T
```
+
+### `.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
+
+
+```
+
### `.debounce`
@@ -337,6 +373,17 @@ The default throttle interval is 250 milliseconds, you can easily customize this
```
+
+### `.fill`
+
+By default, if an input has a value attribute, it is ignored by Alpine and instead, the value of the input is set to the value of the property bound using `x-model`.
+
+But if a bound property is empty, then you can use an input's value attribute to populate the property by adding the `.fill` modifier.
+
+
+
+
+
## Programmatic access
diff --git a/packages/docs/src/en/directives/on.md b/packages/docs/src/en/directives/on.md
index cc1e9a6b3..7ccf7a0ec 100644
--- a/packages/docs/src/en/directives/on.md
+++ b/packages/docs/src/en/directives/on.md
@@ -13,7 +13,7 @@ Here's an example of simple button that shows an alert when clicked.
Say Hi
```
-> `x-on` can only listen for events with lower case names, as HTML attributes are case-insensitive. Writing `x-on:CLICK` will listen for an event named `click`. If you need to listen for a custom event with a camelCase name, you can use the [`.camel` helper](#camel) to work around this limitation. Alternatively, you can use [`x-bind`](/directives/bind#bind-directives) to attach an `x-on` directive to an element in javascript code (where case will be preserved).
+> `x-on` can only listen for events with lower case names, as HTML attributes are case-insensitive. Writing `x-on:CLICK` will listen for an event named `click`. If you need to listen for a custom event with a camelCase name, you can use the [`.camel` helper](#camel) to work around this limitation. Alternatively, you can use [`x-bind`](/directives/bind#bind-directives) to attach an `x-on` directive to an element in javascript code (where case will be preserved).
## Shorthand syntax
@@ -26,6 +26,8 @@ Here's the same component as above, but using the shorthand syntax instead:
Say Hi
```
+> Despite not being included in the above snippet, `x-on` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
+
## The event object
@@ -74,22 +76,64 @@ You can directly use any valid key names exposed via [`KeyboardEvent.key`](https
For easy reference, here is a list of common keys you may want to listen for.
-| Modifier | Keyboard Key |
-| -------------------------- | --------------------------- |
-| `.shift` | Shift |
-| `.enter` | Enter |
-| `.space` | Space |
-| `.ctrl` | Ctrl |
-| `.cmd` | Cmd |
-| `.meta` | Cmd on Mac, Ctrl on Windows |
-| `.alt` | Alt |
-| `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows |
-| `.escape` | Escape |
-| `.tab` | Tab |
-| `.caps-lock` | Caps Lock |
-| `.equal` | Equal, `=` |
-| `.period` | Period, `.` |
-| `.slash` | Foward Slash, `/` |
+| Modifier | Keyboard Key |
+| ------------------------------ | ---------------------------------- |
+| `.shift` | Shift |
+| `.enter` | Enter |
+| `.space` | Space |
+| `.ctrl` | Ctrl |
+| `.cmd` | Cmd |
+| `.meta` | Cmd on Mac, Windows key on Windows |
+| `.alt` | Alt |
+| `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows |
+| `.escape` | Escape |
+| `.tab` | Tab |
+| `.caps-lock` | Caps Lock |
+| `.equal` | Equal, `=` |
+| `.period` | Period, `.` |
+| `.comma` | Comma, `,` |
+| `.slash` | Forward Slash, `/` |
+
+
+## Mouse events
+
+Like the above Keyboard Events, Alpine allows the use of some key modifiers for handling `click` events.
+
+| Modifier | Event Key |
+| -------- | --------- |
+| `.shift` | shiftKey |
+| `.ctrl` | ctrlKey |
+| `.cmd` | metaKey |
+| `.meta` | metaKey |
+| `.alt` | altKey |
+
+These work on `click`, `auxclick`, `context` and `dblclick` events, and even `mouseover`, `mousemove`, `mouseenter`, `mouseleave`, `mouseout`, `mouseup` and `mousedown`.
+
+Here's an example of a button that changes behaviour when the `Shift` key is held down.
+
+```alpine
+
+ @mousemove.shift="message = 'add to selection'"
+ @mouseout="message = 'select'"
+ x-text="message">
+```
+
+
+
+
+
+
+
+
+
+> Note: Normal click events with some modifiers (like `ctrl`) will automatically become `contextmenu` events in most browsers. Similarly, `right-click` events will trigger a `contextmenu` event, but will also trigger an `auxclick` event if the `contextmenu` event is prevented.
## Custom events
@@ -100,7 +144,7 @@ Here's an example of a component that dispatches a custom DOM event and listens
```alpine
- ...
+ ...
```
@@ -112,7 +156,7 @@ Here's the same component re-written with the `$dispatch` magic property.
```alpine
- ...
+ ...
```
@@ -298,3 +342,15 @@ If you are listening for touch events, it's important to add `.passive` to your
```
[→ Read more about passive listeners](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners)
+
+### .capture
+
+Add this modifier if you want to execute this listener in the event's capturing phase, e.g. before the event bubbles from the target element up the DOM.
+
+```alpine
+
+
+
+```
+
+[→ Read more about the capturing and bubbling phase of events](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture)
diff --git a/packages/docs/src/en/directives/ref.md b/packages/docs/src/en/directives/ref.md
index 075307614..9c70d2f99 100644
--- a/packages/docs/src/en/directives/ref.md
+++ b/packages/docs/src/en/directives/ref.md
@@ -22,3 +22,5 @@ title: ref
+
+> Despite not being included in the above snippet, `x-ref` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
diff --git a/packages/docs/src/en/directives/show.md b/packages/docs/src/en/directives/show.md
index 988a16f8a..cd28fcf97 100644
--- a/packages/docs/src/en/directives/show.md
+++ b/packages/docs/src/en/directives/show.md
@@ -37,3 +37,20 @@ If you want to apply smooth transitions to the `x-show` behavior, you can use it
```
+
+
+## Using the important modifier
+
+Sometimes you need to apply a little more force to actually hide an element. In cases where a CSS selector applies the `display` property with the `!important` flag, it will take precedence over the inline style set by Alpine.
+
+In these cases you may use the `.important` modifier to set the inline style to `display: none !important`.
+
+```alpine
+
+ Toggle Dropdown
+
+
+ Dropdown Contents...
+
+
+```
diff --git a/packages/docs/src/en/directives/teleport.md b/packages/docs/src/en/directives/teleport.md
index b550c7a45..37321810d 100644
--- a/packages/docs/src/en/directives/teleport.md
+++ b/packages/docs/src/en/directives/teleport.md
@@ -11,8 +11,6 @@ The `x-teleport` directive allows you to transport part of your Alpine template
This is useful for things like modals (especially nesting them), where it's helpful to break out of the z-index of the current Alpine component.
-> Warning: if you are a [Livewire](https://laravel-livewire.com) user, this functionality does not currently work inside Livewire components. Support for this is on the roadmap.
-
## x-teleport
@@ -115,7 +113,7 @@ Teleporting is especially helpful if you are trying to nest one modal within ano
Modal contents...
-
+
Toggle Nested Modal
@@ -138,7 +136,7 @@ Teleporting is especially helpful if you are trying to nest one modal within ano
Modal contents...
-
+
Toggle Nested Modal
diff --git a/packages/docs/src/en/directives/transition.md b/packages/docs/src/en/directives/transition.md
index 1c2dcfbab..fcddb7a76 100644
--- a/packages/docs/src/en/directives/transition.md
+++ b/packages/docs/src/en/directives/transition.md
@@ -21,9 +21,9 @@ The simplest way to achieve a transition using Alpine is by adding `x-transition
Toggle
-
+
Hello 👋
-
+
```
@@ -32,9 +32,9 @@ The simplest way to achieve a transition using Alpine is by adding `x-transition
Toggle
-
+
Hello 👋
-
+
@@ -65,6 +65,8 @@ If you wish to customize the durations specifically for entering and leaving, yo
>
```
+> Despite not being included in the above snippet, `x-transition` cannot be used if no parent element has `x-data` defined. [→ Read more about `x-data`](/directives/data)
+
### Customizing delay
diff --git a/packages/docs/src/en/essentials/installation.md b/packages/docs/src/en/essentials/installation.md
index bee805db2..1ab0fed97 100644
--- a/packages/docs/src/en/essentials/installation.md
+++ b/packages/docs/src/en/essentials/installation.md
@@ -19,12 +19,12 @@ This is by far the simplest way to get started with Alpine. Include the followin
```alpine
-
- ...
+
+ ...
-
-
- ...
+
+
+ ...
```
@@ -33,11 +33,13 @@ 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
-
+
```
That's it! Alpine is now available for use inside your page.
+Note that you will still need to define a component with `x-data` in order for any Alpine.js attributes to work. See for more information.
+
## As a module
@@ -61,8 +63,9 @@ Alpine.start()
> The `window.Alpine = Alpine` bit is optional, but is nice to have for freedom and flexibility. Like when tinkering with Alpine from the devtools for example.
-
> If you imported Alpine into a bundle, you have to make sure you are registering any extension code IN BETWEEN when you import the `Alpine` global object, and when you initialize Alpine by calling `Alpine.start()`.
+> Ensure that `Alpine.start()` is only called once per page. Calling it more than once will result in multiple "instances" of Alpine running at the same time.
+
[→ Read more about extending Alpine](/advanced/extending)
diff --git a/packages/docs/src/en/essentials/lifecycle.md b/packages/docs/src/en/essentials/lifecycle.md
index 7a6d32151..f3f9297ae 100644
--- a/packages/docs/src/en/essentials/lifecycle.md
+++ b/packages/docs/src/en/essentials/lifecycle.md
@@ -87,7 +87,7 @@ document.addEventListener('alpine:init', () => {
### `alpine:initialized`
-Alpine also offers a hook that you can use to execute code After it's done initializing called `alpine:initialized`:
+Alpine also offers a hook that you can use to execute code AFTER it's done initializing called `alpine:initialized`:
```js
document.addEventListener('alpine:initialized', () => {
diff --git a/packages/docs/src/en/globals/alpine-data.md b/packages/docs/src/en/globals/alpine-data.md
index 51e40a6d9..cf37e78f2 100644
--- a/packages/docs/src/en/globals/alpine-data.md
+++ b/packages/docs/src/en/globals/alpine-data.md
@@ -37,7 +37,7 @@ As you can see we've extracted the properties and methods we would usually defin
If you've chosen to use a build step for your Alpine code, you should register your components in the following way:
```js
-import Alpine from `alpinejs`
+import Alpine from 'alpinejs'
import dropdown from './dropdown.js'
Alpine.data('dropdown', dropdown)
@@ -87,6 +87,43 @@ Alpine.data('dropdown', () => ({
}))
```
+
+## Destroy functions
+
+If your component contains a `destroy()` method, Alpine will automatically execute it before cleaning up the component.
+
+A primary example for this is when registering an event handler with another library or a browser API that isn't available through Alpine.
+See the following example code on how to use the `destroy()` method to clean up such a handler.
+
+```js
+Alpine.data('timer', () => ({
+ timer: null,
+ counter: 0,
+ init() {
+ // Register an event handler that references the component instance
+ this.timer = setInterval(() => {
+ console.log('Increased counter to', ++this.counter);
+ }, 1000);
+ },
+ destroy() {
+ // Detach the handler, avoiding memory and side-effect leakage
+ clearInterval(this.timer);
+ },
+}))
+```
+
+An example where a component is destroyed is when using one inside an `x-if`:
+
+```html
+
+ Toggle
+
+
+
+
+
+```
+
## Using magic properties
diff --git a/packages/docs/src/en/magics/dispatch.md b/packages/docs/src/en/magics/dispatch.md
index 376b70f6c..e43f5ba04 100644
--- a/packages/docs/src/en/magics/dispatch.md
+++ b/packages/docs/src/en/magics/dispatch.md
@@ -66,7 +66,7 @@ Notice that, because of [event bubbling](https://en.wikipedia.org/wiki/Event_bub
```
-> The first example won't work because when `custom-event` is dispatched, it'll propagate to its common ancestor, the `div`, not its sibling, the ``. The second example will work because the sibling is listening for `notify` at the `window` level, which the custom event will eventually bubble up to.
+> The first example won't work because when `notify` is dispatched, it'll propagate to its common ancestor, the `div`, not its sibling, the ``. The second example will work because the sibling is listening for `notify` at the `window` level, which the custom event will eventually bubble up to.
## Dispatching to other components
diff --git a/packages/docs/src/en/magics/refs.md b/packages/docs/src/en/magics/refs.md
index 6c42f5e2d..acfc2446e 100644
--- a/packages/docs/src/en/magics/refs.md
+++ b/packages/docs/src/en/magics/refs.md
@@ -25,3 +25,18 @@ title: refs
Now, when the `` is pressed, the `` will be removed.
+
+
+### Limitations
+
+In V2 it was possible to bind `$refs` to elements dynamically, like seen below:
+
+```alpine
+
+
+ some content ...
+
+
+```
+
+However, in V3, `$refs` can only be accessed for elements that are created statically. So for the example above: if you were expecting the value of `item.name` inside of `$refs` to be something like *Batteries*, you should be aware that `$refs` will actually contain the literal string `'item.name'` and not *Batteries*.
diff --git a/packages/docs/src/en/magics/watch.md b/packages/docs/src/en/magics/watch.md
index e70886e87..3fc402a64 100644
--- a/packages/docs/src/en/magics/watch.md
+++ b/packages/docs/src/en/magics/watch.md
@@ -39,7 +39,7 @@ When the `` is pressed, `foo.bar` will be set to "bob", and "bob" will b
### Deep watching
-`$watch` will automatically watches from changes at any level but you should keep in mind that, when a change is detected, the watcher will return the value of the observed property, not the value of the subproperty that has changed.
+`$watch` automatically watches from changes at any level but you should keep in mind that, when a change is detected, the watcher will return the value of the observed property, not the value of the subproperty that has changed.
```alpine
diff --git a/packages/docs/src/en/plugins/anchor.md b/packages/docs/src/en/plugins/anchor.md
new file mode 100644
index 000000000..b7c3b94c7
--- /dev/null
+++ b/packages/docs/src/en/plugins/anchor.md
@@ -0,0 +1,213 @@
+---
+order: 7
+title: Anchor
+description: Anchor an element's positioning to another element on the page
+graph_image: https://alpinejs.dev/social_anchor.jpg
+---
+
+# Anchor Plugin
+
+Alpine's Anchor plugin allows you to 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.
+
+
+## Installation
+
+You can use this plugin by either including it from a `
+
+
+
+```
+
+### 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)
+
+...
+```
+
+
+## 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 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
+
+ Toggle
+
+
+ Dropdown content
+
+
+```
+
+
+
+
+ Toggle
+
+
+
+ Dropdown content
+
+
+
+
+
+## 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
+
+ Toggle
+
+
+ Dropdown content
+
+
+```
+
+
+
+
+ Toggle
+
+
+
+ Dropdown content
+
+
+
+
+
+## Offset
+
+You can add an offset to your anchored element using the `.offset.[px value]` modifier like so:
+
+```alpine
+
+ Toggle
+
+
+ Dropdown content
+
+
+```
+
+
+
+
+ Toggle
+
+
+
+ Dropdown content
+
+
+
+
+
+## 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
+
+ Toggle
+
+
+ Dropdown content
+
+
+```
+
+
+
+
+ Toggle
+
+
+
+ Dropdown content
+
+
+
+
+
+## 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
+
+ Toggle
+
+
+ Dropdown content
+
+
+```
+
+
+
+
+ Toggle
+
+
+
+
+ Dropdown content
+
+
+
+
diff --git a/packages/docs/src/en/plugins/collapse.md b/packages/docs/src/en/plugins/collapse.md
index 7d6047f14..063caec54 100644
--- a/packages/docs/src/en/plugins/collapse.md
+++ b/packages/docs/src/en/plugins/collapse.md
@@ -1,5 +1,5 @@
---
-order: 4
+order: 6
title: Collapse
description: Collapse and expand elements with robust animations
graph_image: https://alpinejs.dev/social_collapse.jpg
@@ -22,10 +22,10 @@ You can include the CDN build of this plugin as a `
+
-
+
```
### Via NPM
diff --git a/packages/docs/src/en/plugins/focus.md b/packages/docs/src/en/plugins/focus.md
index 694688967..69c11ee6f 100644
--- a/packages/docs/src/en/plugins/focus.md
+++ b/packages/docs/src/en/plugins/focus.md
@@ -1,5 +1,5 @@
---
-order: 3
+order: 5
title: Focus
description: Easily manage focus within the page
graph_image: https://alpinejs.dev/social_focus.jpg
@@ -24,10 +24,10 @@ You can include the CDN build of this plugin as a `
+
-
+
```
### Via NPM
@@ -228,7 +228,7 @@ For example:
```alpine
- Open Dialog
+ Open Dialog
Dialog Contents
@@ -301,6 +301,13 @@ For example:
+
+#### .noautofocus
+
+By default, when `x-trap` traps focus within an element, it focuses the first focussable element within that element. This is a sensible default, however there are times where you may want to disable this behavior and not automatically focus any elements when `x-trap` engages.
+
+By adding `.noautofocus`, Alpine will not automatically focus any elements when trapping focus.
+
## $focus
@@ -309,7 +316,7 @@ This plugin offers many smaller utilities for managing focus within a page. Thes
| Property | Description |
| --- | --- |
| `focus(el)` | Focus the passed element (handling annoyances internally: using nextTick, etc.) |
-| `focusable(el)` | Detect weather or not an element is focusable |
+| `focusable(el)` | Detect whether or not an element is focusable |
| `focusables()` | Get all "focusable" elements within the current element |
| `focused()` | Get the currently focused element on the page |
| `lastFocused()` | Get the last focused element on the page |
diff --git a/packages/docs/src/en/plugins/intersect.md b/packages/docs/src/en/plugins/intersect.md
index b1cb7d3a2..3176e549f 100644
--- a/packages/docs/src/en/plugins/intersect.md
+++ b/packages/docs/src/en/plugins/intersect.md
@@ -22,10 +22,10 @@ You can include the CDN build of this plugin as a `
+
-
+
```
### Via NPM
@@ -89,11 +89,14 @@ You may choose to use this for clarity when also using the `:leave` suffix.
### x-intersect:leave
-Appending `:leave` runs your expression when the element leaves the viewport:
+Appending `:leave` runs your expression when the element leaves the viewport.
```alpine
...
```
+> By default, this means the *whole element* is not in the viewport. Use `x-intersect:leave.full` to run your expression when only *parts of the element* are not in the viewport.
+
+[→ Read more about the underlying `IntersectionObserver` API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
## Modifiers
@@ -152,7 +155,7 @@ If you wanted to trigger only when 5% of the element has entered the viewport, y
Allows you to control the `rootMargin` property of the underlying `IntersectionObserver`.
This effectively tweaks the size of the viewport boundary. Positive values
expand the boundary beyond the viewport, and negative values shrink it inward. The values
-work like CSS margin: one value for all sides, two values for top/bottom, left/right, or
+work like CSS margin: one value for all sides; two values for top/bottom, left/right; or
four values for top, right, bottom, left. You can use `px` and `%` values, or use a bare number to
get a pixel value.
diff --git a/packages/docs/src/en/plugins/mask.md b/packages/docs/src/en/plugins/mask.md
index 4c5d713e0..41e11586a 100644
--- a/packages/docs/src/en/plugins/mask.md
+++ b/packages/docs/src/en/plugins/mask.md
@@ -12,6 +12,7 @@ Alpine's Mask plugin allows you to automatically format a text input field as a
This is useful for many different types of inputs: phone numbers, credit cards, dollar amounts, account numbers, dates, etc.
+
## Installation
@@ -27,10 +28,10 @@ You can include the CDN build of this plugin as a `
+
-
+
```
### Via NPM
@@ -57,9 +58,10 @@ Alpine.plugin(mask)
Show↓
-
+
+
## x-mask
The primary API for using this plugin is the `x-mask` directive.
@@ -80,13 +82,14 @@ Notice how the text you type into the input field must adhere to the format prov
The following wildcard characters are supported in masks:
-| Wildcard | Description |
-| -------------------------- | --------------------------- |
-| `*` | Any character |
-| `a` | Only alpha characters (a-z, A-Z) |
-| `9` | Only numeric characters (0-9) |
+| Wildcard | Description |
+| -------- | -------------------------------- |
+| `*` | Any character |
+| `a` | Only alpha characters (a-z, A-Z) |
+| `9` | Only numeric characters (0-9) |
+
## Dynamic Masks
Sometimes simple mask literals (i.e. `(999) 999-9999`) are not sufficient. In these cases, `x-mask:dynamic` allows you to dynamically generate masks on the fly based on user input.
@@ -113,7 +116,7 @@ Try it for yourself by typing a number that starts with "34" and one that doesn'
-`x-mask:dynamic` also accepts a function as a result of the expression and will automatically pass it the `$input` as the the first paramter. For example:
+`x-mask:dynamic` also accepts a function as a result of the expression and will automatically pass it the `$input` as the first parameter. For example:
```alpine
@@ -128,6 +131,7 @@ function creditCardMask(input) {
```
+
## Money Inputs
Because writing your own dynamic mask expression for money inputs is fairly complex, Alpine offers a prebuilt one and makes it available as `$money()`.
@@ -155,3 +159,28 @@ If you wish to swap the periods for commas and vice versa (as is required in cer
+
+You may also choose to override the thousands separator by supplying a third optional argument:
+
+```alpine
+
+```
+
+
+
+
+
+
+
+
+You can also override the default precision of 2 digits by using any desired number of digits as the fourth optional argument:
+
+```alpine
+
+```
+
+
+
+
+
+
diff --git a/packages/docs/src/en/plugins/morph.md b/packages/docs/src/en/plugins/morph.md
index 2ff023291..108158e65 100644
--- a/packages/docs/src/en/plugins/morph.md
+++ b/packages/docs/src/en/plugins/morph.md
@@ -1,5 +1,5 @@
---
-order: 5
+order: 8
title: Morph
description: Morph an element into the provided HTML
graph_image: https://alpinejs.dev/social_morph.jpg
@@ -9,7 +9,7 @@ graph_image: https://alpinejs.dev/social_morph.jpg
Alpine's Morph plugin allows you to "morph" an element on the page into the provided HTML template, all while preserving any browser or Alpine state within the "morphed" element.
-This is useful for updating HTML from a server request without loosing Alpine's on-page state. A utility like this is at the core of full-stack frameworks like [Laravel Livewire](https://laravel-livewire.com/) and [Phoenix LiveView](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript).
+This is useful for updating HTML from a server request without losing Alpine's on-page state. A utility like this is at the core of full-stack frameworks like [Laravel Livewire](https://laravel-livewire.com/) and [Phoenix LiveView](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript).
The best way to understand its purpose is with the following interactive visualization. Give it a try!
@@ -41,10 +41,10 @@ You can include the CDN build of this plugin as a `
+
-
+
```
### Via NPM
@@ -250,3 +250,15 @@ By adding keys to each node, we can accomplish this like so:
Now that there are "keys" on the `
`s, Morph will match them in both trees and move them accordingly.
You can configure what Morph considers a "key" with the `key:` configuration option. [More on that here](#lifecycle-hooks)
+
+
+## Alpine.morphBetween()
+
+The `Alpine.morphBetween(startMarker, endMarker, newHtml, options)` method allows you to morph a range of DOM nodes between two marker elements based on passed in HTML. This is useful when you want to update only a specific section of the DOM without providing a single root node.
+
+| Parameter | Description |
+| --- | --- |
+| `startMarker` | A DOM node (typically a comment node) that marks the beginning of the range to morph |
+| `endMarker` | A DOM node (typically a comment node) that marks the end of the range to morph |
+| `newHtml` | A string of HTML or a DOM element to replace the content between the markers |
+| `options` | An object of options (same as `Alpine.morph()`) |
diff --git a/packages/docs/src/en/plugins/persist.md b/packages/docs/src/en/plugins/persist.md
index d08bd76a8..4c6fb665a 100644
--- a/packages/docs/src/en/plugins/persist.md
+++ b/packages/docs/src/en/plugins/persist.md
@@ -1,5 +1,5 @@
---
-order: 2
+order: 4
title: Persist
description: Easily persist data across page loads using localStorage
graph_image: https://alpinejs.dev/social_persist.jpg
@@ -22,10 +22,10 @@ You can include the CDN build of this plugin as a `
+
-
+
```
### Via NPM
@@ -204,4 +204,4 @@ Alpine.data('dropdown', function () {
Alpine.store('darkMode', {
on: Alpine.$persist(true).as('darkMode_on')
});
-```
\ No newline at end of file
+```
diff --git a/packages/docs/src/en/plugins/resize.md b/packages/docs/src/en/plugins/resize.md
new file mode 100644
index 000000000..cecdedf62
--- /dev/null
+++ b/packages/docs/src/en/plugins/resize.md
@@ -0,0 +1,99 @@
+---
+order: 3
+title: Resize
+description: An Alpine convenience wrapper for the Resize Observer API that allows you to easily react when an element is resized.
+graph_image: https://alpinejs.dev/social_resize.jpg
+---
+
+# Resize Plugin
+
+Alpine's Resize plugin is a convenience wrapper for the [Resize Observer](https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API) that allows you to easily react when an element changes size.
+
+This is useful for: custom size-based animations, intelligent sticky positioning, conditionally adding attributes based on the element's size, etc.
+
+
+## Installation
+
+You can use this plugin by either including it from a `
+
+
+
+```
+
+### Via NPM
+
+You can install Resize from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/resize
+```
+
+Then initialize it from your bundle:
+
+```js
+import Alpine from 'alpinejs'
+import resize from '@alpinejs/resize'
+
+Alpine.plugin(resize)
+
+...
+```
+
+
+## x-resize
+
+The primary API for using this plugin is `x-resize`. You can add `x-resize` to any element within an Alpine component, and when that element is resized for any reason, the provided expression will execute with two magic properties: `$width` and `$height`.
+
+For example, here's a simple example of using `x-resize` to display the width and height of an element as it changes size.
+
+```alpine
+
+
+
+
+```
+
+
+
+
+ Resize your browser window to see the width and height values change.
+
+
+
+
+
+
+
+
+## Modifiers
+
+
+### .document
+
+It's often useful to observe the entire document's size, rather than a specific element. To do this, you can add the `.document` modifier to `x-resize`:
+
+```alpine
+
+```
+
+
+
+
+ Resize your browser window to see the document width and height values change.
+
+
+
+
+
+
diff --git a/packages/docs/src/en/plugins/sort.md b/packages/docs/src/en/plugins/sort.md
new file mode 100644
index 000000000..554fcfb49
--- /dev/null
+++ b/packages/docs/src/en/plugins/sort.md
@@ -0,0 +1,372 @@
+---
+order: 9
+title: Sort
+description: Easily re-order elements by dragging them with your mouse
+graph_image: https://alpinejs.dev/social_sort.jpg
+---
+
+# Sort Plugin
+
+Alpine's Sort plugin allows you to easily re-order elements by dragging them with your mouse.
+
+This functionality is useful for things like Kanban boards, to-do lists, sortable table columns, etc.
+
+The drag functionality used in this plugin is provided by the [SortableJS](https://github.com/SortableJS/Sortable) project.
+
+
+## Installation
+
+You can use this plugin by either including it from a `
+
+
+
+```
+
+### Via NPM
+
+You can install Sort from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/sort
+```
+
+Then initialize it from your bundle:
+
+```js
+import Alpine from 'alpinejs'
+import sort from '@alpinejs/sort'
+
+Alpine.plugin(sort)
+
+...
+```
+
+
+## Basic usage
+
+The primary API for using this plugin is the `x-sort` directive. By adding `x-sort` to an element, its children containing `x-sort:item` become sortable—meaning you can drag them around with your mouse, and they will change positions.
+
+```alpine
+
+
foo
+
bar
+
baz
+
+```
+
+
+
+
+
foo
+
bar
+
baz
+
+
+
+
+
+## Sort handlers
+
+You can react to sorting changes by passing a handler function to `x-sort` and adding keys to each item using `x-sort:item`. Here is an example of a simple handler function that shows an alert dialog with the changed item's key and its new position:
+
+```alpine
+
+
foo
+
bar
+
baz
+
+```
+
+
+
+
+
foo
+
bar
+
baz
+
+
+
+
+The `x-sort` handler will be called every time the sort order of the items change. The `$item` magic will contain the key of the sorted element (derived from `x-sort:item`), and `$position` will contain the new position of the item (starting at index `0`).
+
+You can also pass a handler function to `x-sort` and that function will receive the `item` and `position` as the first and second parameter:
+
+```alpine
+
+
+
foo
+
bar
+
baz
+
+
+```
+
+Handler functions are often used to persist the new order of items in the database so that the sorting order of a list is preserved between page refreshes.
+
+
+## Sorting groups
+
+This plugin allows you to drag items from one `x-sort` sortable list into another one by adding a matching `x-sort:group` value to both lists:
+
+```alpine
+
+
+
foo
+
bar
+
baz
+
+
+
+
foo
+
bar
+
baz
+
+
+```
+
+Because both sortable lists above use the same group name (`todos`), you can drag items from one list onto another.
+
+> When using sort handlers like `x-sort="handle"` and dragging an item from one group to another, only the destination list's handler will be called with the key and new position.
+
+
+## Drag handles
+
+By default, each `x-sort:item` element is draggable by clicking and dragging anywhere within it. However, you may want to designate a smaller, more specific element as the "drag handle" so that the rest of the element can be interacted with like normal, and only the handle will respond to mouse dragging:
+
+```alpine
+
+
+ - foo
+
+
+
+ - bar
+
+
+
+ - baz
+
+
+```
+
+
+
+
+
+ - foo
+
+
+ - bar
+
+
+ - baz
+
+
+
+
+
+As you can see in the above example, the hyphen "-" is draggable, but the item text ("foo") is not.
+
+
+## Ignoring elements
+
+Sometimes you want to prevent certain elements within a sortable item from initiating a drag operation. This is especially useful when you have interactive elements like buttons, dropdowns, or links that users should be able to click without accidentally dragging the sortable item.
+
+You can use the `x-sort:ignore` directive to mark elements that should not trigger dragging:
+
+```alpine
+
+
+
+
+ Edit
+
+
+
+
+
+ Edit
+
+
+
+
+
+ Edit
+
+
+```
+
+In the above example, users can click and drag the item itself, but clicking on the "Edit" button will not initiate a drag operation.
+
+> **Note:** Elements with `x-sort:ignore` will still function normally (buttons can be clicked, inputs can be focused, etc.) - they are only excluded from drag operations.
+
+
+## Ghost elements
+
+When a user drags an item, the element will follow their mouse to appear as though they are physically dragging the element.
+
+By default, a "hole" (empty space) will be left in the original element's place during the drag.
+
+If you would like to show a "ghost" of the original element in its place instead of an empty space, you can add the `.ghost` modifier to `x-sort`:
+
+```alpine
+
+
foo
+
bar
+
baz
+
+```
+
+
+
+
+
foo
+
bar
+
baz
+
+
+
+
+
+### Styling the ghost element
+
+By default, the "ghost" element has a `.sortable-ghost` CSS class attached to it while the original element is being dragged.
+
+This makes it easy to add any custom styling you would like:
+
+```alpine
+
+
+
+
foo
+
bar
+
baz
+
+```
+
+
+
+
+
foo
+
bar
+
baz
+
+
+
+
+
+## Sorting class on body
+
+While an element is being dragged around, Alpine will automatically add a `.sorting` class to the `` element of the page.
+
+This is useful for styling any element on the page conditionally using only CSS.
+
+For example you could have a warning that only displays while a user is sorting items:
+
+```html
+
+ Page functionality is limited while sorting
+
+```
+
+To show this only while sorting, you can use the `body.sorting` CSS selector:
+
+```css
+#sort-warning {
+ display: none;
+}
+
+body.sorting #sort-warning {
+ display: block;
+}
+```
+
+
+## CSS hover bug
+
+Currently, there is a [bug in Chrome and Safari](https://issues.chromium.org/issues/41129937) (not Firefox) that causes issues with hover styles.
+
+Consider HTML like the following, where each item in the list is styled differently based on a hover state (here we're using Tailwind's `.hover` class to conditionally add a border):
+
+```html
+
+
foo
+
bar
+
baz
+
+```
+
+If you drag one of the elements in the list below you will see that the hover effect will be errantly applied to any element in the original element's place:
+
+
+
+
+
foo
+
bar
+
baz
+
+
+
+
+To fix this, you can leverage the `.sorting` class applied to the body while sorting to limit the hover effect to only be applied while `.sorting` does NOT exist on `body`.
+
+Here is how you can do this directly inline using Tailwind arbitrary variants:
+
+```html
+
+
foo
+
bar
+
baz
+
+```
+
+Now you can see below that the hover effect is only applied to the dragging element and not the others in the list.
+
+
+
+
+
foo
+
bar
+
baz
+
+
+
+
+
+## Custom configuration
+
+Alpine chooses sensible defaults for configuring [SortableJS](https://github.com/SortableJS/Sortable?tab=readme-ov-file#options) under the hood. However, you can add or override any of these options yourself using `x-sort:config`:
+
+```alpine
+
+
foo
+
bar
+
baz
+
+```
+
+
+
+
+
foo
+
bar
+
baz
+
+
+
+
+> Any config options passed will overwrite Alpine defaults. In this case of `animation`, this is fine, however be aware that overwriting `handle`, `group`, `filter`, `onSort`, `onStart`, or `onEnd` may break functionality.
+
+[View the full list of SortableJS configuration options here →](https://github.com/SortableJS/Sortable?tab=readme-ov-file#options)
diff --git a/packages/docs/src/en/start-here.md b/packages/docs/src/en/start-here.md
index f3e9ef55e..3f0cda649 100644
--- a/packages/docs/src/en/start-here.md
+++ b/packages/docs/src/en/start-here.md
@@ -12,7 +12,7 @@ Using a text editor, fill the file with these contents:
```alpine
-
+
@@ -71,6 +71,8 @@ Everything in Alpine starts with an `x-data` directive. Inside of `x-data`, in p
Every property inside this object will be made available to other directives inside this HTML element. In addition, when one of these properties changes, everything that relies on it will change as well.
+> `x-data` is required on a parent element for most Alpine directives to work.
+
[→ Read more about `x-data`](/directives/data)
Let's look at `x-on` and see how it can access and modify the `count` property from above:
@@ -96,14 +98,14 @@ When a `click` event happens, Alpine will call the associated JavaScript express
### Reacting to changes
```alpine
-
+
```
`x-text` is an Alpine directive you can use to set the text content of an element to the result of a JavaScript expression.
-In this case, we're telling Alpine to always make sure that the contents of this `h1` tag reflect the value of the `count` property.
+In this case, we're telling Alpine to always make sure that the contents of this `span` tag reflect the value of the `count` property.
-In case it's not clear, `x-text`, like most directives accepts a plain JavaScript expression as an argument. So for example, you could instead set its contents to: `x-text="count * 2"` and the text content of the `h1` will now always be 2 times the value of `count`.
+In case it's not clear, `x-text`, like most directives accepts a plain JavaScript expression as an argument. So for example, you could instead set its contents to: `x-text="count * 2"` and the text content of the `span` will now always be 2 times the value of `count`.
[→ Read more about `x-text`](/directives/text)
diff --git a/packages/focus/builds/module.js b/packages/focus/builds/module.js
index 765ebbfdb..6473892ec 100644
--- a/packages/focus/builds/module.js
+++ b/packages/focus/builds/module.js
@@ -1,3 +1,5 @@
-import registerFocusPlugin from '../src/index.js'
+import focus from '../src/index.js'
-export default registerFocusPlugin
+export default focus
+
+export { focus }
diff --git a/packages/focus/package.json b/packages/focus/package.json
index 048f617da..7563b3d81 100644
--- a/packages/focus/package.json
+++ b/packages/focus/package.json
@@ -1,13 +1,20 @@
{
"name": "@alpinejs/focus",
- "version": "3.10.2",
+ "version": "3.15.1",
"description": "Manage focus within a page",
+ "homepage": "https://alpinejs.dev/plugins/focus",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/focus"
+ },
"author": "Caleb Porzio",
"license": "MIT",
"main": "dist/module.cjs.js",
"module": "dist/module.esm.js",
"unpkg": "dist/cdn.min.js",
"dependencies": {
- "focus-trap": "^6.6.1"
+ "focus-trap": "^6.9.4",
+ "tabbable": "^5.3.3"
}
}
diff --git a/packages/focus/src/index.js b/packages/focus/src/index.js
index 4c823507e..32a98f70d 100644
--- a/packages/focus/src/index.js
+++ b/packages/focus/src/index.js
@@ -1,5 +1,5 @@
import { createFocusTrap } from 'focus-trap'
-import { focusable, tabbable, isFocusable } from 'tabbable'
+import { focusable, isFocusable } from 'tabbable'
export default function (Alpine) {
let lastFocused
@@ -90,7 +90,7 @@ export default function (Alpine) {
setTimeout(() => {
if (! el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0')
- el.focus({ preventScroll: this._noscroll })
+ el.focus({ preventScroll: this.__noscroll })
})
}
}
@@ -102,14 +102,32 @@ export default function (Alpine) {
let oldValue = false
- let trap = createFocusTrap(el, {
+ let options = {
escapeDeactivates: false,
allowOutsideClick: true,
fallbackFocus: () => el,
- initialFocus: el.querySelector('[autofocus]')
- })
+ }
let undoInert = () => {}
+
+ if (modifiers.includes('noautofocus')) {
+ options.initialFocus = false
+ } else {
+ let autofocusEl = el.querySelector('[autofocus]')
+
+ if (autofocusEl) options.initialFocus = autofocusEl
+ }
+
+ if (modifiers.includes('inert')) {
+ options.onPostActivate = () => {
+ Alpine.nextTick(() => {
+ undoInert = setInert(el);
+ });
+ }
+ }
+
+ let trap = createFocusTrap(el, options)
+
let undoDisableScrolling = () => {}
const releaseFocus = () => {
@@ -129,12 +147,12 @@ 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()
+ // Activate the trap after a generous tick. (Needed to play nice with transitions...)
+ setTimeout(() => {
trap.activate()
- });
+ }, 15)
}
// Stop trapping.
@@ -176,9 +194,11 @@ function crawlSiblingsUp(el, callback) {
if (el.isSameNode(document.body) || ! el.parentNode) return
Array.from(el.parentNode.children).forEach(sibling => {
- if (! sibling.isSameNode(el)) callback(sibling)
-
- crawlSiblingsUp(el.parentNode, callback)
+ if (sibling.isSameNode(el)) {
+ crawlSiblingsUp(el.parentNode, callback)
+ } else {
+ callback(sibling)
+ }
})
}
diff --git a/packages/history/builds/module.js b/packages/history/builds/module.js
index a7d9aba46..ca49ea2f7 100644
--- a/packages/history/builds/module.js
+++ b/packages/history/builds/module.js
@@ -1,3 +1,6 @@
import history from '../src/index.js'
+import { track } from '../src/index.js'
export default history
+
+export { history, track }
diff --git a/packages/history/package.json b/packages/history/package.json
index 08355c25f..924ff48ab 100644
--- a/packages/history/package.json
+++ b/packages/history/package.json
@@ -2,6 +2,12 @@
"name": "@alpinejs/history",
"version": "3.0.0-alpha.0",
"description": "Sync Alpine data with the browser's query string",
+ "homepage": "https://alpinejs.dev/",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/history"
+ },
"author": "Caleb Porzio",
"license": "MIT",
"main": "dist/module.cjs.js",
diff --git a/packages/history/src/index.js b/packages/history/src/index.js
index f230d809a..d08ca39cf 100644
--- a/packages/history/src/index.js
+++ b/packages/history/src/index.js
@@ -1,76 +1 @@
-export default function history(Alpine) {
- Alpine.magic('queryString', (el, { interceptor }) => {
- let alias
-
- return interceptor((initialValue, getter, setter, path, key) => {
- let pause = false
- let queryKey = alias || path
-
- let value = initialValue
- let url = new URL(window.location.href)
-
- if (url.searchParams.has(queryKey)) {
- value = url.searchParams.get(queryKey)
- }
-
- setter(value)
-
- let object = { value }
-
- url.searchParams.set(queryKey, value)
-
- replace(url.toString(), path, object)
-
- window.addEventListener('popstate', (e) => {
- if (! e.state) return
- if (! e.state.alpine) return
-
- Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
- if (newKey !== key) return
-
- pause = true
-
- Alpine.disableEffectScheduling(() => {
- setter(value)
- })
-
- pause = false
- })
- })
-
- Alpine.effect(() => {
- let value = getter()
-
- if (pause) return
-
- let object = { value }
-
- let url = new URL(window.location.href)
-
- url.searchParams.set(queryKey, value)
-
- push(url.toString(), path, object)
- })
-
- return value
- }, func => {
- func.as = key => { alias = key; return func }
- })
- })
-}
-
-function replace(url, key, object) {
- let state = window.history.state || {}
-
- if (! state.alpine) state.alpine = {}
-
- state.alpine[key] = object
-
- window.history.replaceState(state, '', url)
-}
-
-function push(url, key, object) {
- let state = { alpine: {...window.history.state.alpine, ...{[key]: object}} }
-
- window.history.pushState(state, '', url)
-}
+// This plugin has been moved into the livewire/livewire repository until it's more stable and ready to tag.
diff --git a/packages/history/src/url.js b/packages/history/src/url.js
deleted file mode 100644
index 7e2b4b686..000000000
--- a/packages/history/src/url.js
+++ /dev/null
@@ -1,36 +0,0 @@
-
-export function hasQueryParam(param) {
- let queryParams = new URLSearchParams(window.location.search);
-
- return queryParams.has(param)
-}
-
-export function getQueryParam(param) {
- let queryParams = new URLSearchParams(window.location.search);
-
- return queryParams.get(param)
-}
-
-export function setQueryParam(param, value) {
- let queryParams = new URLSearchParams(window.location.search);
-
- queryParams.set(param, value)
-
- let url = urlFromQueryParams(queryParams)
-
- history.replaceState(history.state, '', url)
-}
-
-function urlFromParams(params = {}) {
- let queryParams = new URLSearchParams(window.location.search);
-
- Object.entries(params).forEach(([key, value]) => {
- queryParams.set(key, value)
- })
-
- let queryString = Array.from(queryParams.entries()).length > 0
- ? '?'+params.toString()
- : ''
-
- return window.location.origin + window.location.pathname + '?'+queryString + window.location.hash
-}
diff --git a/packages/intersect/builds/module.js b/packages/intersect/builds/module.js
index e33db042a..011bf5473 100644
--- a/packages/intersect/builds/module.js
+++ b/packages/intersect/builds/module.js
@@ -1,3 +1,5 @@
import intersect from './../src/index.js'
export default intersect
+
+export { intersect }
diff --git a/packages/intersect/package.json b/packages/intersect/package.json
index 3f13925c5..cf225881a 100644
--- a/packages/intersect/package.json
+++ b/packages/intersect/package.json
@@ -1,7 +1,13 @@
{
"name": "@alpinejs/intersect",
- "version": "3.10.2",
+ "version": "3.15.1",
"description": "Trigger JavaScript when an element enters the viewport",
+ "homepage": "https://alpinejs.dev/plugins/intersect",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/intersect"
+ },
"author": "Caleb Porzio",
"license": "MIT",
"main": "dist/module.cjs.js",
diff --git a/packages/intersect/src/index.js b/packages/intersect/src/index.js
index 0880258cd..f955c4fce 100644
--- a/packages/intersect/src/index.js
+++ b/packages/intersect/src/index.js
@@ -1,10 +1,10 @@
export default function (Alpine) {
- Alpine.directive('intersect', (el, { value, expression, modifiers }, { evaluateLater, cleanup }) => {
+ Alpine.directive('intersect', Alpine.skipDuringClone((el, { value, expression, modifiers }, { evaluateLater, cleanup }) => {
let evaluate = evaluateLater(expression)
let options = {
rootMargin: getRootMargin(modifiers),
- threshold: getThreshhold(modifiers),
+ threshold: getThreshold(modifiers),
}
let observer = new IntersectionObserver(entries => {
@@ -23,10 +23,10 @@ export default function (Alpine) {
cleanup(() => {
observer.disconnect()
})
- })
+ }))
}
-function getThreshhold(modifiers) {
+function getThreshold(modifiers) {
if (modifiers.includes('full')) return 0.99
if (modifiers.includes('half')) return 0.5
if (! modifiers.includes('threshold')) return 0
diff --git a/packages/mask/builds/module.js b/packages/mask/builds/module.js
index c569048d1..c49f3a1c7 100644
--- a/packages/mask/builds/module.js
+++ b/packages/mask/builds/module.js
@@ -1,5 +1,5 @@
-import maskPlugin, { stripDown } from '../src/index.js'
+import mask, { stripDown } from '../src/index.js'
-export default maskPlugin
+export default mask
-export { stripDown }
+export { mask, stripDown }
diff --git a/packages/mask/package.json b/packages/mask/package.json
index 9da2bbb3c..764906e30 100644
--- a/packages/mask/package.json
+++ b/packages/mask/package.json
@@ -1,7 +1,13 @@
{
"name": "@alpinejs/mask",
- "version": "3.10.2",
+ "version": "3.15.1",
"description": "An Alpine plugin for input masking",
+ "homepage": "https://alpinejs.dev/plugins/mask",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/mask"
+ },
"author": "Caleb Porzio",
"license": "MIT",
"main": "dist/module.cjs.js",
diff --git a/packages/mask/src/index.js b/packages/mask/src/index.js
index b73a258b2..9686a0d67 100644
--- a/packages/mask/src/index.js
+++ b/packages/mask/src/index.js
@@ -1,47 +1,73 @@
export default function (Alpine) {
- Alpine.directive('mask', (el, { value, expression }, { effect, evaluateLater }) => {
+ Alpine.directive('mask', (el, { value, expression }, { effect, evaluateLater, cleanup }) => {
let templateFn = () => expression
let lastInputValue = ''
- if (['function', 'dynamic'].includes(value)) {
- // This is an x-mask:function directive.
-
- let evaluator = evaluateLater(expression)
-
- effect(() => {
- templateFn = input => {
- let result
-
- // We need to prevent "auto-evaluation" of functions like
- // x-on expressions do so that we can use them as mask functions.
- Alpine.dontAutoEvaluateFunctions(() => {
- evaluator(value => {
- result = typeof value === 'function' ? value(input) : value
- }, { scope: {
- // These are "magics" we'll make available to the x-mask:function:
- '$input': input,
- '$money': formatMoney.bind({ el }),
- }})
- })
-
- return result
- }
-
- // Run on initialize which serves a dual purpose:
- // - Initializing the mask on the input if it has an initial value.
- // - Running the template function to set up reactivity, so that
- // when a dependancy inside it changes, the input re-masks.
- processInputValue(el)
- })
- } else {
- processInputValue(el)
- }
+ queueMicrotask(() => {
+ if (['function', 'dynamic'].includes(value)) {
+ // This is an x-mask:function directive.
+
+ let evaluator = evaluateLater(expression)
+
+ effect(() => {
+ templateFn = input => {
+ let result
+
+ // We need to prevent "auto-evaluation" of functions like
+ // x-on expressions do so that we can use them as mask functions.
+ Alpine.dontAutoEvaluateFunctions(() => {
+ evaluator(value => {
+ result = typeof value === 'function' ? value(input) : value
+ }, { scope: {
+ // These are "magics" we'll make available to the x-mask:function:
+ '$input': input,
+ '$money': formatMoney.bind({ el }),
+ }})
+ })
+
+ return result
+ }
+
+ // Run on initialize which serves a dual purpose:
+ // - Initializing the mask on the input if it has an initial value.
+ // - Running the template function to set up reactivity, so that
+ // when a dependency inside it changes, the input re-masks.
+ processInputValue(el, false)
+ })
+ } else {
+ processInputValue(el, false)
+ }
+
+ // Override x-model's initial value...
+ if (el._x_model) {
+ // If the x-model value is the same, don't override it as that will trigger updates...
+ if (el._x_model.get() === el.value) return
+
+ // If the x-model value is `null` and the input value is an empty
+ // string, don't override it as that will trigger updates...
+ if (el._x_model.get() === null && el.value === '') return
+
+ el._x_model.set(el.value)
+ }
+ })
+
+ const controller = new AbortController()
+
+ cleanup(() => {
+ controller.abort()
+ })
+
+ el.addEventListener('input', () => processInputValue(el), {
+ signal: controller.signal,
+ // Setting this as a capture phase listener to ensure it runs
+ // before wire:model or x-model added as a latent binding...
+ capture: true,
+ })
- el.addEventListener('input', () => processInputValue(el))
// Don't "restoreCursorPosition" on "blur", because Safari
// will re-focus the input and cause a focus trap.
- el.addEventListener('blur', () => processInputValue(el, false))
+ el.addEventListener('blur', () => processInputValue(el, false), { signal: controller.signal })
function processInputValue (el, shouldRestoreCursor = true) {
let input = el.value
@@ -56,7 +82,9 @@ export default function (Alpine) {
return lastInputValue = el.value
}
- let setInput = () => { lastInputValue = el.value = formatInput(input, template) }
+ let setInput = () => {
+ lastInputValue = el.value = formatInput(input, template)
+ }
if (shouldRestoreCursor) {
// When an input element's value is set, it moves the cursor to the end
@@ -79,7 +107,7 @@ export default function (Alpine) {
return rebuiltInput
}
- })
+ }).before('model')
}
export function restoreCursorPosition(el, template, callback) {
@@ -163,9 +191,13 @@ export function buildUp(template, input) {
return output
}
-function formatMoney(input, delimeter = '.', thousands) {
- thousands = (delimeter === ',' && thousands === undefined)
- ? '.' : ','
+export function formatMoney(input, delimiter = '.', thousands, precision = 2) {
+ if (input === '-') return '-'
+ if (/^\D+$/.test(input)) return '9'
+
+ if (thousands === null || thousands === undefined) {
+ thousands = delimiter === "," ? "." : ","
+ }
let addThousands = (input, thousands) => {
let output = ''
@@ -186,17 +218,19 @@ function formatMoney(input, delimeter = '.', thousands) {
return output
}
- let strippedInput = input.replaceAll(new RegExp(`[^0-9\\${delimeter}]`, 'g'), '')
- let template = Array.from({ length: strippedInput.split(delimeter)[0].length }).fill('9').join('')
+ let minus = input.startsWith('-') ? '-' : ''
+ let strippedInput = input.replaceAll(new RegExp(`[^0-9\\${delimiter}]`, 'g'), '')
+ let template = Array.from({ length: strippedInput.split(delimiter)[0].length }).fill('9').join('')
- template = addThousands(template, thousands)
+ template = `${minus}${addThousands(template, thousands)}`
- if (input.includes(delimeter)) template += `${delimeter}99`
+ if (precision > 0 && input.includes(delimiter))
+ template += `${delimiter}` + '9'.repeat(precision)
queueMicrotask(() => {
- if (this.el.value.endsWith(delimeter)) return
+ if (this.el.value.endsWith(delimiter)) return
- if (this.el.value[this.el.selectionStart - 1] === delimeter) {
+ if (this.el.value[this.el.selectionStart - 1] === delimiter) {
this.el.setSelectionRange(this.el.selectionStart - 1, this.el.selectionStart - 1)
}
})
diff --git a/packages/morph/builds/module.js b/packages/morph/builds/module.js
index 0359ad74f..7908165e1 100644
--- a/packages/morph/builds/module.js
+++ b/packages/morph/builds/module.js
@@ -1,5 +1,5 @@
-import morphPlugin, { morph } from '../src/index.js'
+import morph from '../src/index.js'
-export default morphPlugin
+export default morph
export { morph }
diff --git a/packages/morph/package.json b/packages/morph/package.json
index 2370fe870..14639661f 100644
--- a/packages/morph/package.json
+++ b/packages/morph/package.json
@@ -1,7 +1,13 @@
{
"name": "@alpinejs/morph",
- "version": "3.10.2",
+ "version": "3.15.1",
"description": "Diff and patch a block of HTML on a page with an HTML template",
+ "homepage": "https://alpinejs.dev/plugins/morph",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/morph"
+ },
"author": "Caleb Porzio",
"license": "MIT",
"main": "dist/module.cjs.js",
diff --git a/packages/morph/src/dom.js b/packages/morph/src/dom.js
deleted file mode 100644
index 1e74fe721..000000000
--- a/packages/morph/src/dom.js
+++ /dev/null
@@ -1,66 +0,0 @@
-class DomManager {
- el = undefined
-
- constructor(el) {
- this.el = el
- }
-
- traversals = {
- 'first': 'firstElementChild',
- 'next': 'nextElementSibling',
- 'parent': 'parentElement',
- }
-
- nodes() {
- this.traversals = {
- 'first': 'firstChild',
- 'next': 'nextSibling',
- 'parent': 'parentNode',
- }; return this
- }
-
- first() {
- return this.teleportTo(this.el[this.traversals['first']])
- }
-
- next() {
- return this.teleportTo(this.teleportBack(this.el[this.traversals['next']]))
- }
-
- before(insertee) {
- this.el[this.traversals['parent']].insertBefore(insertee, this.el); return insertee
- }
-
- replace(replacement) {
- this.el[this.traversals['parent']].replaceChild(replacement, this.el); return replacement
- }
-
- append(appendee) {
- this.el.appendChild(appendee); return appendee
- }
-
- teleportTo(el) {
- if (! el) return el
- if (el._x_teleport) return el._x_teleport
- return el
- }
-
- teleportBack(el) {
- if (! el) return el
- if (el._x_teleportBack) return el._x_teleportBack
- return el
- }
-}
-
-export function dom(el) {
- return new DomManager(el)
-}
-
-export function createElement(html) {
- return document.createRange().createContextualFragment(html).firstElementChild
-}
-
-export function textOrComment(el) {
- return el.nodeType === 3
- || el.nodeType === 8
-}
diff --git a/packages/morph/src/index.js b/packages/morph/src/index.js
index 93c46b206..e159f9017 100644
--- a/packages/morph/src/index.js
+++ b/packages/morph/src/index.js
@@ -1,7 +1,8 @@
-import { morph } from './morph'
+import { morph, morphBetween } from './morph'
export default function (Alpine) {
Alpine.morph = morph
+ Alpine.morphBetween = morphBetween
}
-export { morph }
+export { morph, morphBetween }
diff --git a/packages/morph/src/morph.js b/packages/morph/src/morph.js
index b472c6c07..477c63c2b 100644
--- a/packages/morph/src/morph.js
+++ b/packages/morph/src/morph.js
@@ -1,117 +1,165 @@
-import { dom, createElement, textOrComment} from './dom.js'
-
let resolveStep = () => {}
let logger = () => {}
-export async function morph(from, toHtml, options) {
+export function morph(from, toHtml, options) {
+ monkeyPatchDomSetAttributeToAllowAtSymbols()
+
// We're defining these globals and methods inside this function (instead of outside)
// because it's an async function and if run twice, they would overwrite
// each other.
- let fromEl
- let toEl
- let key
- ,lookahead
- ,updating
- ,updated
- ,removing
- ,removed
- ,adding
- ,added
- ,debug
+ let context = createMorphContext(options)
+ // Finally we morph the element
- function breakpoint(message) {
- if (! debug) return
+ let toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
- logger((message || '').replace('\n', '\\n'), fromEl, toEl)
+ if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
+ // Just in case a part of this template uses Alpine scope from somewhere
+ // higher in the DOM tree, we'll find that state and replace it on the root
+ // element so everything is synced up accurately.
+ toEl._x_dataStack = window.Alpine.closestDataStack(from)
- return new Promise(resolve => resolveStep = () => resolve())
+ // We will kick off a clone on the root element.
+ toEl._x_dataStack && window.Alpine.cloneNode(from, toEl)
}
- function assignOptions(options = {}) {
- let defaultGetKey = el => el.getAttribute('key')
- let noop = () => {}
-
- updating = options.updating || noop
- updated = options.updated || noop
- removing = options.removing || noop
- removed = options.removed || noop
- adding = options.adding || noop
- added = options.added || noop
- key = options.key || defaultGetKey
- lookahead = options.lookahead || false
- debug = options.debug || false
- }
+ context.patch(from, toEl)
- async function patch(from, to) {
- // This is a time saver, however, it won't catch differences in nested tags.
- // I'm leaving this here as I believe it's an important speed improvement, I just
- // don't see a way to enable it currently:
- //
- // if (from.isEqualNode(to)) return
+ return from
+}
+
+export function morphBetween(startMarker, endMarker, toHtml, options = {}) {
+ monkeyPatchDomSetAttributeToAllowAtSymbols()
+
+ let context = createMorphContext(options)
+
+ // Setup from block...
+ let fromContainer = startMarker.parentNode
+ let fromBlock = new Block(startMarker, endMarker)
- if (differentElementNamesTypesOrKeys(from, to)) {
- let result = patchElement(from, to)
+ // Setup to block...
+ let toContainer = typeof toHtml === 'string'
+ ? (() => {
+ let container = document.createElement('div')
+ container.insertAdjacentHTML('beforeend', toHtml)
+ return container
+ })()
+ : toHtml
- await breakpoint('Swap elements')
+ let toStartMarker = document.createComment('[morph-start]')
+ let toEndMarker = document.createComment('[morph-end]')
+
+ toContainer.insertBefore(toStartMarker, toContainer.firstChild)
+ toContainer.appendChild(toEndMarker)
+
+ let toBlock = new Block(toStartMarker, toEndMarker)
+
+ if (window.Alpine && window.Alpine.closestDataStack) {
+ toContainer._x_dataStack = window.Alpine.closestDataStack(fromContainer)
+ toContainer._x_dataStack && window.Alpine.cloneNode(fromContainer, toContainer)
+ }
- return result
+ // Run the patch
+ context.patchChildren(fromBlock, toBlock)
+}
+
+function createMorphContext(options = {}) {
+ let defaultGetKey = el => el.getAttribute('key')
+ let noop = () => {}
+
+ let context = {
+ key: options.key || defaultGetKey,
+ lookahead: options.lookahead || false,
+ updating: options.updating || noop,
+ updated: options.updated || noop,
+ removing: options.removing || noop,
+ removed: options.removed || noop,
+ adding: options.adding || noop,
+ added: options.added || noop
+ }
+
+ context.patch = function(from, to) {
+ if (context.differentElementNamesTypesOrKeys(from, to)) {
+ return context.swapElements(from, to)
}
let updateChildrenOnly = false
+ let skipChildren = false
- if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
+ // If we used `shouldSkip()` here and append the `skipChildren` function on the end, it will cause the signature of the `updating`
+ // hook to change. For example, when it was `shouldSkip()` the signature was `updating: (el, toEl, childrenOnly, skip)`. But if
+ // we append `skipChildren()`, it would make the signature `updating: (el, toEl, childrenOnly, skipChildren, skip)`. This is
+ // a breaking change due to how the `shouldSkip()` function is structured.
+ //
+ // So we're using `shouldSkipChildren()` instead which doesn't have this problem as it allows us to pass in the `skipChildren()`
+ // function as an earlier parameter and then append it to the `updating` hook signature manually. The signature of `updating`
+ // hook is now `updating: (el, toEl, childrenOnly, skip, skipChildren)`.
+
+ let skipUntil = predicate => context.skipUntilCondition = predicate
- window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
+ if (shouldSkipChildren(context.updating, () => skipChildren = true, skipUntil, from, to, () => updateChildrenOnly = true)) return
+
+ // Initialize the server-side HTML element with Alpine...
+ if (from.nodeType === 1 && window.Alpine) {
+ window.Alpine.cloneNode(from, to)
+
+ if (from._x_teleport && to._x_teleport) {
+ context.patch(from._x_teleport, to._x_teleport)
+ }
+ }
if (textOrComment(to)) {
- await patchNodeValue(from, to)
- updated(from, to)
+ context.patchNodeValue(from, to)
+
+ context.updated(from, to)
return
}
if (! updateChildrenOnly) {
- await patchAttributes(from, to)
+ context.patchAttributes(from, to)
}
- updated(from, to)
+ context.updated(from, to)
- await patchChildren(from, to)
+ if (! skipChildren) {
+ context.patchChildren(from, to)
+ }
}
- function differentElementNamesTypesOrKeys(from, to) {
+ context.differentElementNamesTypesOrKeys = function(from, to) {
return from.nodeType != to.nodeType
|| from.nodeName != to.nodeName
- || getKey(from) != getKey(to)
+ || context.getKey(from) != context.getKey(to)
}
- function patchElement(from, to) {
- if (shouldSkip(removing, from)) return
+ context.swapElements = function(from, to) {
+ if (shouldSkip(context.removing, from)) return
let toCloned = to.cloneNode(true)
- if (shouldSkip(adding, toCloned)) return
+ if (shouldSkip(context.adding, toCloned)) return
- dom(from).replace(toCloned)
+ from.replaceWith(toCloned)
- removed(from)
- added(toCloned)
+ context.removed(from)
+ context.added(toCloned)
}
- async function patchNodeValue(from, to) {
+ context.patchNodeValue = function(from, to) {
let value = to.nodeValue
if (from.nodeValue !== value) {
+ // Change text node...
from.nodeValue = value
-
- await breakpoint('Change text node to: ' + value)
}
}
- async function patchAttributes(from, to) {
+ context.patchAttributes = function(from, to) {
+ if (from._x_transitioning) return
+
if (from._x_isShown && ! to._x_isShown) {
return
}
@@ -126,9 +174,8 @@ export async function morph(from, toHtml, options) {
let name = domAttributes[i].name;
if (! to.hasAttribute(name)) {
+ // Remove attribute...
from.removeAttribute(name)
-
- await breakpoint('Remove attribute')
}
}
@@ -138,105 +185,176 @@ export async function morph(from, toHtml, options) {
if (from.getAttribute(name) !== value) {
from.setAttribute(name, value)
-
- await breakpoint(`Set [${name}] attribute to: "${value}"`)
}
}
}
- async function patchChildren(from, to) {
- let domChildren = from.childNodes
- let toChildren = to.childNodes
-
- let toKeyToNodeMap = keyToMap(toChildren)
- let domKeyDomNodeMap = keyToMap(domChildren)
+ context.patchChildren = function(from, to) {
+ let fromKeys = context.keyToMap(from.children)
+ let fromKeyHoldovers = {}
- let currentTo = dom(to).nodes().first()
- let currentFrom = dom(from).nodes().first()
-
- let domKeyHoldovers = {}
+ let currentTo = getFirstNode(to)
+ let currentFrom = getFirstNode(from)
while (currentTo) {
- let toKey = getKey(currentTo)
- let domKey = getKey(currentFrom)
+ // 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 = context.getKey(currentTo)
+ let fromKey = context.getKey(currentFrom)
+
+ if (context.skipUntilCondition) {
+ let fromDone = ! currentFrom || context.skipUntilCondition(currentFrom)
+ let toDone = ! currentTo || context.skipUntilCondition(currentTo)
+ if (fromDone && toDone) {
+ context.skipUntilCondition = null
+ } else {
+ if (! fromDone) currentFrom = currentFrom && getNextSibling(from, currentFrom)
+ if (! toDone) currentTo = currentTo && getNextSibling(to, currentTo)
+ continue
+ }
+ }
- // Add new elements
+ // Add new elements...
if (! currentFrom) {
- if (toKey && domKeyHoldovers[toKey]) {
- let holdover = domKeyHoldovers[toKey]
+ if (toKey && fromKeyHoldovers[toKey]) {
+ // Add element (from key)...
+ let holdover = fromKeyHoldovers[toKey]
- dom(from).append(holdover)
- currentFrom = holdover
+ from.appendChild(holdover)
- await breakpoint('Add element (from key)')
+ currentFrom = holdover
+ fromKey = context.getKey(currentFrom)
} else {
- let added = addNodeTo(currentTo, from) || {}
+ if(! shouldSkip(context.adding, currentTo)) {
+ // Add element...
+ let clone = currentTo.cloneNode(true)
+
+ from.appendChild(clone)
- await breakpoint('Add element: ' + (added.outerHTML || added.nodeValue))
+ context.added(clone)
+ }
- currentTo = dom(currentTo).nodes().next()
+ currentTo = getNextSibling(to, currentTo)
continue
}
}
- if (lookahead) {
- let nextToElementSibling = dom(currentTo).next()
+ // Handle conditional markers (presumably added by backends like Livewire)...
+ let isIf = node => node && node.nodeType === 8 && node.textContent === '[if BLOCK]> node && node.nodeType === 8 && node.textContent === '[if ENDBLOCK]> 0) {
+ nestedIfCount--
+ } else if (isEnd(next) && nestedIfCount === 0) {
+ currentFrom = next
- await breakpoint('Move element (lookahead)')
+ break;
}
- nextToElementSibling = dom(nextToElementSibling).next()
+ currentFrom = next
}
- }
- if (toKey !== domKey) {
- if (! toKey && domKey) {
- domKeyHoldovers[domKey] = currentFrom
- currentFrom = addNodeBefore(currentTo, currentFrom)
- domKeyHoldovers[domKey].remove()
- currentFrom = dom(currentFrom).nodes().next()
- currentTo = dom(currentTo).nodes().next()
+ let fromBlockEnd = currentFrom
- await breakpoint('No "to" key')
+ nestedIfCount = 0
- continue
+ let toBlockStart = currentTo
+
+ while (currentTo) {
+ let next = getNextSibling(to, currentTo)
+
+ if (isIf(next)) {
+ nestedIfCount++
+ } else if (isEnd(next) && nestedIfCount > 0) {
+ nestedIfCount--
+ } else if (isEnd(next) && nestedIfCount === 0) {
+ currentTo = next
+
+ break;
+ }
+
+ currentTo = next
}
- if (toKey && ! domKey) {
- if (domKeyDomNodeMap[toKey]) {
- currentFrom = dom(currentFrom).replace(domKeyDomNodeMap[toKey])
+ let toBlockEnd = currentTo
+
+ let fromBlock = new Block(fromBlockStart, fromBlockEnd)
+ let toBlock = new Block(toBlockStart, toBlockEnd)
+
+ context.patchChildren(fromBlock, toBlock)
+
+ continue
+ }
- await breakpoint('No "from" key')
+ // Lookaheads should only apply to non-text-or-comment elements...
+ if (currentFrom.nodeType === 1 && context.lookahead && ! currentFrom.isEqualNode(currentTo)) {
+ let nextToElementSibling = getNextSibling(to, currentTo)
+
+ let found = false
+
+ while (! found && nextToElementSibling) {
+ if (nextToElementSibling.nodeType === 1 && currentFrom.isEqualNode(nextToElementSibling)) {
+ found = true; // This ";" needs to be here...
+
+ currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
+
+ fromKey = context.getKey(currentFrom)
}
+
+ nextToElementSibling = getNextSibling(to, nextToElementSibling)
}
+ }
- if (toKey && domKey) {
- domKeyHoldovers[domKey] = currentFrom
- let domKeyNode = domKeyDomNodeMap[toKey]
+ if (toKey !== fromKey) {
+ if (! toKey && fromKey) {
+ // No "to" key...
+ fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
+ currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
+ fromKeyHoldovers[fromKey].remove()
+ currentFrom = getNextSibling(from, currentFrom)
+ currentTo = getNextSibling(to, currentTo)
- if (domKeyNode) {
- currentFrom = dom(currentFrom).replace(domKeyNode)
+ continue
+ }
- await breakpoint('Move "from" key')
- } else {
- domKeyHoldovers[domKey] = currentFrom
- currentFrom = addNodeBefore(currentTo, currentFrom)
- domKeyHoldovers[domKey].remove()
- currentFrom = dom(currentFrom).next()
- currentTo = dom(currentTo).next()
+ if (toKey && ! fromKey) {
+ if (fromKeys[toKey]) {
+ // No "from" key...
+ currentFrom.replaceWith(fromKeys[toKey])
+ currentFrom = fromKeys[toKey]
+ fromKey = context.getKey(currentFrom)
+ }
+ }
+
+ if (toKey && fromKey) {
+ let fromKeyNode = fromKeys[toKey]
- await breakpoint('Swap elements with keys')
+ if (fromKeyNode) {
+ // Move "from" key...
+ fromKeyHoldovers[fromKey] = currentFrom
+ currentFrom.replaceWith(fromKeyNode)
+ currentFrom = fromKeyNode
+ fromKey = context.getKey(currentFrom)
+ } else {
+ // Swap elements with keys...
+ fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
+ currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
+ fromKeyHoldovers[fromKey].remove()
+ currentFrom = getNextSibling(from, currentFrom)
+ currentTo = getNextSibling(to, currentTo)
continue
}
@@ -244,24 +362,26 @@ export async function morph(from, toHtml, options) {
}
// Get next from sibling before patching in case the node is replaced
- let currentFromNext = currentFrom && dom(currentFrom).nodes().next()
+ let currentFromNext = currentFrom && getNextSibling(from, currentFrom) //dom.next(from, fromChildren, currentFrom))
// Patch elements
- await patch(currentFrom, currentTo)
+ context.patch(currentFrom, currentTo)
+
+ currentTo = currentTo && getNextSibling(to, currentTo) // dom.next(from, toChildren, currentTo))
- currentTo = currentTo && dom(currentTo).nodes().next()
currentFrom = currentFromNext
}
- // Cleanup extra froms.
+ // Cleanup extra forms.
let removals = []
// We need to collect the "removals" first before actually
// removing them so we don't mess with the order of things.
while (currentFrom) {
- if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
+ if (! shouldSkip(context.removing, currentFrom)) removals.push(currentFrom)
- currentFrom = dom(currentFrom).nodes().next()
+ // currentFrom = dom.next(fromChildren, currentFrom)
+ currentFrom = getNextSibling(from, currentFrom)
}
// Now we can do the actual removals.
@@ -270,104 +390,181 @@ export async function morph(from, toHtml, options) {
domForRemoval.remove()
- await breakpoint('remove el')
-
- removed(domForRemoval)
+ context.removed(domForRemoval)
}
}
- function getKey(el) {
- return el && el.nodeType === 1 && key(el)
+ context.getKey = function(el) {
+ return el && el.nodeType === 1 && context.key(el)
}
- function keyToMap(els) {
+ context.keyToMap = function(els) {
let map = {}
- els.forEach(el => {
- let theKey = getKey(el)
+ for (let el of els) {
+ let theKey = context.getKey(el)
if (theKey) {
map[theKey] = el
}
- })
+ }
return map
}
- function addNodeTo(node, parent) {
- if(! shouldSkip(adding, node)) {
+ context.addNodeBefore = function(parent, node, beforeMe) {
+ if(! shouldSkip(context.adding, node)) {
let clone = node.cloneNode(true)
- dom(parent).append(clone)
+ parent.insertBefore(clone, beforeMe)
- added(clone)
+ context.added(clone)
return clone
}
- return null;
+ return node
}
- function addNodeBefore(node, beforeMe) {
- if(! shouldSkip(adding, node)) {
- let clone = node.cloneNode(true)
+ return context
+}
- dom(beforeMe).before(clone)
+// These are legacy holdovers that don't do anything anymore...
+morph.step = () => {}
+morph.log = () => {}
- added(clone)
+function shouldSkip(hook, ...args) {
+ let skip = false
- return clone
- }
+ hook(...args, () => skip = true)
+
+ return skip
+}
- return beforeMe
+// Due to the structure of the `shouldSkip()` function, we can't pass in the `skipChildren`
+// function as an argument as it would change the signature of the existing hooks. So we
+// are using this function instead which doesn't have this problem as we can pass the
+// `skipChildren` function in as an earlier argument and then append it to the end
+// of the hook signature manually.
+function shouldSkipChildren(hook, skipChildren, skipUntil, ...args) {
+ let skip = false
+ hook(...args, () => skip = true, skipChildren, skipUntil)
+ return skip
+}
+
+let patched = false
+
+export function createElement(html) {
+ const template = document.createElement('template')
+ template.innerHTML = html
+ return template.content.firstElementChild
+}
+
+export function textOrComment(el) {
+ return el.nodeType === 3
+ || el.nodeType === 8
+}
+
+// "Block"s are used when morphing with conditional markers.
+// They allow us to patch isolated portions of a list of
+// siblings in a DOM tree...
+class Block {
+ constructor(start, end) {
+ // We're assuming here that the start and end caps are comment blocks...
+ this.startComment = start
+ this.endComment = end
}
- // Finally we morph the element
+ get children() {
+ let children = [];
- assignOptions(options)
+ let currentNode = this.startComment.nextSibling
- fromEl = from
- toEl = createElement(toHtml)
+ while (currentNode && currentNode !== this.endComment) {
+ children.push(currentNode)
- // If there is no x-data on the element we're morphing,
- // let's seed it with the outer Alpine scope on the page.
- if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
- toEl._x_dataStack = window.Alpine.closestDataStack(from)
+ currentNode = currentNode.nextSibling
+ }
+
+ return children
+ }
- toEl._x_dataStack && window.Alpine.clone(from, toEl)
+ appendChild(child) {
+ this.endComment.before(child)
}
- await breakpoint()
+ get firstChild() {
+ let first = this.startComment.nextSibling
+
+ if (first === this.endComment) return
- await patch(from, toEl)
+ return first
+ }
- // Release these for the garbage collector.
- fromEl = undefined
- toEl = undefined
+ nextNode(reference) {
+ let next = reference.nextSibling
- return from
+ if (next === this.endComment) return
+
+ return next
+ }
+
+ insertBefore(newNode, reference) {
+ reference.before(newNode)
+
+ return newNode
+ }
}
-morph.step = () => resolveStep()
-morph.log = (theLogger) => {
- logger = theLogger
+function getFirstNode(parent) {
+ return parent.firstChild
}
-function shouldSkip(hook, ...args) {
- let skip = false
+function getNextSibling(parent, reference) {
+ let next
- hook(...args, () => skip = true)
+ if (parent instanceof Block) {
+ next = parent.nextNode(reference)
+ } else {
+ next = reference.nextSibling
+ }
- return skip
+ return next
}
-function initializeAlpineOnTo(from, to, childrenOnly) {
- if (from.nodeType !== 1) return
+function monkeyPatchDomSetAttributeToAllowAtSymbols() {
+ if (patched) return
+
+ patched = true
+
+ // Because morphdom may add attributes to elements containing "@" symbols
+ // like in the case of an Alpine `@click` directive, we have to patch
+ // the standard Element.setAttribute method to allow this to work.
+ let original = Element.prototype.setAttribute
+
+ let hostDiv = document.createElement('div')
+
+ Element.prototype.setAttribute = function newSetAttribute(name, value) {
+ if (! name.includes('@')) {
+ return original.call(this, name, value)
+ }
- // If the element we are updating is an Alpine component...
- if (from._x_dataStack) {
- // Then temporarily clone it (with it's data) to the "to" element.
- // This should simulate backend Livewire being aware of Alpine changes.
- window.Alpine.clone(from, to)
+ hostDiv.innerHTML = ``
+
+ let attr = hostDiv.firstElementChild.getAttributeNode(name)
+
+ hostDiv.firstElementChild.removeAttributeNode(attr)
+
+ this.setAttributeNode(attr)
}
}
+
+function seedingMatchingId(to, from) {
+ let fromId = from && from._x_bindings && from._x_bindings.id
+
+ if (! fromId) return
+ if (! to.setAttribute) return
+
+ to.setAttribute('id', fromId)
+ to.id = fromId
+}
diff --git a/packages/morph/src/old_morph.js b/packages/morph/src/old_morph.js
new file mode 100644
index 000000000..fa388a651
--- /dev/null
+++ b/packages/morph/src/old_morph.js
@@ -0,0 +1,405 @@
+import { dom, createElement, textOrComment} from './dom.js'
+
+let resolveStep = () => {}
+
+let logger = () => {}
+
+export async function morph(from, toHtml, options) {
+ // We're defining these globals and methods inside this function (instead of outside)
+ // because it's an async function and if run twice, they would overwrite
+ // each other.
+
+ let fromEl
+ let toEl
+ let key
+ ,lookahead
+ ,updating
+ ,updated
+ ,removing
+ ,removed
+ ,adding
+ ,added
+ ,debug
+
+
+ function breakpoint(message) {
+ if (! debug) return
+
+ logger((message || '').replace('\n', '\\n'), fromEl, toEl)
+
+ return new Promise(resolve => resolveStep = () => resolve())
+ }
+
+ function assignOptions(options = {}) {
+ let defaultGetKey = el => el.getAttribute('key')
+ let noop = () => {}
+
+ updating = options.updating || noop
+ updated = options.updated || noop
+ removing = options.removing || noop
+ removed = options.removed || noop
+ adding = options.adding || noop
+ added = options.added || noop
+ key = options.key || defaultGetKey
+ lookahead = options.lookahead || false
+ debug = options.debug || false
+ }
+
+ async function patch(from, to) {
+ // This is a time saver, however, it won't catch differences in nested tags.
+ // I'm leaving this here as I believe it's an important speed improvement, I just
+ // don't see a way to enable it currently:
+ //
+ // if (from.isEqualNode(to)) return
+
+ if (differentElementNamesTypesOrKeys(from, to)) {
+ let result = patchElement(from, to)
+
+ await breakpoint('Swap elements')
+
+ return result
+ }
+
+ let updateChildrenOnly = false
+
+ if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
+
+ window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
+
+ if (textOrComment(to)) {
+ await patchNodeValue(from, to)
+ updated(from, to)
+
+ return
+ }
+
+ if (! updateChildrenOnly) {
+ await patchAttributes(from, to)
+ }
+
+ updated(from, to)
+
+ await patchChildren(from, to)
+ }
+
+ function differentElementNamesTypesOrKeys(from, to) {
+ return from.nodeType != to.nodeType
+ || from.nodeName != to.nodeName
+ || getKey(from) != getKey(to)
+ }
+
+ function patchElement(from, to) {
+ if (shouldSkip(removing, from)) return
+
+ let toCloned = to.cloneNode(true)
+
+ if (shouldSkip(adding, toCloned)) return
+
+ dom(from).replace(toCloned)
+
+ removed(from)
+ added(toCloned)
+ }
+
+ async function patchNodeValue(from, to) {
+ let value = to.nodeValue
+
+ if (from.nodeValue !== value) {
+ from.nodeValue = value
+
+ await breakpoint('Change text node to: ' + value)
+ }
+ }
+
+ async function patchAttributes(from, to) {
+ if (from._x_isShown && ! to._x_isShown) {
+ return
+ }
+ if (! from._x_isShown && to._x_isShown) {
+ return
+ }
+
+ let domAttributes = Array.from(from.attributes)
+ let toAttributes = Array.from(to.attributes)
+
+ for (let i = domAttributes.length - 1; i >= 0; i--) {
+ let name = domAttributes[i].name;
+
+ if (! to.hasAttribute(name)) {
+ from.removeAttribute(name)
+
+ await breakpoint('Remove attribute')
+ }
+ }
+
+ for (let i = toAttributes.length - 1; i >= 0; i--) {
+ let name = toAttributes[i].name
+ let value = toAttributes[i].value
+
+ if (from.getAttribute(name) !== value) {
+ from.setAttribute(name, value)
+
+ await breakpoint(`Set [${name}] attribute to: "${value}"`)
+ }
+ }
+ }
+
+ async function patchChildren(from, to) {
+ let domChildren = from.childNodes
+ let toChildren = to.childNodes
+
+ let toKeyToNodeMap = keyToMap(toChildren)
+ let domKeyDomNodeMap = keyToMap(domChildren)
+
+ let currentTo = dom(to).nodes().first()
+ let currentFrom = dom(from).nodes().first()
+
+ let domKeyHoldovers = {}
+
+ let isInsideWall = false
+
+ while (currentTo) {
+ // If ""
+ if (
+ currentTo.nodeType === 8
+ && currentTo.textContent === ' end '
+ ) {
+ isInsideWall = false
+ currentTo = dom(currentTo).nodes().next()
+ currentFrom = dom(currentFrom).nodes().next()
+ continue
+ }
+
+ if (insideWall)
+
+ if (isInsideWall) {
+ console.log(currentFrom, currentTo)
+ }
+
+ let toKey = getKey(currentTo)
+ let domKey = getKey(currentFrom)
+
+ // Add new elements
+ if (! currentFrom) {
+ if (toKey && domKeyHoldovers[toKey]) {
+ let holdover = domKeyHoldovers[toKey]
+
+ dom(from).append(holdover)
+ currentFrom = holdover
+
+ await breakpoint('Add element (from key)')
+ } else {
+ let added = addNodeTo(currentTo, from) || {}
+
+ await breakpoint('Add element: ' + (added.outerHTML || added.nodeValue))
+
+ currentTo = dom(currentTo).nodes().next()
+
+ continue
+ }
+ }
+
+ // If ""
+ if (
+ currentTo.nodeType === 8
+ && currentTo.textContent === ' if '
+ && currentFrom.nodeType === 8
+ && currentFrom.textContent === ' if '
+ ) {
+ isInsideWall = true
+ currentTo = dom(currentTo).nodes().next()
+ currentFrom = dom(currentFrom).nodes().next()
+ continue
+ }
+
+ if (lookahead) {
+ let nextToElementSibling = dom(currentTo).next()
+
+ let found = false
+
+ while (!found && nextToElementSibling) {
+ if (currentFrom.isEqualNode(nextToElementSibling)) {
+ found = true
+
+ currentFrom = addNodeBefore(currentTo, currentFrom)
+
+ domKey = getKey(currentFrom)
+
+ await breakpoint('Move element (lookahead)')
+ }
+
+ nextToElementSibling = dom(nextToElementSibling).next()
+ }
+ }
+
+ if (toKey !== domKey) {
+ if (! toKey && domKey) {
+ domKeyHoldovers[domKey] = currentFrom
+ currentFrom = addNodeBefore(currentTo, currentFrom)
+ domKeyHoldovers[domKey].remove()
+ currentFrom = dom(currentFrom).nodes().next()
+ currentTo = dom(currentTo).nodes().next()
+
+ await breakpoint('No "to" key')
+
+ continue
+ }
+
+ if (toKey && ! domKey) {
+ if (domKeyDomNodeMap[toKey]) {
+ currentFrom = dom(currentFrom).replace(domKeyDomNodeMap[toKey])
+
+ await breakpoint('No "from" key')
+ }
+ }
+
+ if (toKey && domKey) {
+ domKeyHoldovers[domKey] = currentFrom
+ let domKeyNode = domKeyDomNodeMap[toKey]
+
+ if (domKeyNode) {
+ currentFrom = dom(currentFrom).replace(domKeyNode)
+
+ await breakpoint('Move "from" key')
+ } else {
+ domKeyHoldovers[domKey] = currentFrom
+ currentFrom = addNodeBefore(currentTo, currentFrom)
+ domKeyHoldovers[domKey].remove()
+ currentFrom = dom(currentFrom).next()
+ currentTo = dom(currentTo).next()
+
+ await breakpoint('Swap elements with keys')
+
+ continue
+ }
+ }
+ }
+
+ // Get next from sibling before patching in case the node is replaced
+ let currentFromNext = currentFrom && dom(currentFrom).nodes().next()
+
+ // Patch elements
+ await patch(currentFrom, currentTo)
+
+ currentTo = currentTo && dom(currentTo).nodes().next()
+ currentFrom = currentFromNext
+ }
+
+ // Cleanup extra forms.
+ let removals = []
+
+ // We need to collect the "removals" first before actually
+ // removing them so we don't mess with the order of things.
+ while (currentFrom) {
+ if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
+
+ currentFrom = dom(currentFrom).nodes().next()
+ }
+
+ // Now we can do the actual removals.
+ while (removals.length) {
+ let domForRemoval = removals.shift()
+
+ domForRemoval.remove()
+
+ await breakpoint('remove el')
+
+ removed(domForRemoval)
+ }
+ }
+
+ function getKey(el) {
+ return el && el.nodeType === 1 && key(el)
+ }
+
+ function keyToMap(els) {
+ let map = {}
+
+ els.forEach(el => {
+ let theKey = getKey(el)
+
+ if (theKey) {
+ map[theKey] = el
+ }
+ })
+
+ return map
+ }
+
+ function addNodeTo(node, parent) {
+ if(! shouldSkip(adding, node)) {
+ let clone = node.cloneNode(true)
+
+ dom(parent).append(clone)
+
+ added(clone)
+
+ return clone
+ }
+
+ return null;
+ }
+
+ function addNodeBefore(node, beforeMe) {
+ if(! shouldSkip(adding, node)) {
+ let clone = node.cloneNode(true)
+
+ dom(beforeMe).before(clone)
+
+ added(clone)
+
+ return clone
+ }
+
+ return beforeMe
+ }
+
+ // Finally we morph the element
+
+ assignOptions(options)
+
+ fromEl = from
+ toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
+
+ // If there is no x-data on the element we're morphing,
+ // let's seed it with the outer Alpine scope on the page.
+ if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
+ toEl._x_dataStack = window.Alpine.closestDataStack(from)
+
+ toEl._x_dataStack && window.Alpine.clone(from, toEl)
+ }
+
+ await breakpoint()
+
+ await patch(from, toEl)
+
+ // Release these for the garbage collector.
+ fromEl = undefined
+ toEl = undefined
+
+ return from
+}
+
+morph.step = () => resolveStep()
+morph.log = (theLogger) => {
+ logger = theLogger
+}
+
+function shouldSkip(hook, ...args) {
+ let skip = false
+
+ hook(...args, () => skip = true)
+
+ return skip
+}
+
+function initializeAlpineOnTo(from, to, childrenOnly) {
+ if (from.nodeType !== 1) return
+
+ // If the element we are updating is an Alpine component...
+ if (from._x_dataStack) {
+ // Then temporarily clone it (with it's data) to the "to" element.
+ // This should simulate backend Livewire being aware of Alpine changes.
+ window.Alpine.clone(from, to)
+ }
+}
diff --git a/packages/navigate/builds/cdn.js b/packages/navigate/builds/cdn.js
new file mode 100644
index 000000000..69e8edabf
--- /dev/null
+++ b/packages/navigate/builds/cdn.js
@@ -0,0 +1,5 @@
+import navigate from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+ window.Alpine.plugin(navigate)
+})
diff --git a/packages/navigate/builds/module.js b/packages/navigate/builds/module.js
new file mode 100644
index 000000000..8c5c3cb09
--- /dev/null
+++ b/packages/navigate/builds/module.js
@@ -0,0 +1,5 @@
+import navigate from '../src/index.js'
+
+export default navigate
+
+export { navigate }
diff --git a/packages/navigate/package.json b/packages/navigate/package.json
new file mode 100644
index 000000000..440dba188
--- /dev/null
+++ b/packages/navigate/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@alpinejs/navigate",
+ "version": "3.10.2",
+ "description": "An Alpine plugin for adding SPA-like navigation to your app.",
+ "author": "Caleb Porzio",
+ "license": "MIT",
+ "main": "dist/module.cjs.js",
+ "module": "dist/module.esm.js",
+ "dependencies": {
+ "nprogress": "^0.2.0"
+ }
+}
diff --git a/packages/navigate/src/index.js b/packages/navigate/src/index.js
new file mode 100644
index 000000000..d08ca39cf
--- /dev/null
+++ b/packages/navigate/src/index.js
@@ -0,0 +1 @@
+// This plugin has been moved into the livewire/livewire repository until it's more stable and ready to tag.
diff --git a/packages/persist/builds/module.js b/packages/persist/builds/module.js
index 2872c9fc7..7b011c230 100644
--- a/packages/persist/builds/module.js
+++ b/packages/persist/builds/module.js
@@ -1,3 +1,5 @@
import persist from '../src/index.js'
export default persist
+
+export { persist }
diff --git a/packages/persist/package.json b/packages/persist/package.json
index 72894b734..b2339a9da 100644
--- a/packages/persist/package.json
+++ b/packages/persist/package.json
@@ -1,11 +1,21 @@
{
"name": "@alpinejs/persist",
- "version": "3.10.2",
+ "version": "3.15.1",
"description": "Persist Alpine data across page loads",
+ "homepage": "https://alpinejs.dev/plugins/persist",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/persist"
+ },
"author": "Caleb Porzio",
"license": "MIT",
"main": "dist/module.cjs.js",
"module": "dist/module.esm.js",
"unpkg": "dist/cdn.min.js",
+ "exports": {
+ "import": "./dist/module.esm.js",
+ "require": "./dist/module.cjs.js"
+ },
"dependencies": {}
}
diff --git a/packages/persist/src/index.js b/packages/persist/src/index.js
index 096ac7053..738f3ed6b 100644
--- a/packages/persist/src/index.js
+++ b/packages/persist/src/index.js
@@ -1,7 +1,21 @@
export default function (Alpine) {
let persist = () => {
let alias
- let storage = localStorage
+ let storage
+
+ try {
+ storage = localStorage
+ } catch (e) {
+ console.error(e)
+ console.warn('Alpine: $persist is using temporary storage since localStorage is unavailable.')
+
+ let dummy = new Map();
+
+ storage = {
+ getItem: dummy.get.bind(dummy),
+ setItem: dummy.set.bind(dummy)
+ }
+ }
return Alpine.interceptor((initialValue, getter, setter, path, key) => {
let lookup = alias || `_x_${path}`
@@ -29,6 +43,21 @@ export default function (Alpine) {
Object.defineProperty(Alpine, '$persist', { get: () => persist() })
Alpine.magic('persist', persist)
+ Alpine.persist = (key, { get, set }, storage = localStorage) => {
+ let initial = storageHas(key, storage)
+ ? storageGet(key, storage)
+ : get()
+
+ set(initial)
+
+ Alpine.effect(() => {
+ let value = get()
+
+ storageSet(key, value, storage)
+
+ set(value)
+ })
+ }
}
function storageHas(key, storage) {
@@ -36,7 +65,11 @@ function storageHas(key, storage) {
}
function storageGet(key, storage) {
- return JSON.parse(storage.getItem(key, storage))
+ let value = storage.getItem(key)
+
+ if (value === undefined) return
+
+ return JSON.parse(value)
}
function storageSet(key, value, storage) {
diff --git a/packages/portal/builds/cdn.js b/packages/portal/builds/cdn.js
deleted file mode 100644
index e3529e7fe..000000000
--- a/packages/portal/builds/cdn.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import portal from '../src/index.js'
-
-document.addEventListener('alpine:init', () => {
- window.Alpine.plugin(portal)
-})
diff --git a/packages/portal/builds/module.js b/packages/portal/builds/module.js
deleted file mode 100644
index bc39614eb..000000000
--- a/packages/portal/builds/module.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import portal from './../src/index.js'
-
-export default portal
diff --git a/packages/portal/package.json b/packages/portal/package.json
deleted file mode 100644
index 7e654070f..000000000
--- a/packages/portal/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "@alpinejs/portal",
- "version": "3.6.1-beta.0",
- "description": "Send Alpine templates to other parts of the DOM",
- "author": "Caleb Porzio",
- "license": "MIT",
- "main": "dist/module.cjs.js",
- "module": "dist/module.esm.js",
- "unpkg": "dist/cdn.min.js",
- "dependencies": {}
-}
diff --git a/packages/portal/src/index.js b/packages/portal/src/index.js
deleted file mode 100644
index fd45badd8..000000000
--- a/packages/portal/src/index.js
+++ /dev/null
@@ -1,62 +0,0 @@
-export default function (Alpine) {
- let portals = new MapSet
-
- Alpine.directive('portal', (el, { expression }, { effect, cleanup }) => {
- let clone = el.content.cloneNode(true).firstElementChild
- // Add reference to element on {
- // Forward event listeners:
- if (el._x_forwardEvents) {
- el._x_forwardEvents.forEach(eventName => {
- clone.addEventListener(eventName, e => {
- e.stopPropagation()
-
- el.dispatchEvent(new e.constructor(e.type, e))
- })
- })
- }
-
- Alpine.addScopeToNode(clone, {}, el)
-
- Alpine.mutateDom(() => {
- target.before(clone)
-
- Alpine.initTree(clone)
- })
-
- cleanup(() => {
- clone.remove()
-
- portals.delete(expression, init)
- })
- }
-
- portals.add(expression, init)
- })
-
- Alpine.addInitSelector(() => `[${Alpine.prefixed('portal-target')}]`)
- Alpine.directive('portal-target', (el, { expression }) => {
- portals.each(expression, initPortal => initPortal(el))
- })
-}
-
-class MapSet {
- map = new Map
-
- get(name) {
- if (! this.map.has(name)) this.map.set(name, new Set)
-
- return this.map.get(name)
- }
-
- add(name, value) { this.get(name).add(value) }
-
- each(name, callback) { this.map.get(name).forEach(callback) }
-
- delete(name, value) {
- this.map.get(name).delete(value)
- }
-}
diff --git a/packages/resize/builds/cdn.js b/packages/resize/builds/cdn.js
new file mode 100644
index 000000000..b8a3f5b76
--- /dev/null
+++ b/packages/resize/builds/cdn.js
@@ -0,0 +1,5 @@
+import resize from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+ window.Alpine.plugin(resize)
+})
diff --git a/packages/resize/builds/module.js b/packages/resize/builds/module.js
new file mode 100644
index 000000000..fc370a076
--- /dev/null
+++ b/packages/resize/builds/module.js
@@ -0,0 +1,5 @@
+import resize from './../src/index.js'
+
+export default resize
+
+export { resize }
diff --git a/packages/resize/package.json b/packages/resize/package.json
new file mode 100644
index 000000000..5d9ce5458
--- /dev/null
+++ b/packages/resize/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@alpinejs/resize",
+ "version": "3.15.1",
+ "description": "Trigger JavaScript when an element is resized on the page",
+ "homepage": "https://alpinejs.dev/plugins/intersect",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/resize"
+ },
+ "author": "Caleb Porzio",
+ "license": "MIT",
+ "main": "dist/module.cjs.js",
+ "module": "dist/module.esm.js",
+ "unpkg": "dist/cdn.min.js",
+ "dependencies": {}
+}
diff --git a/packages/resize/src/index.js b/packages/resize/src/index.js
new file mode 100644
index 000000000..24d647595
--- /dev/null
+++ b/packages/resize/src/index.js
@@ -0,0 +1,59 @@
+export default function (Alpine) {
+ Alpine.directive('resize', Alpine.skipDuringClone((el, { value, expression, modifiers }, { evaluateLater, cleanup }) => {
+ let evaluator = evaluateLater(expression)
+
+ let evaluate = (width, height) => {
+ evaluator(() => {}, { scope: { '$width': width, '$height': height }})
+ }
+
+ let off = modifiers.includes('document')
+ ? onDocumentResize(evaluate)
+ : onElResize(el, evaluate)
+
+ cleanup(() => off())
+ }))
+}
+
+function onElResize(el, callback) {
+ let observer = new ResizeObserver((entries) => {
+ let [width, height] = dimensions(entries)
+
+ callback(width, height)
+ })
+
+ observer.observe(el)
+
+ return () => observer.disconnect()
+}
+
+let documentResizeObserver
+let documentResizeObserverCallbacks = new Set
+
+function onDocumentResize(callback) {
+ documentResizeObserverCallbacks.add(callback)
+
+ if (! documentResizeObserver) {
+ documentResizeObserver = new ResizeObserver((entries) => {
+ let [width, height] = dimensions(entries)
+
+ documentResizeObserverCallbacks.forEach(i => i(width, height))
+ })
+
+ documentResizeObserver.observe(document.documentElement)
+ }
+
+ return () => {
+ documentResizeObserverCallbacks.delete(callback)
+ }
+}
+
+function dimensions(entries) {
+ let width, height
+
+ for (let entry of entries) {
+ width = entry.borderBoxSize[0].inlineSize
+ height = entry.borderBoxSize[0].blockSize
+ }
+
+ return [width, height]
+}
diff --git a/packages/sort/builds/cdn.js b/packages/sort/builds/cdn.js
new file mode 100644
index 000000000..aeb4062f5
--- /dev/null
+++ b/packages/sort/builds/cdn.js
@@ -0,0 +1,5 @@
+import sort from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+ window.Alpine.plugin(sort)
+})
diff --git a/packages/sort/builds/module.js b/packages/sort/builds/module.js
new file mode 100644
index 000000000..8fd7b8339
--- /dev/null
+++ b/packages/sort/builds/module.js
@@ -0,0 +1,5 @@
+import sort from '../src/index.js'
+
+export default sort
+
+export { sort }
diff --git a/packages/sort/package.json b/packages/sort/package.json
new file mode 100644
index 000000000..11219dd34
--- /dev/null
+++ b/packages/sort/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@alpinejs/sort",
+ "version": "3.15.1",
+ "description": "An Alpine plugin for drag sorting items on the page",
+ "homepage": "https://alpinejs.dev/plugins/sort",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/sort"
+ },
+ "author": "Caleb Porzio",
+ "license": "MIT",
+ "main": "dist/module.cjs.js",
+ "module": "dist/module.esm.js"
+}
diff --git a/packages/sort/src/index.js b/packages/sort/src/index.js
new file mode 100644
index 000000000..568eed32b
--- /dev/null
+++ b/packages/sort/src/index.js
@@ -0,0 +1,192 @@
+import Sortable from 'sortablejs'
+import { walk } from '../../alpinejs/src/utils/walk'
+
+export default function (Alpine) {
+ Alpine.directive('sort', (el, { value, modifiers, expression }, { effect, evaluate, evaluateLater, cleanup }) => {
+ if (value === 'config') {
+ return // This will get handled by the main directive...
+ }
+
+ if (value === 'handle') {
+ return // This will get handled by the main directive...
+ }
+
+ if (value === 'group') {
+ return // This will get handled by the main directive...
+ }
+
+ // Supporting both `x-sort:item` AND `x-sort:key` (key for BC)...
+ if (value === 'key' || value === 'item') {
+ if ([undefined, null, ''].includes(expression)) return
+
+ el._x_sort_key = evaluate(expression)
+
+ return
+ }
+
+ let preferences = {
+ hideGhost: ! modifiers.includes('ghost'),
+ useHandles: !! el.querySelector('[x-sort\\:handle],[wire\\:sort\\:handle]'),
+ group: getGroupName(el, modifiers),
+ }
+
+ let handleSort = generateSortHandler(expression, evaluateLater)
+
+ let config = getConfigurationOverrides(el, modifiers, evaluate)
+
+ let sortable = initSortable(el, config, preferences, (key, position) => {
+ handleSort(key, position)
+ })
+
+ cleanup(() => sortable.destroy())
+ })
+}
+
+function generateSortHandler(expression, evaluateLater) {
+ // No handler was passed to x-sort...
+ if ([undefined, null, ''].includes(expression)) return () => {}
+
+ let handle = evaluateLater(expression)
+
+ return (key, position) => {
+ // In the case of `x-sort="handleSort"`, let us call it manually...
+ Alpine.dontAutoEvaluateFunctions(() => {
+ handle(
+ // If a function is returned, call it with the key/position params...
+ received => {
+ if (typeof received === 'function') received(key, position)
+ },
+ // Provide $key and $position to the scope in case they want to call their own function...
+ { scope: {
+ // Supporting both `$item` AND `$key` ($key for BC)...
+ $key: key,
+ $item: key,
+ $position: position,
+ } },
+ )
+ })
+ }
+}
+
+function getConfigurationOverrides(el, modifiers, evaluate)
+{
+ if (el.hasAttribute('x-sort:config')) {
+ return evaluate(el.getAttribute('x-sort:config'))
+ }
+
+ if (el.hasAttribute('wire:sort:config')) {
+ return evaluate(el.getAttribute('wire:sort:config'))
+ }
+
+ return {}
+}
+
+function initSortable(el, config, preferences, handle) {
+ let ghostRef
+
+ let options = {
+ animation: 150,
+
+ handle: preferences.useHandles ? '[x-sort\\:handle],[wire\\:sort\\:handle]' : null,
+
+ group: preferences.group,
+
+ scroll: true,
+
+ forceAutoScrollFallback: true,
+
+ scrollSensitivity: 50,
+
+ // This is here so that if a div containing inputs or buttons has x-sort:ignore, it will not prevent interaction...
+ preventOnFilter: false,
+
+ filter(e) {
+ if (e.target.hasAttribute('x-sort:ignore') || e.target.hasAttribute('wire:sort:ignore')) return true
+ if (e.target.closest('[x-sort\\:ignore]') || e.target.closest('[wire\\:sort\\:ignore]')) return true
+
+ // Normally, we would just filter out any elements without `[x-sort:item]`
+ // on them, however for backwards compatibility (when we didn't require
+ // `[x-sort:item]`) we will check for x-sort\\:item being used at all
+ if (! el.querySelector('[x-sort\\:item],[wire\\:sort\\:item]')) return false
+
+ let itemHasAttribute = e.target.closest('[x-sort\\:item],[wire\\:sort\\:item]')
+
+ return itemHasAttribute ? false : true
+ },
+
+ onSort(e) {
+ // If item has been dragged between groups...
+ if (e.from !== e.to) {
+ // And this is the group it was dragged FROM...
+ if (e.to !== e.target) {
+ return // Don't do anything, because the other group will call the handler...
+ }
+ }
+
+ let key = undefined
+
+ // Support `x-sort:item` not being the first child...
+ walk(e.item, (el, skip) => {
+ if (key !== undefined) return
+
+ if (el._x_sort_key) {
+ key = el._x_sort_key
+
+ skip()
+ }
+ })
+
+ let position = e.newIndex
+
+ if (key !== undefined || key !== null) {
+ handle(key, position)
+ }
+ },
+
+ onStart() {
+ document.body.classList.add('sorting')
+
+ ghostRef = document.querySelector('.sortable-ghost')
+
+ if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '0'
+ },
+
+ onEnd() {
+ document.body.classList.remove('sorting')
+
+ if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '1'
+
+ ghostRef = undefined
+
+ keepElementsWithinMorphMarkers(el)
+ }
+ }
+
+ return new Sortable(el, { ...options, ...config })
+}
+
+function keepElementsWithinMorphMarkers(el) {
+ let cursor = el.firstChild
+
+ while (cursor.nextSibling) {
+ if (cursor.textContent.trim() === '[if ENDBLOCK]> {
+ window.Alpine.plugin(ui)
+})
diff --git a/packages/ui/builds/module.js b/packages/ui/builds/module.js
new file mode 100644
index 000000000..dbf858d93
--- /dev/null
+++ b/packages/ui/builds/module.js
@@ -0,0 +1,5 @@
+import ui from '../src/index.js'
+
+export default ui
+
+export { ui }
diff --git a/packages/ui/demo/index.html b/packages/ui/demo/index.html
new file mode 100644
index 000000000..cebcfcc99
--- /dev/null
+++ b/packages/ui/demo/index.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Examples
+
+
+
+
+
+
+
+
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 000000000..687688b16
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@alpinejs/ui",
+ "version": "3.15.1",
+ "description": "Headless UI components for Alpine",
+ "homepage": "https://alpinejs.dev/components#headless",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alpinejs/alpine.git",
+ "directory": "packages/ui"
+ },
+ "author": "Caleb Porzio",
+ "license": "MIT",
+ "main": "dist/module.cjs.js",
+ "module": "dist/module.esm.js",
+ "unpkg": "dist/cdn.min.js",
+ "devDependencies": {}
+}
diff --git a/packages/ui/src/combobox.js b/packages/ui/src/combobox.js
new file mode 100644
index 000000000..d2e6c116f
--- /dev/null
+++ b/packages/ui/src/combobox.js
@@ -0,0 +1,510 @@
+import { generateContext, renderHiddenInputs } from './list-context'
+
+export default function (Alpine) {
+ Alpine.directive('combobox', (el, directive, { evaluate }) => {
+ if (directive.value === 'input') handleInput(el, Alpine)
+ else if (directive.value === 'button') handleButton(el, Alpine)
+ else if (directive.value === 'label') handleLabel(el, Alpine)
+ else if (directive.value === 'options') handleOptions(el, Alpine)
+ else if (directive.value === 'option') handleOption(el, Alpine, directive, evaluate)
+ else handleRoot(el, Alpine)
+ }).before('bind')
+
+ Alpine.magic('combobox', el => {
+ let data = Alpine.$data(el)
+
+ return {
+ get value() {
+ return data.__value
+ },
+ get isOpen() {
+ return data.__isOpen
+ },
+ get isDisabled() {
+ return data.__isDisabled
+ },
+ get activeOption() {
+ let active = data.__context?.getActiveItem()
+
+ return active && active.value
+ },
+ get activeIndex() {
+ let active = data.__context?.getActiveItem()
+
+ if (active) {
+ return Object.values(Alpine.raw(data.__context.items)).findIndex(i => Alpine.raw(active) == Alpine.raw(i))
+ }
+
+ return null
+ },
+ }
+ })
+
+ Alpine.magic('comboboxOption', el => {
+ let data = Alpine.$data(el)
+
+ // It's not great depending on the existence of the attribute in the DOM
+ // but it's probably the fastest and most reliable at this point...
+ let optionEl = Alpine.findClosest(el, i => {
+ return i.hasAttribute('x-combobox:option')
+ })
+
+ if (! optionEl) throw 'No x-combobox:option directive found...'
+
+ return {
+ get isActive() {
+ return data.__context.isActiveKey(Alpine.$data(optionEl).__optionKey)
+ },
+ get isSelected() {
+ return data.__isSelected(optionEl)
+ },
+ get isDisabled() {
+ return data.__context.isDisabled(Alpine.$data(optionEl).__optionKey)
+ },
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
+ 'x-modelable': '__value',
+
+ // Initialize...
+ 'x-data'() {
+ return {
+ /**
+ * Combobox state...
+ */
+ __ready: false,
+ __value: null,
+ __isOpen: false,
+ __context: undefined,
+ __isMultiple: undefined,
+ __isStatic: false,
+ __isDisabled: undefined,
+ __displayValue: undefined,
+ __compareBy: null,
+ __inputName: null,
+ __isTyping: false,
+ __hold: false,
+
+ /**
+ * Combobox initialization...
+ */
+ init() {
+ this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
+ this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
+ this.__inputName = Alpine.extractProp(el, 'name', null)
+ this.__nullable = Alpine.extractProp(el, 'nullable', false)
+ this.__compareBy = Alpine.extractProp(el, 'by')
+
+ this.__context = generateContext(Alpine, this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst())
+
+ let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
+
+ this.__value = defaultValue
+
+ // We have to wait again until after the "ready" processes are finished
+ // to settle up currently selected Values (this prevents this next bit
+ // of code from running multiple times on startup...)
+ queueMicrotask(() => {
+ Alpine.effect(() => {
+ // Everytime the value changes, we need to re-render the hidden inputs,
+ // if a user passed the "name" prop...
+ this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value)
+ })
+
+ // Set initial combobox values in the input and properly clear it when the value is reset programmatically...
+ Alpine.effect(() => ! this.__isMultiple && this.__resetInput())
+ })
+ },
+ __startTyping() {
+ this.__isTyping = true
+ },
+ __stopTyping() {
+ this.__isTyping = false
+ },
+ __resetInput() {
+ let input = this.$refs.__input
+
+ if (! input) return
+
+ let value = this.__getCurrentValue()
+
+ input.value = value
+ },
+ __getCurrentValue() {
+ if (! this.$refs.__input) return ''
+ if (! this.__value) return ''
+ if (this.__displayValue) return this.__displayValue(this.__value)
+ if (typeof this.__value === 'string') return this.__value
+ return ''
+ },
+ __open() {
+ if (this.__isOpen) return
+ this.__isOpen = true
+
+ let input = this.$refs.__input
+
+ // Make sure we always notify the parent component
+ // that the starting value is the empty string
+ // when we open the combobox (ignoring any existing value)
+ // to avoid inconsistent displaying.
+ // Setting the input to empty and back to the real value
+ // also helps VoiceOver to annunce the content properly
+ // See https://github.com/tailwindlabs/headlessui/pull/2153
+ if (input) {
+ let value = input.value
+ let { selectionStart, selectionEnd, selectionDirection } = input
+ input.value = ''
+ input.dispatchEvent(new Event('change'))
+ input.value = value
+ if (selectionDirection !== null) {
+ input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
+ } else {
+ input.setSelectionRange(selectionStart, selectionEnd)
+ }
+ }
+
+ // 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.__input.focus({ preventScroll: true })
+ this.__activateSelectedOrFirst()
+ })
+ },
+ __close() {
+ this.__isOpen = false
+
+ this.__context.deactivate()
+ },
+ __activateSelectedOrFirst(activateSelected = true) {
+ if (! this.__isOpen) return
+
+ if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return
+
+ let firstSelectedValue
+
+ if (this.__isMultiple) {
+ let selectedItem = this.__context.getItemsByValues(this.__value)
+
+ firstSelectedValue = selectedItem.length ? selectedItem[0].value : null
+ } else {
+ firstSelectedValue = this.__value
+ }
+
+ let firstSelected = null
+ if (activateSelected && firstSelectedValue) {
+ firstSelected = this.__context.getItemByValue(firstSelectedValue)
+ }
+
+ if (firstSelected) {
+ this.__context.activateAndScrollToKey(firstSelected.key)
+ return
+ }
+
+ this.__context.activateAndScrollToKey(this.__context.firstKey())
+ },
+ __selectActive() {
+ let active = this.__context.getActiveItem()
+ if (active) this.__toggleSelected(active.value)
+ },
+ __selectOption(el) {
+ let item = this.__context.getItemByEl(el)
+
+ if (item) this.__toggleSelected(item.value)
+ },
+ __isSelected(el) {
+ let item = this.__context.getItemByEl(el)
+
+ if (! item) return false
+ if (item.value === null || item.value === undefined) return false
+
+ return this.__hasSelected(item.value)
+ },
+ __toggleSelected(value) {
+ if (! this.__isMultiple) {
+ this.__value = value
+
+ return
+ }
+
+ let index = this.__value.findIndex(j => this.__compare(j, value))
+
+ if (index === -1) {
+ this.__value.push(value)
+ } else {
+ this.__value.splice(index, 1)
+ }
+ },
+ __hasSelected(value) {
+ if (! this.__isMultiple) return this.__compare(this.__value, value)
+
+ return this.__value.some(i => this.__compare(i, value))
+ },
+ __compare(a, b) {
+ let by = this.__compareBy
+
+ if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
+
+ if (typeof by === 'string') {
+ let property = by
+ 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)
+ },
+ }
+ },
+ // Register event listeners..
+ '@mousedown.window'(e) {
+ if (
+ !! ! this.$refs.__input.contains(e.target)
+ && ! this.$refs.__button.contains(e.target)
+ && ! this.$refs.__options.contains(e.target)
+ ) {
+ this.__close()
+ this.__resetInput()
+ }
+ }
+ })
+}
+
+function handleInput(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-ref': '__input',
+ ':id'() { return this.$id('alpine-combobox-input') },
+
+ // Accessibility attributes...
+ 'role': 'combobox',
+ 'tabindex': '0',
+ 'aria-autocomplete': 'list',
+
+ // We need to defer this evaluation a bit because $refs that get declared later
+ // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
+ async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
+ ':aria-expanded'() { return this.$data.__isDisabled ? undefined : this.$data.__isOpen },
+ ':aria-multiselectable'() { return this.$data.__isMultiple ? true : undefined },
+ ':aria-activedescendant'() {
+ if (! this.$data.__context.hasActive()) return
+
+ let active = this.$data.__context.getActiveItem()
+
+ return active ? active.el.id : null
+ },
+ ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
+
+ // Initialize...
+ 'x-init'() {
+ let displayValueFn = Alpine.extractProp(this.$el, 'display-value')
+ if (displayValueFn) this.$data.__displayValue = displayValueFn
+ },
+
+ // Register listeners...
+ '@input.stop'(e) {
+ if(this.$data.__isTyping) {
+ this.$data.__open();
+ this.$dispatch('change')
+ }
+ },
+ '@blur'() { this.$data.__stopTyping(false) },
+ '@keydown'(e) {
+ queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, false, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state))
+ },
+ '@keydown.enter.prevent.stop'() {
+ this.$data.__selectActive()
+
+ this.$data.__stopTyping()
+
+ if (! this.$data.__isMultiple) {
+ this.$data.__close()
+ this.$data.__resetInput()
+ }
+ },
+ '@keydown.escape.prevent'(e) {
+ if (! this.$data.__static) e.stopPropagation()
+
+ this.$data.__stopTyping()
+ this.$data.__close()
+ this.$data.__resetInput()
+
+ },
+ '@keydown.tab'() {
+ this.$data.__stopTyping()
+ if (this.$data.__isOpen) { this.$data.__close() }
+ this.$data.__resetInput()
+ },
+ '@keydown.backspace'(e) {
+ if (this.$data.__isMultiple) return
+ if (! this.$data.__nullable) return
+
+ let input = e.target
+
+ requestAnimationFrame(() => {
+ if (input.value === '') {
+ this.$data.__value = null
+
+ let options = this.$refs.__options
+ if (options) {
+ options.scrollTop = 0
+ }
+
+ this.$data.__context.deactivate()
+ }
+ })
+ },
+ })
+}
+
+function handleButton(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-ref': '__button',
+ ':id'() { return this.$id('alpine-combobox-button') },
+
+ // Accessibility attributes...
+ 'aria-haspopup': 'true',
+ // We need to defer this evaluation a bit because $refs that get declared later
+ // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
+ async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
+ ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
+ ':aria-expanded'() { return this.$data.__isDisabled ? null : this.$data.__isOpen },
+ ':disabled'() { return this.$data.__isDisabled },
+ 'tabindex': '-1',
+
+ // Initialize....
+ 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
+
+ // Register listeners...
+ '@click'(e) {
+ if (this.$data.__isDisabled) return
+ if (this.$data.__isOpen) {
+ this.$data.__close()
+ this.$data.__resetInput()
+ } else {
+ e.preventDefault()
+ this.$data.__open()
+ }
+
+ this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+ },
+ })
+}
+
+function handleLabel(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-ref': '__label',
+ ':id'() { return this.$id('alpine-combobox-label') },
+ '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
+ })
+}
+
+function handleOptions(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-ref': '__options',
+ ':id'() { return this.$id('alpine-combobox-options') },
+
+ // Accessibility attributes...
+ 'role': 'listbox',
+ ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
+
+ // Initialize...
+ 'x-init'() {
+ this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
+
+ if (Alpine.bound(this.$el, 'hold')) {
+ this.$data.__hold = true;
+ }
+ },
+
+ 'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
+ })
+}
+
+function handleOption(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-id'() { return ['alpine-combobox-option'] },
+ ':id'() { return this.$id('alpine-combobox-option') },
+
+ // Accessibility attributes...
+ 'role': 'option',
+ ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
+
+ // Only the active element should have aria-selected="true"...
+ 'x-effect'() {
+ this.$comboboxOption.isSelected
+ ? el.setAttribute('aria-selected', true)
+ : el.setAttribute('aria-selected', false)
+ },
+
+ ':aria-disabled'() { return this.$comboboxOption.isDisabled },
+
+ // Initialize...
+ 'x-data'() {
+ return {
+ '__optionKey': null,
+
+ init() {
+ this.__optionKey = (Math.random() + 1).toString(36).substring(7)
+
+ let value = Alpine.extractProp(this.$el, 'value')
+ let disabled = Alpine.extractProp(this.$el, 'disabled', false, false)
+
+ // memoize the context as it's not going to change
+ // and calling this.$data on mouse action is expensive
+ this.__context.registerItem(this.__optionKey, this.$el, value, disabled)
+ },
+ destroy() {
+ this.__context.unregisterItem(this.__optionKey)
+ }
+ }
+ },
+
+ // Register listeners...
+ '@click'() {
+ if (this.$comboboxOption.isDisabled) return;
+
+ this.__selectOption(this.$el)
+
+ if (! this.__isMultiple) {
+ this.__close()
+ this.__resetInput()
+ }
+
+ this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+ },
+ '@mouseenter'(e) {
+ this.__context.activateEl(this.$el)
+ },
+ '@mousemove'(e) {
+ if (this.__context.isActiveEl(this.$el)) return
+
+ this.__context.activateEl(this.$el)
+ },
+ '@mouseleave'(e) {
+ if (this.__hold) return
+
+ this.__context.deactivate()
+ },
+ })
+}
+
+// Little utility to defer a callback into the microtask queue...
+function microtask(callback) {
+ return new Promise(resolve => queueMicrotask(() => resolve(callback())))
+}
diff --git a/packages/ui/src/dialog.js b/packages/ui/src/dialog.js
new file mode 100644
index 000000000..05df18ec4
--- /dev/null
+++ b/packages/ui/src/dialog.js
@@ -0,0 +1,96 @@
+
+export default function (Alpine) {
+ Alpine.directive('dialog', (el, directive) => {
+ if (directive.value === 'overlay') handleOverlay(el, Alpine)
+ else if (directive.value === 'panel') handlePanel(el, Alpine)
+ else if (directive.value === 'title') handleTitle(el, Alpine)
+ else if (directive.value === 'description') handleDescription(el, Alpine)
+ else handleRoot(el, Alpine)
+ })
+
+ Alpine.magic('dialog', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ // Kept here for legacy. Remove after out of beta.
+ get open() {
+ return $data.__isOpen
+ },
+ get isOpen() {
+ return $data.__isOpen
+ },
+ close() {
+ $data.__close()
+ }
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-data'() {
+ return {
+ init() {
+ // If the user chose to use :open and @close instead of x-model.
+ (Alpine.bound(el, 'open') !== undefined) && Alpine.effect(() => {
+ this.__isOpenState = Alpine.bound(el, 'open')
+ })
+
+ if (Alpine.bound(el, 'initial-focus') !== undefined) this.$watch('__isOpenState', () => {
+ if (! this.__isOpenState) return
+
+ setTimeout(() => {
+ Alpine.bound(el, 'initial-focus').focus()
+ }, 0);
+ })
+ },
+ __isOpenState: false,
+ __close() {
+ if (Alpine.bound(el, 'open')) this.$dispatch('close')
+ else this.__isOpenState = false
+ },
+ get __isOpen() {
+ return Alpine.bound(el, 'static', this.__isOpenState)
+ },
+ }
+ },
+ 'x-modelable': '__isOpenState',
+ 'x-id'() { return ['alpine-dialog-title', 'alpine-dialog-description'] },
+ 'x-show'() { return this.__isOpen },
+ 'x-trap.inert.noscroll'() { return this.__isOpen },
+ '@keydown.escape'() { this.__close() },
+ ':aria-labelledby'() { return this.$id('alpine-dialog-title') },
+ ':aria-describedby'() { return this.$id('alpine-dialog-description') },
+ 'role': 'dialog',
+ 'aria-modal': 'true',
+ })
+}
+
+function handleOverlay(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { if (this.$data.__isOpen === undefined) console.warn('"x-dialog:overlay" is missing a parent element with "x-dialog".') },
+ 'x-show'() { return this.__isOpen },
+ '@click.prevent.stop'() { this.$data.__close() },
+ })
+}
+
+function handlePanel(el, Alpine) {
+ Alpine.bind(el, {
+ '@click.outside'() { this.$data.__close() },
+ 'x-show'() { return this.$data.__isOpen },
+ })
+}
+
+function handleTitle(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { if (this.$data.__isOpen === undefined) console.warn('"x-dialog:title" is missing a parent element with "x-dialog".') },
+ ':id'() { return this.$id('alpine-dialog-title') },
+ })
+}
+
+function handleDescription(el, Alpine) {
+ Alpine.bind(el, {
+ ':id'() { return this.$id('alpine-dialog-description') },
+ })
+}
+
diff --git a/packages/ui/src/disclosure.js b/packages/ui/src/disclosure.js
new file mode 100644
index 000000000..55baae313
--- /dev/null
+++ b/packages/ui/src/disclosure.js
@@ -0,0 +1,84 @@
+
+export default function (Alpine) {
+ Alpine.directive('disclosure', (el, directive) => {
+ if (! directive.value) handleRoot(el, Alpine)
+ else if (directive.value === 'panel') handlePanel(el, Alpine)
+ else if (directive.value === 'button') handleButton(el, Alpine)
+ }).before('bind')
+
+ Alpine.magic('disclosure', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ get isOpen() {
+ return $data.__isOpen
+ },
+ close() {
+ $data.__close()
+ }
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-modelable': '__isOpen',
+ 'x-data'() {
+ return {
+ // The panel will call this...
+ // We can't do this inside a microtask in x-init because, when default-open is set to "true",
+ // It will cause the panel to transition in for the first time, instead of showing instantly...
+ __determineDefaultOpenState() {
+ let defaultIsOpen = Boolean(Alpine.bound(this.$el, 'default-open', false))
+
+ if (defaultIsOpen) this.__isOpen = defaultIsOpen
+ },
+ __isOpen: false,
+ __close() {
+ this.__isOpen = false
+ },
+ __toggle() {
+ this.__isOpen = ! this.__isOpen
+ },
+ }
+ },
+ 'x-id'() { return ['alpine-disclosure-panel'] },
+ })
+}
+
+function handleButton(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() {
+ if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+ },
+ '@click'() {
+ this.$data.__isOpen = ! this.$data.__isOpen
+ },
+ ':aria-expanded'() {
+ return this.$data.__isOpen
+ },
+ ':aria-controls'() {
+ return this.$data.$id('alpine-disclosure-panel')
+ },
+ '@keydown.space.prevent.stop'() { this.$data.__toggle() },
+ '@keydown.enter.prevent.stop'() { this.$data.__toggle() },
+ // Required for firefox, event.preventDefault() in handleKeyDown for
+ // the Space key doesn't cancel the handleKeyUp, which in turn
+ // triggers a *click*.
+ '@keyup.space.prevent'() {},
+ })
+}
+
+function handlePanel(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() {
+ this.$data.__determineDefaultOpenState()
+ },
+ 'x-show'() {
+ return this.$data.__isOpen
+ },
+ ':id'() {
+ return this.$data.$id('alpine-disclosure-panel')
+ },
+ })
+}
diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js
new file mode 100644
index 000000000..c834946af
--- /dev/null
+++ b/packages/ui/src/index.js
@@ -0,0 +1,21 @@
+import combobox from './combobox'
+import dialog from './dialog'
+import disclosure from './disclosure'
+import listbox from './listbox'
+import popover from './popover'
+import menu from './menu'
+import notSwitch from './switch'
+import radio from './radio'
+import tabs from './tabs'
+
+export default function (Alpine) {
+ combobox(Alpine)
+ dialog(Alpine)
+ disclosure(Alpine)
+ listbox(Alpine)
+ menu(Alpine)
+ notSwitch(Alpine)
+ popover(Alpine)
+ radio(Alpine)
+ tabs(Alpine)
+}
diff --git a/packages/ui/src/list-context.js b/packages/ui/src/list-context.js
new file mode 100644
index 000000000..481180a61
--- /dev/null
+++ b/packages/ui/src/list-context.js
@@ -0,0 +1,462 @@
+
+export function generateContext(Alpine, multiple, orientation, activateSelectedOrFirst) {
+ return {
+ /**
+ * Main state...
+ */
+ items: [],
+ activeKey: switchboard(),
+ orderedKeys: [],
+ activatedByKeyPress: false,
+
+ /**
+ * Initialization...
+ */
+ activateSelectedOrFirst: Alpine.debounce(function () {
+ activateSelectedOrFirst(false)
+ }),
+
+ registerItemsQueue: [],
+
+ registerItem(key, el, value, disabled) {
+ // We need to queue up these additions to not slow down the
+ // init process for each row...
+ if (this.registerItemsQueue.length === 0) {
+ queueMicrotask(() => {
+ if (this.registerItemsQueue.length > 0) {
+ this.items = this.items.concat(this.registerItemsQueue)
+
+ this.registerItemsQueue = []
+
+ this.reorderKeys()
+ this.activateSelectedOrFirst()
+ }
+ })
+ }
+
+ let item = {
+ key, el, value, disabled
+ }
+
+ this.registerItemsQueue.push(item)
+ },
+
+ unregisterKeysQueue: [],
+
+ unregisterItem(key) {
+ // This gets triggered when the mutation observer picks up DOM changes.
+ // It will get called for every row that gets removed. If there are
+ // 1000x rows, we want to trigger this cleanup when the first one
+ // is handled, let the others add their keys to the queue, then
+ // handle all the cleanup in bulk at the end. Big perf gain...
+ if (this.unregisterKeysQueue.length === 0) {
+ queueMicrotask(() => {
+ if (this.unregisterKeysQueue.length > 0) {
+ this.items = this.items.filter(i => ! this.unregisterKeysQueue.includes(i.key))
+ this.orderedKeys = this.orderedKeys.filter(i => ! this.unregisterKeysQueue.includes(i))
+
+ this.unregisterKeysQueue = []
+
+ this.reorderKeys()
+ this.activateSelectedOrFirst()
+ }
+ })
+ }
+
+ this.unregisterKeysQueue.push(key)
+ },
+
+ getItemByKey(key) {
+ return this.items.find(i => i.key === key)
+ },
+
+ getItemByValue(value) {
+ return this.items.find(i => Alpine.raw(i.value) === Alpine.raw(value))
+ },
+
+ getItemByEl(el) {
+ return this.items.find(i => i.el === el)
+ },
+
+ getItemsByValues(values) {
+ let rawValues = values.map(i => Alpine.raw(i));
+ let filteredValue = this.items.filter(i => rawValues.includes(Alpine.raw(i.value)))
+ filteredValue = filteredValue.slice().sort((a, b) => {
+ let position = a.el.compareDocumentPosition(b.el)
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+ return 0
+ })
+ return filteredValue
+ },
+
+ getActiveItem() {
+ if (! this.hasActive()) return null
+
+ let item = this.items.find(i => i.key === this.activeKey.get())
+
+ if (! item) this.deactivateKey(this.activeKey.get())
+
+ return item
+ },
+
+ activateItem(item) {
+ if (! item) return
+
+ this.activateKey(item.key)
+ },
+
+ /**
+ * Handle elements...
+ */
+ reorderKeys: Alpine.debounce(function () {
+ this.orderedKeys = this.items.map(i => i.key)
+
+ this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
+ if (a === null || z === null) return 0
+
+ let aEl = this.items.find(i => i.key === a).el
+ let zEl = this.items.find(i => i.key === z).el
+
+ let position = aEl.compareDocumentPosition(zEl)
+
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+ return 0
+ })
+
+ // If there no longer is the active key in the items list, then
+ // deactivate it...
+ if (! this.orderedKeys.includes(this.activeKey.get())) this.deactivateKey(this.activeKey.get())
+ }),
+
+ getActiveKey() {
+ return this.activeKey.get()
+ },
+
+ activeEl() {
+ if (! this.activeKey.get()) return
+
+ return this.items.find(i => i.key === this.activeKey.get()).el
+ },
+
+ isActiveEl(el) {
+ let key = this.items.find(i => i.el === el)
+
+ return this.activeKey.is(key)
+ },
+
+ activateEl(el) {
+ let item = this.items.find(i => i.el === el)
+
+ this.activateKey(item.key)
+ },
+
+ isDisabledEl(el) {
+ return this.items.find(i => i.el === el).disabled
+ },
+
+ get isScrollingTo() { return this.scrollingCount > 0 },
+
+ scrollingCount: 0,
+
+ activateAndScrollToKey(key, activatedByKeyPress) {
+ if (! this.getItemByKey(key)) return
+
+ // This addresses the following problem:
+ // If deactivate is hooked up to mouseleave,
+ // scrolling to an element will trigger deactivation.
+ // This "isScrollingTo" is exposed to prevent that.
+ this.scrollingCount++
+
+ this.activateKey(key, activatedByKeyPress)
+
+ let targetEl = this.items.find(i => i.key === key).el
+
+ targetEl.scrollIntoView({ block: 'nearest' })
+
+ setTimeout(() => {
+ this.scrollingCount--
+ // Unfortunately, browser experimentation has shown me
+ // that 25ms is the sweet spot when holding down an
+ // arrow key to scroll the list of items...
+ }, 25)
+ },
+
+ /**
+ * Handle disabled keys...
+ */
+ isDisabled(key) {
+ let item = this.items.find(i => i.key === key)
+
+ if (! item) return false
+
+ return item.disabled
+ },
+
+ get nonDisabledOrderedKeys() {
+ return this.orderedKeys.filter(i => ! this.isDisabled(i))
+ },
+
+ /**
+ * Handle activated keys...
+ */
+ hasActive() { return !! this.activeKey.get() },
+
+ /**
+ * Return true if the latest active element was activated
+ * by the user (i.e. using the arrow keys) and false if was
+ * activated automatically by alpine (i.e. first element automatically
+ * activated after filtering the list)
+ */
+ wasActivatedByKeyPress() {return this.activatedByKeyPress},
+
+ isActiveKey(key) { return this.activeKey.is(key) },
+
+ activateKey(key, activatedByKeyPress = false) {
+ if (this.isDisabled(key)) return
+
+ this.activeKey.set(key)
+ this.activatedByKeyPress = activatedByKeyPress
+ },
+
+ deactivateKey(key) {
+ if (this.activeKey.get() === key) {
+ this.activeKey.set(null)
+ this.activatedByKeyPress = false
+ }
+ },
+
+ deactivate() {
+ if (! this.activeKey.get()) return
+ if (this.isScrollingTo) return
+
+ this.activeKey.set(null)
+ this.activatedByKeyPress = false
+ },
+
+ /**
+ * Handle active key traversal...
+ */
+ nextKey() {
+ if (! this.activeKey.get()) return
+
+ let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey.get())
+
+ return this.nonDisabledOrderedKeys[index + 1]
+ },
+
+ prevKey() {
+ if (! this.activeKey.get()) return
+
+ let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey.get())
+
+ return this.nonDisabledOrderedKeys[index - 1]
+ },
+
+ firstKey() { return this.nonDisabledOrderedKeys[0] },
+
+ lastKey() { return this.nonDisabledOrderedKeys[this.nonDisabledOrderedKeys.length - 1] },
+
+ searchQuery: '',
+
+ clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
+
+ searchKey(query) {
+ this.clearSearch()
+
+ this.searchQuery += query
+
+ let foundKey
+
+ for (let key in this.items) {
+ let content = this.items[key].el.textContent.trim().toLowerCase()
+
+ if (content.startsWith(this.searchQuery)) {
+ foundKey = this.items[key].key
+ break;
+ }
+ }
+
+ if (! this.nonDisabledOrderedKeys.includes(foundKey)) return
+
+ return foundKey
+ },
+
+ activateByKeyEvent(e, searchable = false, isOpen = () => false, open = () => {}, setIsTyping) {
+ let targetKey, hasActive
+
+ setIsTyping(true)
+
+ let activatedByKeyPress = true
+
+ switch (e.key) {
+ // case 'Backspace':
+ // case 'Delete':
+ case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
+ e.preventDefault(); e.stopPropagation()
+
+ setIsTyping(false)
+
+ if (! isOpen()) {
+ open()
+ break;
+ }
+
+ this.reorderKeys(); hasActive = this.hasActive()
+
+ targetKey = hasActive ? this.nextKey() : this.firstKey()
+ break;
+
+ case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
+ e.preventDefault(); e.stopPropagation()
+
+ setIsTyping(false)
+
+ if (! isOpen()) {
+ open()
+ break;
+ }
+
+ this.reorderKeys(); hasActive = this.hasActive()
+
+ targetKey = hasActive ? this.prevKey() : this.lastKey()
+ break;
+ case 'Home':
+ case 'PageUp':
+ if (e.key == 'Home' && e.shiftKey) return;
+
+ e.preventDefault(); e.stopPropagation()
+ setIsTyping(false)
+ this.reorderKeys(); hasActive = this.hasActive()
+ targetKey = this.firstKey()
+ break;
+
+ case 'End':
+ case 'PageDown':
+ if (e.key == 'End' && e.shiftKey) return;
+
+ e.preventDefault(); e.stopPropagation()
+ setIsTyping(false)
+ this.reorderKeys(); hasActive = this.hasActive()
+ targetKey = this.lastKey()
+ break;
+
+ default:
+ activatedByKeyPress = this.activatedByKeyPress
+ if (searchable && e.key.length === 1) {
+ targetKey = this.searchKey(e.key)
+ }
+ break;
+ }
+
+ if (targetKey) {
+ this.activateAndScrollToKey(targetKey, activatedByKeyPress)
+ }
+ }
+ }
+}
+
+function keyByValue(object, value) {
+ return Object.keys(object).find(key => object[key] === value)
+}
+
+export function renderHiddenInputs(Alpine, el, name, value) {
+ // Create input elements...
+ let newInputs = generateInputs(name, value)
+
+ // Mark them for later tracking...
+ newInputs.forEach(i => i._x_hiddenInput = true)
+
+ // Mark them for Alpine ignoring...
+ newInputs.forEach(i => i._x_ignore = true)
+
+ // Gather old elements for removal...
+ let children = el.children
+
+ let oldInputs = []
+
+ for (let i = 0; i < children.length; i++) {
+ let child = children[i];
+
+ if (child._x_hiddenInput) oldInputs.push(child)
+ else break
+ }
+
+ // Remove old, and insert new ones into the DOM...
+ Alpine.mutateDom(() => {
+ oldInputs.forEach(i => i.remove())
+
+ newInputs.reverse().forEach(i => el.prepend(i))
+ })
+}
+
+function generateInputs(name, value, carry = []) {
+ if (isObjectOrArray(value)) {
+ for (let key in value) {
+ carry = carry.concat(
+ generateInputs(`${name}[${key}]`, value[key])
+ )
+ }
+ } else {
+ let el = document.createElement('input')
+ el.setAttribute('type', 'hidden')
+ el.setAttribute('name', name)
+ el.setAttribute('value', '' + value)
+
+ return [el]
+ }
+
+
+ return carry
+}
+
+function isObjectOrArray(subject) {
+ return typeof subject === 'object' && subject !== null
+}
+
+function switchboard(value) {
+ let lookup = {}
+
+ let current
+
+ let changeTracker = Alpine.reactive({ state: false })
+
+ let get = () => {
+ // Depend on the change tracker so reading "get" becomes reactive...
+ if (changeTracker.state) {
+ //
+ }
+
+ return current
+ }
+
+ let set = (newValue) => {
+ if (newValue === current) return
+
+ if (current !== undefined) lookup[current].state = false
+
+ current = newValue
+
+ if (lookup[newValue] === undefined) {
+ lookup[newValue] = Alpine.reactive({ state: true })
+ } else {
+ lookup[newValue].state = true
+ }
+
+ changeTracker.state = ! changeTracker.state
+ }
+
+ let is = (comparisonValue) => {
+ if (lookup[comparisonValue] === undefined) {
+ lookup[comparisonValue] = Alpine.reactive({ state: false })
+ return lookup[comparisonValue].state
+ }
+
+ return !! lookup[comparisonValue].state
+ }
+
+ value === undefined || set(value)
+
+ return { get, set, is }
+}
diff --git a/packages/ui/src/listbox.js b/packages/ui/src/listbox.js
new file mode 100644
index 000000000..3fc52fcce
--- /dev/null
+++ b/packages/ui/src/listbox.js
@@ -0,0 +1,388 @@
+import { generateContext, renderHiddenInputs } from './list-context'
+
+export default function (Alpine) {
+ Alpine.directive('listbox', (el, directive) => {
+ if (! directive.value) handleRoot(el, Alpine)
+ else if (directive.value === 'label') handleLabel(el, Alpine)
+ else if (directive.value === 'button') handleButton(el, Alpine)
+ else if (directive.value === 'options') handleOptions(el, Alpine)
+ else if (directive.value === 'option') handleOption(el, Alpine)
+ }).before('bind')
+
+ Alpine.magic('listbox', (el) => {
+ let data = Alpine.$data(el)
+
+ return {
+ // @deprecated:
+ get selected() {
+ return data.__value
+ },
+ // @deprecated:
+ get active() {
+ let active = data.__context.getActiveItem()
+
+ return active && active.value
+ },
+ get value() {
+ return data.__value
+ },
+ get isOpen() {
+ return data.__isOpen
+ },
+ get isDisabled() {
+ return data.__isDisabled
+ },
+ get activeOption() {
+ let active = data.__context.getActiveItem()
+
+ return active && active.value
+ },
+ get activeIndex() {
+ let active = data.__context.getActiveItem()
+
+ return active && active.key
+ },
+ }
+ })
+
+ Alpine.magic('listboxOption', (el) => {
+ let data = Alpine.$data(el)
+
+ // It's not great depending on the existence of the attribute in the DOM
+ // but it's probably the fastest and most reliable at this point...
+ let optionEl = Alpine.findClosest(el, i => {
+ return i.hasAttribute('x-listbox:option')
+ })
+
+ if (! optionEl) throw 'No x-listbox:option directive found...'
+
+ return {
+ get isActive() {
+ return data.__context.isActiveKey(Alpine.$data(optionEl).__optionKey)
+ },
+ get isSelected() {
+ return data.__isSelected(optionEl)
+ },
+ get isDisabled() {
+ return data.__context.isDisabled(Alpine.$data(optionEl).__optionKey)
+ },
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
+ 'x-modelable': '__value',
+
+ // Initialize...
+ 'x-data'() {
+ return {
+ /**
+ * Listbox state...
+ */
+ __ready: false,
+ __value: null,
+ __isOpen: false,
+ __context: undefined,
+ __isMultiple: undefined,
+ __isStatic: false,
+ __isDisabled: undefined,
+ __compareBy: null,
+ __inputName: null,
+ __orientation: 'vertical',
+ __hold: false,
+
+ /**
+ * Listbox initialization...
+ */
+ init() {
+ this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
+ this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
+ this.__inputName = Alpine.extractProp(el, 'name', null)
+ this.__compareBy = Alpine.extractProp(el, 'by')
+ this.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical'
+
+ this.__context = generateContext(Alpine, this.__isMultiple, this.__orientation, () => this.__activateSelectedOrFirst())
+
+ let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
+
+ this.__value = defaultValue
+
+ // We have to wait again until after the "ready" processes are finished
+ // to settle up currently selected Values (this prevents this next bit
+ // of code from running multiple times on startup...)
+ queueMicrotask(() => {
+ Alpine.effect(() => {
+ // Everytime the value changes, we need to re-render the hidden inputs,
+ // if a user passed the "name" prop...
+ this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value)
+ })
+
+ // Keep the currently selected value in sync with the input value...
+ Alpine.effect(() => {
+ this.__resetInput()
+ })
+ })
+ },
+ __resetInput() {
+ let input = this.$refs.__input
+ if (! input) return
+
+ let value = this.$data.__getCurrentValue()
+
+ input.value = value
+ },
+ __getCurrentValue() {
+ if (! this.$refs.__input) return ''
+ if (! this.__value) return ''
+ if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value)
+ if (typeof this.__value === 'string') return this.__value
+ return ''
+ },
+ __open() {
+ if (this.__isOpen) return
+ this.__isOpen = true
+
+ this.__activateSelectedOrFirst()
+
+ // 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.__options.focus({ preventScroll: true }))
+ },
+ __close() {
+ this.__isOpen = false
+
+ this.__context.deactivate()
+
+ this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+ },
+ __activateSelectedOrFirst(activateSelected = true) {
+ if (! this.__isOpen) return
+
+ if (this.__context.getActiveKey()) {
+ this.__context.activateAndScrollToKey(this.__context.getActiveKey())
+ return
+ }
+
+ let firstSelectedValue
+
+ if (this.__isMultiple) {
+ firstSelectedValue = this.__value.find(i => {
+ return !! this.__context.getItemByValue(i)
+ })
+ } else {
+ firstSelectedValue = this.__value
+ }
+
+ if (activateSelected && firstSelectedValue) {
+ let firstSelected = this.__context.getItemByValue(firstSelectedValue)
+
+ firstSelected && this.__context.activateAndScrollToKey(firstSelected.key)
+ } else {
+ this.__context.activateAndScrollToKey(this.__context.firstKey())
+ }
+ },
+ __selectActive() {
+ let active = this.$data.__context.getActiveItem()
+ if (active) this.__toggleSelected(active.value)
+ },
+ __selectOption(el) {
+ let item = this.__context.getItemByEl(el)
+
+ if (item) this.__toggleSelected(item.value)
+ },
+ __isSelected(el) {
+ let item = this.__context.getItemByEl(el)
+
+ if (! item) return false
+ if (item.value === null || item.value === undefined) return false
+
+ return this.__hasSelected(item.value)
+ },
+ __toggleSelected(value) {
+ if (! this.__isMultiple) {
+ this.__value = value
+
+ return
+ }
+
+ let index = this.__value.findIndex(j => this.__compare(j, value))
+
+ if (index === -1) {
+ this.__value.push(value)
+ } else {
+ this.__value.splice(index, 1)
+ }
+ },
+ __hasSelected(value) {
+ if (! this.__isMultiple) return this.__compare(this.__value, value)
+
+ return this.__value.some(i => this.__compare(i, value))
+ },
+ __compare(a, b) {
+ let by = this.__compareBy
+
+ if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
+
+ if (typeof by === 'string') {
+ let property = by
+ 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)
+ },
+ }
+ },
+ })
+}
+
+function handleLabel(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-ref': '__label',
+ ':id'() { return this.$id('alpine-listbox-label') },
+ '@click'() { this.$refs.__button.focus({ preventScroll: true }) },
+ })
+}
+
+function handleButton(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-ref': '__button',
+ ':id'() { return this.$id('alpine-listbox-button') },
+
+ // Accessibility attributes...
+ 'aria-haspopup': 'true',
+ ':aria-labelledby'() { return this.$id('alpine-listbox-label') },
+ ':aria-expanded'() { return this.$data.__isOpen },
+ ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
+
+ // Initialize....
+ 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+
+ // Register listeners...
+ '@click'() { this.$data.__open() },
+ '@keydown'(e) {
+ if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
+ e.stopPropagation()
+ e.preventDefault()
+
+ this.$data.__open()
+ }
+ },
+ '@keydown.space.stop.prevent'() { this.$data.__open() },
+ '@keydown.enter.stop.prevent'() { this.$data.__open() },
+ })
+}
+
+function handleOptions(el, Alpine) {
+ Alpine.bind(el, {
+ // Setup...
+ 'x-ref': '__options',
+ ':id'() { return this.$id('alpine-listbox-options') },
+
+ // Accessibility attributes...
+ 'role': 'listbox',
+ tabindex: '0',
+ ':aria-orientation'() {
+ return this.$data.__orientation
+ },
+ ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
+ ':aria-activedescendant'() {
+ if (! this.$data.__context.hasActive()) return
+
+ let active = this.$data.__context.getActiveItem()
+
+ return active ? active.el.id : null
+ },
+
+ // Initialize...
+ 'x-init'() {
+ this.$data.__isStatic = Alpine.extractProp(this.$el, 'static', false)
+
+ if (Alpine.bound(this.$el, 'hold')) {
+ this.$data.__hold = true;
+ }
+ },
+
+ 'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
+ 'x-trap'() { return this.$data.__isOpen },
+ '@click.outside'() { this.$data.__close() },
+ '@keydown.escape.stop.prevent'() { this.$data.__close() },
+ '@focus'() { this.$data.__activateSelectedOrFirst() },
+ '@keydown'(e) {
+ queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, true, () => this.$data.__isOpen, () => this.$data.__open(), () => {}))
+ },
+ '@keydown.enter.stop.prevent'() {
+ this.$data.__selectActive();
+
+ this.$data.__isMultiple || this.$data.__close()
+ },
+ '@keydown.space.stop.prevent'() {
+ this.$data.__selectActive();
+
+ this.$data.__isMultiple || this.$data.__close()
+ },
+ })
+}
+
+function handleOption(el, Alpine) {
+ Alpine.bind(el, () => {
+ return {
+ 'x-id'() { return ['alpine-listbox-option'] },
+ ':id'() { return this.$id('alpine-listbox-option') },
+
+ // Accessibility attributes...
+ 'role': 'option',
+ ':tabindex'() { return this.$listboxOption.isDisabled ? false : '-1' },
+ ':aria-selected'() { return this.$listboxOption.isSelected },
+
+ // Initialize...
+ 'x-data'() {
+ return {
+ '__optionKey': null,
+
+ init() {
+ this.__optionKey = (Math.random() + 1).toString(36).substring(7)
+
+ let value = Alpine.extractProp(el, 'value')
+ let disabled = Alpine.extractProp(el, 'disabled', false, false)
+
+ this.$data.__context.registerItem(this.__optionKey, el, value, disabled)
+ },
+ destroy() {
+ this.$data.__context.unregisterItem(this.__optionKey)
+ },
+ }
+ },
+
+ // Register listeners...
+ '@click'() {
+ if (this.$listboxOption.isDisabled) return;
+
+ this.$data.__selectOption(el)
+
+ this.$data.__isMultiple || this.$data.__close()
+ },
+ '@mouseenter'() { this.$data.__context.activateEl(el) },
+ '@mouseleave'() {
+ this.$data.__hold || this.$data.__context.deactivate()
+ },
+ }
+ })
+}
+
+// Little utility to defer a callback into the microtask queue...
+function microtask(callback) {
+ return new Promise(resolve => queueMicrotask(() => resolve(callback())))
+}
diff --git a/packages/ui/src/menu.js b/packages/ui/src/menu.js
new file mode 100644
index 000000000..23698c0c8
--- /dev/null
+++ b/packages/ui/src/menu.js
@@ -0,0 +1,240 @@
+export default function (Alpine) {
+ Alpine.directive('menu', (el, directive) => {
+ if (! directive.value) handleRoot(el, Alpine)
+ else if (directive.value === 'items') handleItems(el, Alpine)
+ else if (directive.value === 'item') handleItem(el, Alpine)
+ else if (directive.value === 'button') handleButton(el, Alpine)
+ }).before('bind')
+
+ Alpine.magic('menuItem', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ get isActive() {
+ return $data.__activeEl == $data.__itemEl
+ },
+ get isDisabled() {
+ return $data.__itemEl.__isDisabled.value
+ },
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-id'() { return ['alpine-menu-button', 'alpine-menu-items'] },
+ 'x-modelable': '__isOpen',
+ 'x-data'() {
+ return {
+ __itemEls: [],
+ __activeEl: null,
+ __isOpen: false,
+ __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 })
+
+ // 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
+
+ focusAfter && this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+ },
+ __contains(outer, inner) {
+ return !! Alpine.findClosest(inner, el => el.isSameNode(outer))
+ }
+ }
+ },
+ '@focusin.window'() {
+ if (! this.$data.__contains(this.$el, document.activeElement)) {
+ this.$data.__close(false)
+ }
+ },
+ })
+}
+
+function handleButton(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-ref': '__button',
+ 'aria-haspopup': 'true',
+ ':aria-labelledby'() { return this.$id('alpine-menu-label') },
+ ':id'() { return this.$id('alpine-menu-button') },
+ ':aria-expanded'() { return this.$data.__isOpen },
+ ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-menu-items') },
+ '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.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',
+ 'aria-orientation': 'vertical',
+ 'role': 'menu',
+ ':id'() { return this.$id('alpine-menu-items') },
+ ':aria-labelledby'() { return this.$id('alpine-menu-button') },
+ ':aria-activedescendant'() { return this.$data.__activeEl && this.$data.__activeEl.id },
+ 'x-show'() { return this.$data.__isOpen },
+ 'tabindex': '0',
+ '@click.outside'() { this.$data.__close() },
+ '@keydown'(e) { dom.search(Alpine, this.$refs.__items, e.key, el => el.__activate()) },
+ '@keydown.down.stop.prevent'() {
+ if (this.$data.__activeEl) dom.next(Alpine, this.$data.__activeEl, el => el.__activate())
+ else dom.first(Alpine, this.$refs.__items, el => el.__activate())
+ },
+ '@keydown.up.stop.prevent'() {
+ if (this.$data.__activeEl) dom.previous(Alpine, this.$data.__activeEl, el => el.__activate())
+ else dom.last(Alpine, this.$refs.__items, el => el.__activate())
+ },
+ '@keydown.home.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
+ '@keydown.end.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
+ '@keydown.page-up.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
+ '@keydown.page-down.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
+ '@keydown.escape.stop.prevent'() { this.$data.__close() },
+ '@keydown.space.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
+ '@keydown.enter.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
+ // Required for firefox, event.preventDefault() in handleKeyDown for
+ // the Space key doesn't cancel the handleKeyUp, which in turn
+ // triggers a *click*.
+ '@keyup.space.prevent'() { },
+ })
+}
+
+function handleItem(el, Alpine) {
+ Alpine.bind(el, () => {
+ return {
+ 'x-data'() {
+ return {
+ __itemEl: this.$el,
+ init() {
+ // Add current element to element list for navigating.
+ let els = Alpine.raw(this.$data.__itemEls)
+ let inserted = false
+
+ for (let i = 0; i < els.length; i++) {
+ if (els[i].compareDocumentPosition(this.$el) & Node.DOCUMENT_POSITION_PRECEDING) {
+ els.splice(i, 0, this.$el)
+ inserted = true
+ break
+ }
+ }
+
+ if (! inserted) els.push(this.$el)
+
+ this.$el.__activate = () => {
+ this.$data.__activeEl = this.$el
+ this.$el.scrollIntoView({ block: 'nearest' })
+ }
+
+ this.$el.__deactivate = () => {
+ this.$data.__activeEl = null
+ }
+
+
+ this.$el.__isDisabled = Alpine.reactive({ value: false })
+
+ queueMicrotask(() => {
+ this.$el.__isDisabled.value = Alpine.bound(this.$el, 'disabled', false)
+ })
+ },
+ destroy() {
+ // Remove this element from the elements list.
+ let els = this.$data.__itemEls
+ els.splice(els.indexOf(this.$el), 1)
+ },
+ }
+ },
+ 'x-id'() { return ['alpine-menu-item'] },
+ ':id'() { return this.$id('alpine-menu-item') },
+ ':tabindex'() { return this.__itemEl.__isDisabled.value ? false : '-1' },
+ 'role': 'menuitem',
+ '@mousemove'() { this.__itemEl.__isDisabled.value || this.$menuItem.isActive || this.__itemEl.__activate() },
+ '@mouseleave'() { this.__itemEl.__isDisabled.value || ! this.$menuItem.isActive || this.__itemEl.__deactivate() },
+ }
+ })
+}
+
+let dom = {
+ first(Alpine, parent, receive = i => i, fallback = () => { }) {
+ let first = Alpine.$data(parent).__itemEls[0]
+
+ if (! first) return fallback()
+
+ if (first.tagName.toLowerCase() === 'template') {
+ return this.next(Alpine, first, receive)
+ }
+
+ if (first.__isDisabled.value) return this.next(Alpine, first, receive)
+
+ return receive(first)
+ },
+ last(Alpine, parent, receive = i => i, fallback = () => { }) {
+ let last = Alpine.$data(parent).__itemEls.slice(-1)[0]
+
+ if (! last) return fallback()
+ if (last.__isDisabled.value) return this.previous(Alpine, last, receive)
+ return receive(last)
+ },
+ next(Alpine, el, receive = i => i, fallback = () => { }) {
+ if (! el) return fallback()
+
+ let els = Alpine.$data(el).__itemEls
+ let next = els[els.indexOf(el) + 1]
+
+ if (! next) return fallback()
+ if (next.__isDisabled.value || next.tagName.toLowerCase() === 'template') return this.next(Alpine, next, receive, fallback)
+ return receive(next)
+ },
+ previous(Alpine, el, receive = i => i, fallback = () => { }) {
+ if (! el) return fallback()
+
+ let els = Alpine.$data(el).__itemEls
+ let prev = els[els.indexOf(el) - 1]
+
+ if (! prev) return fallback()
+ if (prev.__isDisabled.value || prev.tagName.toLowerCase() === 'template') return this.previous(Alpine, prev, receive, fallback)
+ return receive(prev)
+ },
+ searchQuery: '',
+ debouncedClearSearch: undefined,
+ clearSearch(Alpine) {
+ if (! this.debouncedClearSearch) {
+ this.debouncedClearSearch = Alpine.debounce(function () { this.searchQuery = '' }, 350)
+ }
+
+ this.debouncedClearSearch()
+ },
+ search(Alpine, parent, key, receiver) {
+ if (key.length > 1) return
+
+ this.searchQuery += key
+
+ let els = Alpine.raw(Alpine.$data(parent).__itemEls)
+
+ let el = els.find(el => {
+ return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
+ })
+
+ el && ! el.__isDisabled.value && receiver(el)
+
+ this.clearSearch(Alpine)
+ },
+}
diff --git a/packages/ui/src/popover.js b/packages/ui/src/popover.js
new file mode 100644
index 000000000..1068334ee
--- /dev/null
+++ b/packages/ui/src/popover.js
@@ -0,0 +1,209 @@
+
+export default function (Alpine) {
+ Alpine.directive('popover', (el, directive) => {
+ if (! directive.value) handleRoot(el, Alpine)
+ else if (directive.value === 'overlay') handleOverlay(el, Alpine)
+ else if (directive.value === 'button') handleButton(el, Alpine)
+ else if (directive.value === 'panel') handlePanel(el, Alpine)
+ else if (directive.value === 'group') handleGroup(el, Alpine)
+ })
+
+ Alpine.magic('popover', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ get isOpen() {
+ return $data.__isOpenState
+ },
+ open() {
+ $data.__open()
+ },
+ close() {
+ $data.__close()
+ },
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-id'() { return ['alpine-popover-button', 'alpine-popover-panel'] },
+ 'x-modelable': '__isOpenState',
+ 'x-data'() {
+ return {
+ init() {
+ if (this.$data.__groupEl) {
+ this.$data.__groupEl.addEventListener('__close-others', ({ detail }) => {
+ if (detail.el.isSameNode(this.$el)) return
+
+ this.__close(false)
+ })
+ }
+ },
+ __buttonEl: undefined,
+ __panelEl: undefined,
+ __isStatic: false,
+ get __isOpen() {
+ if (this.__isStatic) return true
+
+ return this.__isOpenState
+ },
+ __isOpenState: false,
+ __open() {
+ this.__isOpenState = true
+
+ this.$dispatch('__close-others', { el: this.$el })
+ },
+ __toggle() {
+ this.__isOpenState ? this.__close() : this.__open()
+ },
+ __close(el) {
+ if (this.__isStatic) return
+
+ this.__isOpenState = false
+
+ if (el === false) return
+
+ el = el || this.$data.__buttonEl
+
+ if (document.activeElement.isSameNode(el)) return
+
+ setTimeout(() => el.focus())
+ },
+ __contains(outer, inner) {
+ return !! Alpine.findClosest(inner, el => el.isSameNode(outer))
+ }
+ }
+ },
+ '@keydown.escape.stop.prevent'() {
+ this.__close()
+ },
+ '@focusin.window'() {
+ if (this.$data.__groupEl) {
+ if (! this.$data.__contains(this.$data.__groupEl, document.activeElement)) {
+ this.$data.__close(false)
+ }
+
+ return
+ }
+
+ if (! this.$data.__contains(this.$el, document.activeElement)) {
+ this.$data.__close(false)
+ }
+ },
+ })
+}
+
+function handleButton(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-ref': 'button',
+ ':id'() { return this.$id('alpine-popover-button') },
+ ':aria-expanded'() { return this.$data.__isOpen },
+ ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-popover-panel') },
+ 'x-init'() {
+ if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+
+ this.$data.__buttonEl = this.$el
+ },
+ '@click'() { this.$data.__toggle() },
+ '@keydown.tab'(e) {
+ if (! e.shiftKey && this.$data.__isOpen) {
+ let firstFocusableEl = this.$focus.within(this.$data.__panelEl).getFirst()
+
+ if (firstFocusableEl) {
+ e.preventDefault()
+ e.stopPropagation()
+
+ this.$focus.focus(firstFocusableEl)
+ }
+ }
+ },
+ '@keyup.tab'(e) {
+ if (this.$data.__isOpen) {
+ // Check if the last focused element was "after" this one
+ let lastEl = this.$focus.previouslyFocused()
+
+ if (! lastEl) return
+
+ if (
+ // Make sure the last focused wasn't part of this popover.
+ (! this.$data.__buttonEl.contains(lastEl) && ! this.$data.__panelEl.contains(lastEl))
+ // Also make sure it appeared "after" this button in the DOM.
+ && (lastEl && (this.$el.compareDocumentPosition(lastEl) & Node.DOCUMENT_POSITION_FOLLOWING))
+ ) {
+ e.preventDefault()
+ e.stopPropagation()
+
+ this.$focus.within(this.$data.__panelEl).last()
+ }
+ }
+ },
+ '@keydown.space.stop.prevent'() { this.$data.__toggle() },
+ '@keydown.enter.stop.prevent'() { this.$data.__toggle() },
+ // This is to stop Firefox from firing a "click".
+ '@keyup.space.stop.prevent'() { },
+ })
+}
+
+function handlePanel(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() {
+ this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
+ this.$data.__panelEl = this.$el
+ },
+ 'x-effect'() {
+ this.$data.__isOpen && Alpine.bound(el, 'focus') && this.$focus.first()
+ },
+ 'x-ref': 'panel',
+ ':id'() { return this.$id('alpine-popover-panel') },
+ 'x-show'() { return this.$data.__isOpen },
+ '@mousedown.window'($event) {
+ if (! this.$data.__isOpen) return
+ if (this.$data.__contains(this.$data.__buttonEl, $event.target)) return
+ if (this.$data.__contains(this.$el, $event.target)) return
+
+ if (! this.$focus.focusable($event.target)) {
+ this.$data.__close()
+ }
+ },
+ '@keydown.tab'(e) {
+ if (e.shiftKey && this.$focus.isFirst(e.target)) {
+ e.preventDefault()
+ e.stopPropagation()
+ Alpine.bound(el, 'focus') ? this.$data.__close() : this.$data.__buttonEl.focus()
+ } else if (! e.shiftKey && this.$focus.isLast(e.target)) {
+ e.preventDefault()
+ e.stopPropagation()
+
+ // Get the next panel button:
+ let els = this.$focus.within(document).all()
+ let buttonIdx = els.indexOf(this.$data.__buttonEl)
+
+ let nextEls = els
+ .splice(buttonIdx + 1) // Elements after button
+ .filter(el => ! this.$el.contains(el)) // Ignore items in panel
+
+ nextEls[0].focus()
+
+ Alpine.bound(el, 'focus') && this.$data.__close(false)
+ }
+ },
+ })
+}
+
+function handleGroup(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-ref': 'container',
+ 'x-data'() {
+ return {
+ __groupEl: this.$el,
+ }
+ },
+ })
+}
+
+function handleOverlay(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-show'() { return this.$data.__isOpen }
+ })
+}
diff --git a/packages/ui/src/radio.js b/packages/ui/src/radio.js
new file mode 100644
index 000000000..2fe20a47f
--- /dev/null
+++ b/packages/ui/src/radio.js
@@ -0,0 +1,220 @@
+
+export default function (Alpine) {
+ Alpine.directive('radio', (el, directive) => {
+ if (! directive.value) handleRoot(el, Alpine)
+ else if (directive.value === 'option') handleOption(el, Alpine)
+ else if (directive.value === 'label') handleLabel(el, Alpine)
+ else if (directive.value === 'description') handleDescription(el, Alpine)
+ }).before('bind')
+
+ Alpine.magic('radioOption', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ get isActive() {
+ return $data.__option === $data.__active
+ },
+ get isChecked() {
+ return $data.__option === $data.__value
+ },
+ get isDisabled() {
+ let disabled = $data.__disabled
+
+ if ($data.__rootDisabled) return true
+
+ return disabled
+ },
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-modelable': '__value',
+ 'x-data'() {
+ return {
+ init() {
+ queueMicrotask(() => {
+ this.__rootDisabled = Alpine.bound(el, 'disabled', false);
+ this.__value = Alpine.bound(this.$el, 'default-value', false)
+ this.__inputName = Alpine.bound(this.$el, 'name', false)
+ this.__inputId = 'alpine-radio-'+Date.now()
+ })
+
+ // Add `role="none"` to all non role elements.
+ this.$nextTick(() => {
+ let walker = document.createTreeWalker(
+ this.$el,
+ NodeFilter.SHOW_ELEMENT,
+ {
+ acceptNode: node => {
+ if (node.getAttribute('role') === 'radio') return NodeFilter.FILTER_REJECT
+ if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
+ return NodeFilter.FILTER_ACCEPT
+ }
+ },
+ false
+ )
+
+ while (walker.nextNode()) walker.currentNode.setAttribute('role', 'none')
+ })
+ },
+ __value: undefined,
+ __active: undefined,
+ __rootEl: this.$el,
+ __optionValues: [],
+ __disabledOptions: new Set,
+ __optionElsByValue: new Map,
+ __hasLabel: false,
+ __hasDescription: false,
+ __rootDisabled: false,
+ __inputName: undefined,
+ __inputId: undefined,
+ __change(value) {
+ if (this.__rootDisabled) return
+
+ this.__value = value
+ },
+ __addOption(option, el, disabled) {
+ // Add current element to element list for navigating.
+ let options = Alpine.raw(this.__optionValues)
+ let els = options.map(i => this.__optionElsByValue.get(i))
+ let inserted = false
+
+ for (let i = 0; i < els.length; i++) {
+ if (els[i].compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
+ options.splice(i, 0, option)
+ this.__optionElsByValue.set(option, el)
+ inserted = true
+ break
+ }
+ }
+
+ if (!inserted) {
+ options.push(option)
+ this.__optionElsByValue.set(option, el)
+ }
+
+ disabled && this.__disabledOptions.add(option)
+ },
+ __isFirstOption(option) {
+ return this.__optionValues.indexOf(option) === 0
+ },
+ __setActive(option) {
+ this.__active = option
+ },
+ __focusOptionNext() {
+ let option = this.__active
+ let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
+ let next = all[this.__optionValues.indexOf(option) + 1]
+ next = next || all[0]
+
+ this.__optionElsByValue.get(next).focus()
+ this.__change(next)
+ },
+ __focusOptionPrev() {
+ let option = this.__active
+ let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
+ let prev = all[all.indexOf(option) - 1]
+ prev = prev || all.slice(-1)[0]
+
+ this.__optionElsByValue.get(prev).focus()
+ this.__change(prev)
+ },
+ }
+ },
+ 'x-effect'() {
+ let value = this.__value
+
+ // Only render a hidden input if the "name" prop is passed...
+ if (! this.__inputName) return
+
+ // First remove a previously appended hidden input (if it exists)...
+ let nextEl = this.$el.nextElementSibling
+ if (nextEl && String(nextEl.id) === String(this.__inputId)) {
+ nextEl.remove()
+ }
+
+ // If the value is true, create the input and append it, otherwise,
+ // we already removed it in the previous step...
+ if (value) {
+ let input = document.createElement('input')
+
+ input.type = 'hidden'
+ input.value = value
+ input.name = this.__inputName
+ input.id = this.__inputId
+
+ this.$el.after(input)
+ }
+ },
+ 'role': 'radiogroup',
+ 'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
+ ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
+ ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
+ '@keydown.up.prevent.stop'() { this.__focusOptionPrev() },
+ '@keydown.left.prevent.stop'() { this.__focusOptionPrev() },
+ '@keydown.down.prevent.stop'() { this.__focusOptionNext() },
+ '@keydown.right.prevent.stop'() { this.__focusOptionNext() },
+ })
+}
+
+function handleOption(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-data'() {
+ return {
+ init() {
+ queueMicrotask(() => {
+ this.__disabled = Alpine.bound(el, 'disabled', false)
+ this.__option = Alpine.bound(el, 'value')
+ this.$data.__addOption(this.__option, this.$el, this.__disabled)
+ })
+ },
+ __option: undefined,
+ __disabled: false,
+ __hasLabel: false,
+ __hasDescription: false,
+ }
+ },
+ 'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
+ 'role': 'radio',
+ ':aria-checked'() { return this.$radioOption.isChecked },
+ ':aria-disabled'() { return this.$radioOption.isDisabled },
+ ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
+ ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
+ ':tabindex'() {
+ if (this.$radioOption.isDisabled) return -1
+ if (this.$radioOption.isChecked) return 0
+ if (! this.$data.__value && this.$data.__isFirstOption(this.$data.__option)) return 0
+
+ return -1
+ },
+ '@click'() {
+ if (this.$radioOption.isDisabled) return
+ this.$data.__change(this.$data.__option)
+ this.$el.focus()
+ },
+ '@focus'() {
+ if (this.$radioOption.isDisabled) return
+ this.$data.__setActive(this.$data.__option)
+ },
+ '@blur'() {
+ if (this.$data.__active === this.$data.__option) this.$data.__setActive(undefined)
+ },
+ '@keydown.space.stop.prevent'() { this.$data.__change(this.$data.__option) },
+ })
+}
+
+function handleLabel(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { this.$data.__hasLabel = true },
+ ':id'() { return this.$id('alpine-radio-label') },
+ })
+}
+
+function handleDescription(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { this.$data.__hasDescription = true },
+ ':id'() { return this.$id('alpine-radio-description') },
+ })
+}
diff --git a/packages/ui/src/switch.js b/packages/ui/src/switch.js
new file mode 100644
index 000000000..f0057ef36
--- /dev/null
+++ b/packages/ui/src/switch.js
@@ -0,0 +1,116 @@
+
+export default function (Alpine) {
+ Alpine.directive('switch', (el, directive) => {
+ if (directive.value === 'group') handleGroup(el, Alpine)
+ else if (directive.value === 'label') handleLabel(el, Alpine)
+ else if (directive.value === 'description') handleDescription(el, Alpine)
+ else handleRoot(el, Alpine)
+ }).before('bind')
+
+ Alpine.magic('switch', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ get isChecked() {
+ return $data.__value === true
+ },
+ }
+ })
+}
+
+function handleGroup(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-id'() { return ['alpine-switch-label', 'alpine-switch-description'] },
+ 'x-data'() {
+ return {
+ __hasLabel: false,
+ __hasDescription: false,
+ __switchEl: undefined,
+ }
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-modelable': '__value',
+ 'x-data'() {
+ return {
+ init() {
+ queueMicrotask(() => {
+ this.__value = Alpine.bound(this.$el, 'default-checked', false)
+ this.__inputName = Alpine.bound(this.$el, 'name', false)
+ this.__inputValue = Alpine.bound(this.$el, 'value', 'on')
+ this.__inputId = 'alpine-switch-'+Date.now()
+ })
+ },
+ __value: undefined,
+ __inputName: undefined,
+ __inputValue: undefined,
+ __inputId: undefined,
+ __toggle() {
+ this.__value = ! this.__value;
+ },
+ }
+ },
+ 'x-effect'() {
+ let value = this.__value
+
+ // Only render a hidden input if the "name" prop is passed...
+ if (! this.__inputName) return
+
+ // First remove a previously appended hidden input (if it exists)...
+ let nextEl = this.$el.nextElementSibling
+ if (nextEl && String(nextEl.id) === String(this.__inputId)) {
+ nextEl.remove()
+ }
+
+ // If the value is true, create the input and append it, otherwise,
+ // we already removed it in the previous step...
+ if (value) {
+ let input = document.createElement('input')
+
+ input.type = 'hidden'
+ input.value = this.__inputValue
+ input.name = this.__inputName
+ input.id = this.__inputId
+
+ this.$el.after(input)
+ }
+ },
+ 'x-init'() {
+ if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+ this.$data.__switchEl = this.$el
+ },
+ 'role': 'switch',
+ 'tabindex': "0",
+ ':aria-checked'() { return !!this.__value },
+ ':aria-labelledby'() { return this.$data.__hasLabel && this.$id('alpine-switch-label') },
+ ':aria-describedby'() { return this.$data.__hasDescription && this.$id('alpine-switch-description') },
+ '@click.prevent'() { this.__toggle() },
+ '@keyup'(e) {
+ if (e.key !== 'Tab') e.preventDefault()
+ if (e.key === ' ') this.__toggle()
+ },
+ // This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
+ '@keypress.prevent'() { },
+ })
+}
+
+function handleLabel(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { this.$data.__hasLabel = true },
+ ':id'() { return this.$id('alpine-switch-label') },
+ '@click'() {
+ this.$data.__switchEl.click()
+ this.$data.__switchEl.focus({ preventScroll: true })
+ },
+ })
+}
+
+function handleDescription(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { this.$data.__hasDescription = true },
+ ':id'() { return this.$id('alpine-switch-description') },
+ })
+}
diff --git a/packages/ui/src/tabs.js b/packages/ui/src/tabs.js
new file mode 100644
index 000000000..22ba191d3
--- /dev/null
+++ b/packages/ui/src/tabs.js
@@ -0,0 +1,146 @@
+
+export default function (Alpine) {
+ Alpine.directive('tabs', (el, directive) => {
+ if (! directive.value) handleRoot(el, Alpine)
+ else if (directive.value === 'list') handleList(el, Alpine)
+ else if (directive.value === 'tab') handleTab(el, Alpine)
+ else if (directive.value === 'panels') handlePanels(el, Alpine)
+ else if (directive.value === 'panel') handlePanel(el, Alpine)
+ }).before('bind')
+
+ Alpine.magic('tab', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ get isSelected() {
+ return $data.__selectedIndex === $data.__tabs.indexOf($data.__tabEl)
+ },
+ get isDisabled() {
+ return $data.__isDisabled
+ }
+ }
+ })
+
+ Alpine.magic('panel', el => {
+ let $data = Alpine.$data(el)
+
+ return {
+ get isSelected() {
+ return $data.__selectedIndex === $data.__panels.indexOf($data.__panelEl)
+ }
+ }
+ })
+}
+
+function handleRoot(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-modelable': '__selectedIndex',
+ 'x-data'() {
+ return {
+ init() {
+ queueMicrotask(() => {
+ let defaultIndex = this.__selectedIndex || Number(Alpine.bound(this.$el, 'default-index', 0))
+ let tabs = this.__activeTabs()
+ let clamp = (number, min, max) => Math.min(Math.max(number, min), max)
+
+ this.__selectedIndex = clamp(defaultIndex, 0, tabs.length -1)
+
+ Alpine.effect(() => {
+ this.__manualActivation = Alpine.bound(this.$el, 'manual', false)
+ })
+ })
+ },
+ __tabs: [],
+ __panels: [],
+ __selectedIndex: null,
+ __tabGroupEl: undefined,
+ __manualActivation: false,
+ __addTab(el) { this.__tabs.push(el) },
+ __addPanel(el) { this.__panels.push(el) },
+ __selectTab(el) {
+ this.__selectedIndex = this.__tabs.indexOf(el)
+ },
+ __activeTabs() {
+ return this.__tabs.filter(i => !i.__disabled)
+ },
+ }
+ }
+ })
+}
+
+function handleList(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { this.$data.__tabGroupEl = this.$el }
+ })
+}
+
+function handleTab(el, Alpine) {
+ Alpine.bind(el, {
+ 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+ 'x-data'() { return {
+ init() {
+ this.__tabEl = this.$el
+ this.$data.__addTab(this.$el)
+ this.__tabEl.__disabled = Alpine.bound(this.$el, 'disabled', false)
+ this.__isDisabled = this.__tabEl.__disabled
+ },
+ __tabEl: undefined,
+ __isDisabled: false,
+ }},
+ '@click'() {
+ if (this.$el.__disabled) return
+
+ this.$data.__selectTab(this.$el)
+
+ this.$el.focus()
+ },
+ '@keydown.enter.prevent.stop'() { this.__selectTab(this.$el) },
+ '@keydown.space.prevent.stop'() { this.__selectTab(this.$el) },
+ '@keydown.home.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).first() },
+ '@keydown.page-up.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).first() },
+ '@keydown.end.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).last() },
+ '@keydown.page-down.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).last() },
+ '@keydown.down.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().next() },
+ '@keydown.right.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().next() },
+ '@keydown.up.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
+ '@keydown.left.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
+ ':tabindex'() { return this.$tab.isSelected ? 0 : -1 },
+ // This is important because we want to only focus the tab when it gets focus
+ // OR it finished the click event (mouseup). However, if you perform a `click`,
+ // then you will first get the `focus` and then get the `click` event.
+ // See https://github.com/tailwindlabs/headlessui/pull/1192
+ '@mousedown'(event) { event.preventDefault() },
+ '@focus'() {
+ if (this.$data.__manualActivation) {
+ this.$el.focus()
+ } else {
+ if (this.$el.__disabled) return
+
+ this.$data.__selectTab(this.$el)
+
+ this.$el.focus()
+ }
+ },
+ })
+}
+
+function handlePanels(el, Alpine) {
+ Alpine.bind(el, {
+ //
+ })
+}
+
+function handlePanel(el, Alpine) {
+ Alpine.bind(el, {
+ ':tabindex'() { return this.$panel.isSelected ? 0 : -1 },
+ 'x-data'() { return {
+ init() {
+ this.__panelEl = this.$el
+ this.$data.__addPanel(this.$el)
+ },
+ __panelEl: undefined,
+ }},
+ 'x-show'() { return this.$panel.isSelected },
+ })
+}
+
diff --git a/scripts/build.js b/scripts/build.js
index a322bf7dd..c7421ab44 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -1,18 +1,23 @@
+let { writeToPackageDotJson, getFromPackageDotJson } = require('./utils');
let fs = require('fs');
-let DotJson = require('dot-json');
-let brotliSize = require('brotli-size');
+let zlib = require('zlib');
([
// Packages:
'alpinejs',
'csp',
- 'history',
+ // 'history', - removed because this plugin has been moved to livewire/livewire until it's stable...
+ // 'navigate', - remove because this plugin has been moved to livewire/livewire until it's stable...
'intersect',
- 'persist',
'collapse',
+ 'persist',
+ 'resize',
+ 'anchor',
'morph',
'focus',
+ 'sort',
'mask',
+ 'ui',
]).forEach(package => {
if (! fs.existsSync(`./packages/${package}/dist`)) {
fs.mkdirSync(`./packages/${package}/dist`, 0744);
@@ -35,7 +40,7 @@ function bundleFile(package, file) {
outfile: `packages/${package}/dist/${file}`,
bundle: true,
platform: 'browser',
- define: { CDN: true },
+ define: { CDN: 'true' },
})
// Build a minified version.
@@ -45,7 +50,7 @@ function bundleFile(package, file) {
bundle: true,
minify: true,
platform: 'browser',
- define: { CDN: true },
+ define: { CDN: 'true' },
}).then(() => {
outputSize(package, `packages/${package}/dist/${file.replace('.js', '.min.js')}`)
})
@@ -84,26 +89,15 @@ function build(options) {
options.define['process.env.NODE_ENV'] = process.argv.includes('--watch') ? `'production'` : `'development'`
return require('esbuild').build({
+ logLevel: process.argv.includes('--watch') ? 'info' : 'warning',
watch: process.argv.includes('--watch'),
// external: ['alpinejs'],
...options,
}).catch(() => process.exit(1))
}
-function writeToPackageDotJson(package, key, value) {
- let dotJson = new DotJson(`./packages/${package}/package.json`)
-
- dotJson.set(key, value).save()
-}
-
-function getFromPackageDotJson(package, key) {
- let dotJson = new DotJson(`./packages/${package}/package.json`)
-
- return dotJson.get(key)
-}
-
function outputSize(package, file) {
- let size = bytesToSize(brotliSize.sync(fs.readFileSync(file)))
+ let size = bytesToSize(zlib.brotliCompressSync(fs.readFileSync(file)).length)
console.log("\x1b[32m", `${package}: ${size}`)
}
@@ -114,4 +108,4 @@ function bytesToSize(bytes) {
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10)
if (i === 0) return `${bytes} ${sizes[i]}`
return `${(bytes / (1024 ** i)).toFixed(1)} ${sizes[i]}`
- }
+}
diff --git a/scripts/release.js b/scripts/release.js
index d15b4a25f..3cb746913 100644
--- a/scripts/release.js
+++ b/scripts/release.js
@@ -39,9 +39,18 @@ function writeNewAlpineVersion() {
writeToPackageDotJson('alpinejs', 'version', version)
console.log('Bumping alpinejs package.json: '+version)
+ writeToPackageDotJson('ui', 'version', version)
+ console.log('Bumping @alpinejs/ui package.json: '+version)
+
+ writeToPackageDotJson('csp', 'version', version)
+ console.log('Bumping @alpinejs/csp package.json: '+version)
+
writeToPackageDotJson('intersect', 'version', version)
console.log('Bumping @alpinejs/intersect package.json: '+version)
+ writeToPackageDotJson('resize', 'version', version)
+ console.log('Bumping @alpinejs/resize package.json: '+version)
+
writeToPackageDotJson('persist', 'version', version)
console.log('Bumping @alpinejs/persist package.json: '+version)
@@ -51,11 +60,17 @@ 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)
writeToPackageDotJson('mask', 'version', version)
console.log('Bumping @alpinejs/mask package.json: '+version)
+
+ writeToPackageDotJson('sort', 'version', version)
+ console.log('Bumping @alpinejs/sort package.json: '+version)
}
function writeNewDocsVersion() {
@@ -74,12 +89,21 @@ function publish() {
console.log('Publishing alpinejs on NPM...');
runFromPackage('alpinejs', 'npm publish')
+ console.log('Publishing @alpinejs/ui on NPM...');
+ runFromPackage('ui', 'npm publish --access public')
+
+ console.log('Publishing @alpinejs/csp on NPM...');
+ runFromPackage('csp', 'npm publish --access public')
+
console.log('Publishing @alpinejs/docs on NPM...');
runFromPackage('docs', 'npm publish --access public')
console.log('Publishing @alpinejs/intersect on NPM...');
runFromPackage('intersect', 'npm publish --access public')
+ console.log('Publishing @alpinejs/resize on NPM...');
+ runFromPackage('resize', 'npm publish --access public')
+
console.log('Publishing @alpinejs/persist on NPM...');
runFromPackage('persist', 'npm publish --access public')
@@ -89,12 +113,18 @@ 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')
console.log('Publishing @alpinejs/mask on NPM...');
runFromPackage('mask', 'npm publish --access public')
+ console.log('Publishing @alpinejs/sort on NPM...');
+ runFromPackage('sort', 'npm publish --access public')
+
log('\n\nFinished!')
}
diff --git a/tests/cypress/integration/clone.spec.js b/tests/cypress/integration/clone.spec.js
index e3586b81b..9d4f0d652 100644
--- a/tests/cypress/integration/clone.spec.js
+++ b/tests/cypress/integration/clone.spec.js
@@ -97,3 +97,34 @@ test('wont register listeners on clone',
get('#copy span').should(haveText('1'))
}
)
+
+test('wont register extra listeners on x-model on clone',
+ html`
+
+
+ click
+
+
+
+
+
+
+
+
+
+
+ `,
+ ({ get }) => {
+ get('#original span').should(haveText(''))
+ get('#copy span').should(haveText(''))
+ get('button').click()
+ get('#copy span').should(haveText(''))
+ get('#copy input').click()
+ get('#copy span').should(haveText('1'))
+ }
+)
diff --git a/tests/cypress/integration/custom-bind.spec.js b/tests/cypress/integration/custom-bind.spec.js
index ca731c7fa..08a82e8d6 100644
--- a/tests/cypress/integration/custom-bind.spec.js
+++ b/tests/cypress/integration/custom-bind.spec.js
@@ -44,3 +44,18 @@ test('can consume custom bind as function',
`,
({ get }) => get('div').should(haveText('bar'))
)
+
+test('can bind directives individually to an element',
+ html`
+
+
+
+ `,
+ ({ get }) => get('div').should(haveText('foo'))
+)
diff --git a/tests/cypress/integration/custom-directives.spec.js b/tests/cypress/integration/custom-directives.spec.js
index 41aa209f6..c4267a1d6 100644
--- a/tests/cypress/integration/custom-directives.spec.js
+++ b/tests/cypress/integration/custom-directives.spec.js
@@ -1,4 +1,4 @@
-import { haveText, html, test } from '../utils'
+import { haveText, haveAttribute, html, test } from '../utils'
test('can register custom directive',
[html`
@@ -49,3 +49,17 @@ test('directives are auto cleaned up',
get('h1').should(haveText('7'))
}
)
+
+test('can register a custom directive before an existing one',
+ [html`
+
+
+
+ `,
+ `
+ Alpine.directive('foo', (el, { value, modifiers, expression }) => {
+ Alpine.addScopeToNode(el, {foo: 'bar'})
+ }).before('bind')
+ `],
+ ({ get }) => get('span').should(haveAttribute('foo', 'bar'))
+)
diff --git a/tests/cypress/integration/directives/x-bind.spec.js b/tests/cypress/integration/directives/x-bind.spec.js
index 3aea6ef6d..ccbd61aa1 100644
--- a/tests/cypress/integration/directives/x-bind.spec.js
+++ b/tests/cypress/integration/directives/x-bind.spec.js
@@ -1,4 +1,4 @@
-import { beHidden, beVisible, haveText, beChecked, haveAttribute, haveClasses, haveValue, notBeChecked, notHaveAttribute, notHaveClasses, test, html } from '../../utils'
+import { beHidden, beVisible, haveText, beChecked, haveAttribute, haveClasses, haveProperty, haveValue, notBeChecked, notHaveAttribute, notHaveClasses, test, html } from '../../utils';
test('sets attribute bindings on initialize',
html`
@@ -27,13 +27,30 @@ test('style attribute bindings are added by string syntax',
({ get }) => get('span').should(haveClasses(['foo']))
)
-test('aria-pressed/checked attribute boolean values are cast to a true/false string',
+test('aria-pressed/checked/expanded/selected attribute boolean values are cast to a true/false string',
html`
+
+
+
+
+
+
+
+
`,
- ({ get }) => get('span').should(haveAttribute('aria-pressed', 'true'))
+ ({ get }) => {
+ get('span:nth-of-type(1)').should(haveAttribute('aria-pressed', 'true'))
+ get('span:nth-of-type(2)').should(haveAttribute('aria-checked', 'true'))
+ get('span:nth-of-type(3)').should(haveAttribute('aria-expanded', 'true'))
+ get('span:nth-of-type(4)').should(haveAttribute('aria-selected', 'true'))
+ get('span:nth-of-type(5)').should(haveAttribute('aria-pressed', 'false'))
+ get('span:nth-of-type(6)').should(haveAttribute('aria-checked', 'false'))
+ get('span:nth-of-type(7)').should(haveAttribute('aria-expanded', 'false'))
+ get('span:nth-of-type(8)').should(haveAttribute('aria-selected', 'false'))
+ }
)
test('non-boolean attributes set to null/undefined/false are removed from the element',
@@ -46,15 +63,22 @@ test('non-boolean attributes set to null/undefined/false are removed from the el
nullfalseundefined
+
+ null
+ false
+ undefined
`,
({ get }) => {
- get('a:nth-child(1)').should(notHaveAttribute('href'))
- get('a:nth-child(2)').should(notHaveAttribute('href'))
- get('a:nth-child(3)').should(notHaveAttribute('href'))
- get('span:nth-child(1)').should(notHaveAttribute('visible'))
- get('span:nth-child(2)').should(notHaveAttribute('visible'))
- get('span:nth-child(3)').should(notHaveAttribute('visible'))
+ get('a:nth-of-type(1)').should(notHaveAttribute('href'))
+ get('a:nth-of-type(2)').should(notHaveAttribute('href'))
+ get('a:nth-of-type(3)').should(notHaveAttribute('href'))
+ get('span:nth-of-type(1)').should(notHaveAttribute('visible'))
+ get('span:nth-of-type(2)').should(notHaveAttribute('visible'))
+ get('span:nth-of-type(3)').should(notHaveAttribute('visible'))
+ get('span:nth-of-type(4)').should(notHaveAttribute('hidden'))
+ get('span:nth-of-type(5)').should(notHaveAttribute('hidden'))
+ get('span:nth-of-type(6)').should(notHaveAttribute('hidden'))
}
)
@@ -81,10 +105,12 @@ test('boolean attribute values are set to their attribute name if true and remov
-
+
+
`,
({ get }) => {
@@ -111,7 +142,6 @@ test('boolean attribute values are set to their attribute name if true and remov
get('dl').should(haveAttribute('itemscope', 'itemscope'))
get('form').should(haveAttribute('novalidate', 'novalidate'))
get('iframe').should(haveAttribute('allowfullscreen', 'allowfullscreen'))
- get('iframe').should(haveAttribute('allowpaymentrequest', 'allowpaymentrequest'))
get('button').should(haveAttribute('formnovalidate', 'formnovalidate'))
get('audio').should(haveAttribute('autoplay', 'autoplay'))
get('audio').should(haveAttribute('controls', 'controls'))
@@ -121,6 +151,9 @@ test('boolean attribute values are set to their attribute name if true and remov
get('track').should(haveAttribute('default', 'default'))
get('img').should(haveAttribute('ismap', 'ismap'))
get('ol').should(haveAttribute('reversed', 'reversed'))
+ get('template').should(haveAttribute('shadowrootclonable', 'shadowrootclonable'))
+ get('template').should(haveAttribute('shadowrootdelegatesfocus', 'shadowrootdelegatesfocus'))
+ get('template').should(haveAttribute('shadowrootserializable', 'shadowrootserializable'))
get('#setToFalse').click()
@@ -391,8 +424,8 @@ test('x-bind object syntax event handlers defined as functions receive the event
+
+
+ thing
+
+ `,
+ ({ get }) =>
+ {
+ get('span').should(beVisible())
+ get('span').should(beVisible())
+ }
+
+);
diff --git a/tests/cypress/integration/entangle.spec.js b/tests/cypress/integration/entangle.spec.js
new file mode 100644
index 000000000..3ffb4bf5f
--- /dev/null
+++ b/tests/cypress/integration/entangle.spec.js
@@ -0,0 +1,106 @@
+import { haveValue, html, test } from '../utils'
+
+test.skip('can entangle to getter/setter pairs',
+ [html`
+
@@ -127,3 +127,34 @@ test('$id scopes can be reset',
get('h6').should(haveAttribute('aria-labelledby', 'bar-1'))
}
)
+
+test('can be used with morph without losing track',
+ [html`
+
+
+ bob
+
+
+
lob
+
+ `],
+ ({ get }, reload, window, document) => {
+ let toHtml = html`
+
+
+ bob
+
+
+
lob
+
+ `
+
+ get('span').should(haveAttribute('id', 'foo-1'))
+ get('h1').should(haveAttribute('id', 'bar-1'))
+
+ get('div').then(([el]) => window.Alpine.morph(el, toHtml))
+
+ get('span').should(haveAttribute('id', 'foo-1'))
+ get('h1').should(haveAttribute('id', 'bar-1'))
+ },
+)
diff --git a/tests/cypress/integration/mutation.spec.js b/tests/cypress/integration/mutation.spec.js
index 0c1f48a7d..9dce237a4 100644
--- a/tests/cypress/integration/mutation.spec.js
+++ b/tests/cypress/integration/mutation.spec.js
@@ -219,3 +219,54 @@ test('no side effects when directives are added to an element that is removed af
get('span').should(haveText('0'))
}
)
+
+test(
+ "previously initialized elements are not reinitialized on being moved",
+ html`
+
+
+
+
+ `,
+ ({ get }) => {
+ get("[x-test]").should(haveText("1"));
+ }
+);
+
+test(
+ "previously initialized elements are not cleaned up on being moved",
+ html`
+
+
+
+
+ `,
+ ({ get }) => {
+ get("[x-test]").should(haveText("Initialized"));
+ }
+);
\ No newline at end of file
diff --git a/tests/cypress/integration/plugins/anchor.spec.js b/tests/cypress/integration/plugins/anchor.spec.js
new file mode 100644
index 000000000..52e97eeb5
--- /dev/null
+++ b/tests/cypress/integration/plugins/anchor.spec.js
@@ -0,0 +1,13 @@
+import { haveAttribute, haveComputedStyle, html, notHaveAttribute, test } from '../../utils'
+
+test('can anchor an element',
+ [html`
+
+ toggle
+
contents
+
+ `],
+ ({ get }, reload) => {
+ get('h1').should(haveComputedStyle('position', 'absolute'))
+ },
+)
diff --git a/tests/cypress/integration/plugins/csp-compatibility.spec.js b/tests/cypress/integration/plugins/csp-compatibility.spec.js
index 97e9e3e6b..a61889c66 100644
--- a/tests/cypress/integration/plugins/csp-compatibility.spec.js
+++ b/tests/cypress/integration/plugins/csp-compatibility.spec.js
@@ -20,3 +20,39 @@ test.csp('Can use components and basic expressions with CSP-compatible build',
get('span').should(haveText('baz'))
}
)
+
+test.csp('Supports nested properties',
+ [html`
+
+ `],
+ ({ get, url, go }, reload) => {
+ url().should('include', '?foo=hey%26there&bar=hey+there')
+ get('span').should(haveText('"hey&there""hey there"'))
+ reload()
+ url().should('include', '?foo=hey%26there&bar=hey+there')
+ get('span').should(haveText('"hey&there""hey there"'))
+ },
+ )
+})
+
diff --git a/tests/cypress/integration/plugins/mask.spec.js b/tests/cypress/integration/plugins/mask.spec.js
index 9b86e27f7..20d26e5ea 100644
--- a/tests/cypress/integration/plugins/mask.spec.js
+++ b/tests/cypress/integration/plugins/mask.spec.js
@@ -60,6 +60,50 @@ test('x-mask with x-model',
},
)
+// This passes locally but fails in CI...
+test.skip('x-mask with latently bound x-model',
+ [html`
+
+
+
+
+ `],
+ ({ get }) => {
+ get('#1').type('a').should(haveValue('('))
+ get('#2').should(haveValue('('))
+ get('#1').type('1').should(haveValue('(1'))
+ get('#2').should(haveValue('(1'))
+ },
+)
+
+test('x-mask with x-model with initial value',
+ [html`
+
+
+
+
+ `],
+ ({ get }) => {
+ get('#1').should(haveValue('(123) 456-7890'))
+ get('#2').should(haveValue('(123) 456-7890'))
+ },
+)
+
+test('x-mask with x-model if initial value is null it should remain null',
+ [html`
+
+
+
+
+
+ `],
+ ({ get }) => {
+ get('#1').should(haveValue(''))
+ get('#2').should(haveValue(''))
+ get('#3').contains('NULL')
+ },
+)
+
test('x-mask with a falsy input',
[html``],
({ get }) => {
@@ -144,7 +188,21 @@ test('$money swapping commas and periods',
},
)
-test('$money works with permenant inserted at beginning',
+test('$money with different thousands separator',
+ [html``],
+ ({ get }) => {
+ get('input').type('3000').should(haveValue('3 000'));
+ get('input').type('{backspace}').blur().should(haveValue('300'));
+ get('input').type('5').should(haveValue('3 005'));
+ get('input').type('{selectAll}{backspace}').should(haveValue(''));
+ get('input').type('123').should(haveValue('123'));
+ get('input').type('4').should(haveValue('1 234'));
+ get('input').type('567').should(haveValue('1 234 567'));
+ get('input').type('.89').should(haveValue('1 234 567.89'));
+ }
+);
+
+test('$money works with permanent inserted at beginning',
[html``],
({ get }) => {
get('input').type('40.00').should(haveValue('40.00'))
@@ -153,3 +211,46 @@ test('$money works with permenant inserted at beginning',
get('input').should(haveValue('40.00'))
}
)
+
+test('$money mask should remove letters or non numeric characters',
+ [html``],
+ ({ get }) => {
+ get('input').type('A').should(haveValue(''))
+ get('input').type('ABC').should(haveValue(''))
+ get('input').type('$').should(haveValue(''))
+ get('input').type('/').should(haveValue(''))
+ get('input').type('40').should(haveValue('40'))
+ }
+)
+
+test('$money mask negative values',
+ [html`
+
+
+ `],
+ ({ get }) => {
+ get('#1').should(haveValue('-1,234.50'))
+ get('#2').type('-12.509').should(haveValue('-12.50'))
+ get('#2').type('{leftArrow}{leftArrow}{leftArrow}-').should(haveValue('-12.50'))
+ get('#2').type('{leftArrow}{leftArrow}{backspace}').should(haveValue('12.50'))
+ get('#2').type('{rightArrow}-').should(haveValue('12.50'))
+ get('#2').type('{rightArrow}-').should(haveValue('12.50'))
+ get('#2').type('{rightArrow}{rightArrow}{rightArrow}-').should(haveValue('12.50'))
+ get('#2').type('{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}-').should(haveValue('-12.50'))
+ }
+)
+
+test('$money with custom decimal precision',
+ [html`
+
+
+
+
+ `],
+ ({ get }) => {
+ get('#0').type('1234.5678').should(haveValue('12,345,678'))
+ get('#1').type('1234.5678').should(haveValue('1,234.5'))
+ get('#2').type('1234.5678').should(haveValue('1,234.56'))
+ get('#3').type('1234.5678').should(haveValue('1,234.567'))
+ }
+)
diff --git a/tests/cypress/integration/plugins/morph.spec.js b/tests/cypress/integration/plugins/morph.spec.js
index 0ad99544f..50df0fd6e 100644
--- a/tests/cypress/integration/plugins/morph.spec.js
+++ b/tests/cypress/integration/plugins/morph.spec.js
@@ -134,6 +134,51 @@ test('can morph teleports',
},
)
+test('can morph teleports in different places with IDs',
+ [html`
+
+ Inc
+
+
+
+
+
hey
+
+
+
+
moving placeholder
+
+
+
+ `],
+ ({ get }, reload, window, document) => {
+ let toHtml = html`
+
+ `],
+ ({ get }) => {
+ get('#1').drag('#3').then(() => {
+ // This is the easiest way I can think of to assert the order of HTML comments doesn't change...
+ get('ul').should('have.html', `\n \n \n
bar
\n
baz
\n \n
foo
`)
+ })
+ },
+)
+
+test.skip('x-sort:item can be used as a filter',
+ [html`
+
+
+
foo
+
bar
+
baz
+
+
+ `],
+ ({ get }) => {
+ get('ul li').eq(0).should(haveText('foo'))
+ get('ul li').eq(1).should(haveText('bar'))
+ get('ul li').eq(2).should(haveText('baz'))
+
+ // Unfortunately, github actions doesn't like "async/await" here
+ // so we need to use .then() throughout this entire test...
+ get('#1').drag('#3').then(() => {
+ get('ul li').eq(0).should(haveText('bar'))
+ get('ul li').eq(1).should(haveText('baz'))
+ get('ul li').eq(2).should(haveText('foo'))
+
+ get('#2').drag('#1').then(() => {
+ get('ul li').eq(0).should(haveText('bar'))
+ get('ul li').eq(1).should(haveText('baz'))
+ get('ul li').eq(2).should(haveText('foo'))
+ })
+ })
+ },
+)
diff --git a/tests/cypress/integration/plugins/ui/combobox.spec.js b/tests/cypress/integration/plugins/ui/combobox.spec.js
new file mode 100644
index 000000000..aa8137397
--- /dev/null
+++ b/tests/cypress/integration/plugins/ui/combobox.spec.js
@@ -0,0 +1,1809 @@
+import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, contain, notContain, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test, haveValue, haveLength, ensureNoConsoleWarns} from '../../../utils'
+
+test('it works with x-model',
+ [html`
+
+ `],
+ ({ get }) => {
+ // Test after closing with button
+ get('button').click()
+ get('input').type('w')
+ get('button').click()
+ get('input').should(haveValue(''))
+
+ // Test correct state after closing with ESC
+ get('button').click()
+ get('input').type('w')
+ get('input').type('{esc}')
+ get('input').should(haveValue(''))
+
+ // Test correct state after closing with TAB
+ get('button').click()
+ get('input').type('w')
+ get('input').tab()
+ get('input').should(haveValue(''))
+
+ // Test correct state after closing with external click
+ get('button').click()
+ get('input').type('w')
+ get('article').click()
+ get('input').should(haveValue(''))
+
+ // Select something
+ get('button').click()
+ get('ul').should(beVisible())
+ get('[option="2"]').click()
+ get('input').should(haveValue('Arlene Mccoy'))
+
+ // Test after closing with button
+ get('button').click()
+ get('input').type('w')
+ get('button').click()
+ get('input').should(haveValue('Arlene Mccoy'))
+
+ // Test correct state after closing with ESC and reopening
+ get('button').click()
+ get('input').type('w')
+ get('input').type('{esc}')
+ get('input').should(haveValue('Arlene Mccoy'))
+
+ // Test correct state after closing with TAB and reopening
+ get('button').click()
+ get('input').type('w')
+ get('input').tab()
+ get('input').should(haveValue('Arlene Mccoy'))
+
+ // Test correct state after closing with external click and reopening
+ get('button').click()
+ get('input').type('w')
+ get('article').click()
+ get('input').should(haveValue('Arlene Mccoy'))
+
+ // Test correct state after clearing selected via code
+ get('a').click()
+ get('input').should(haveValue(''))
+ },
+)
+
+test('combobox shows all options when opening',
+ [html`
+
+
+
+
+
+
+
+
+ Toggle
+
+
+
+
+
+
+
+
+
+
+
No people match your query.
+
+
+
+
+ lorem ipsum
+
+ `],
+ ({ get }) => {
+ get('button').click()
+ get('li').should(haveLength('10'))
+
+ // Test after closing with button and reopening
+ get('input').type('w').trigger('input')
+ get('li').should(haveLength('2'))
+ get('button').click()
+ get('button').click()
+ get('li').should(haveLength('10'))
+
+ // Test correct state after closing with ESC and reopening
+ get('input').type('w').trigger('input')
+ get('li').should(haveLength('2'))
+ get('input').type('{esc}')
+ get('button').click()
+ get('li').should(haveLength('10'))
+
+ // Test correct state after closing with TAB and reopening
+ get('input').type('w').trigger('input')
+ get('li').should(haveLength('2'))
+ get('input').tab()
+ get('button').click()
+ get('li').should(haveLength('10'))
+
+ // Test correct state after closing with external click and reopening
+ get('input').type('w').trigger('input')
+ get('li').should(haveLength('2'))
+ get('article').click()
+ get('button').click()
+ get('li').should(haveLength('10'))
+ },
+)
+
+test('active element logic when opening a combobox',
+ [html`
+
+
+
+
+
+
+
+
+ Toggle
+
+
+
+
+
+
+
+
+
+
+
No people match your query.
+
+
+
+
+ `],
+ ({ get }) => {
+ get('button').click()
+ // First option is selected on opening if no preselection
+ get('ul').should(beVisible())
+ get('[option="1"]').should(haveAttribute('aria-selected', 'false'))
+ get('[option="1"]').should(haveClasses(['active']))
+ // First match is selected while typing
+ 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', '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(haveAttribute('aria-selected', 'false'))
+ get('[option="3"]').should(haveAttribute('aria-selected', 'true'))
+ }
+)
+
+test('can remove an option without other options getting removed',
+ [html`