diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml
index 037fed64..25327c77 100644
--- a/.github/workflows/elixir.yml
+++ b/.github/workflows/elixir.yml
@@ -23,12 +23,15 @@ jobs:
strategy:
matrix:
include:
- - elixir: 1.15.8
- otp: 25.3.2.9
+ - elixir: 1.17.3
+ otp: 26.2.5.2
+ coveralls: false
- elixir: 1.18.4
otp: 27.3
+ coveralls: true
- elixir: 1.18.4
otp: 28.0.1
+ coveralls: false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
@@ -54,9 +57,27 @@ jobs:
- name: Install dependencies
run: mix deps.get
+ - name: Set up Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: package.json
+
+ - name: Install npm dependencies
+ run: npm install
+
+ - name: Check JavaScript (prettier + eslint)
+ run: npm run check
+
- name: Run tests and post coverage to Coveralls
+ if: matrix.coveralls
run: mix coveralls.github
+ - name: Run tests
+ if: ${{ !matrix.coveralls }}
+ run: mix test
+
e2e-tests:
name: E2E tests (OTP ${{ matrix.otp }} | Elixir ${{ matrix.elixir }})
runs-on: ubuntu-24.04
@@ -64,8 +85,8 @@ jobs:
strategy:
matrix:
include:
- - elixir: 1.15.8
- otp: 25.3.2.9
+ - elixir: 1.17.3
+ otp: 26.2.5.2
- elixir: 1.18.4
otp: 27.3
- elixir: 1.18.4
@@ -89,6 +110,22 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ - name: Set up pnpm
+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
+ with:
+ version: 10.33.0
+
+ - name: Set up Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: "24"
+ cache: pnpm
+ cache-dependency-path: e2e/pnpm-lock.yaml
+
+ - name: Install e2e JS dependencies
+ working-directory: e2e
+ run: pnpm install --frozen-lockfile
+
- name: Set up Elixir
uses: erlef/setup-beam@3580539ceec3dc05b0ed51e9e10b08eb7a7c2bb4
with:
@@ -130,8 +167,8 @@ jobs:
strategy:
matrix:
include:
- - elixir: 1.15.8
- otp: 25.3.2.9
+ - elixir: 1.17.3
+ otp: 26.2.5.2
- elixir: 1.18.4
otp: 27.3
- elixir: 1.18.4
@@ -171,10 +208,10 @@ jobs:
strategy:
matrix:
include:
- - elixir: 1.15.8
- otp: 25.3.2.9
- elixir: 1.17.3
- otp: 27.1.2
+ otp: 26.2.5.2
+ - elixir: 1.18.4
+ otp: 27.3
services:
postgres:
@@ -214,6 +251,18 @@ jobs:
working-directory: integration_test
run: mix deps.get
+ - name: Install installer dependencies and local corex_new archive
+ working-directory: installer
+ run: |
+ mix deps.get
+ mix archive.build -o corex_new.ez
+ mix archive.install corex_new.ez --force
+
+ - name: Install Mix archives for corex.new (phx.new + igniter.install)
+ run: |
+ mix archive.install hex phx_new --force
+ mix archive.install hex igniter_new --force
+
- name: Run integration tests
working-directory: integration_test
- run: mix test --exclude database:mysql --exclude database:mssql --timeout 600000
+ run: mix test test/code_generation/dev_corex_new_test.exs --timeout 600000
diff --git a/.gitignore b/.gitignore
index ea0e7541..8f4b0670 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,6 @@ corex-*.tar
/integration_test/_build/
/integration_test/deps/
+.dexter.db*
+
+/.claude/
diff --git a/LICENSE b/LICENSE
index ba3784e8..e52b8d33 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
+
+Corex MCP
+
+The HTTP Model Context Protocol stack under `lib/corex/mcp/` is aligned with
+[tidewave_phoenix](https://github.com/tidewave-ai/tidewave_phoenix)
+
+The original Tidewave files are copyright (c) 2025 Dashbit and licensed under the
+[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
diff --git a/README.md b/README.md
index ebb7e36b..5243bb0e 100644
--- a/README.md
+++ b/README.md
@@ -1,65 +1,69 @@
+# Installation
+


[](https://coveralls.io/github/corex-ui/corex?branch=corex-install)


-# Corex
+## Introduction
-Corex is an accessible and unstyled UI components library written in Elixir and TypeScript that integrates [Zag.js](https://zagjs.com/) state machines into the Phoenix Framework.
+Corex brings Zag.js state machines to Phoenix to build accessible and unstyled components with a full server and client API.
+Control and listen from both sides of the wire and Fully Compatible with Phoenix Form, Stream and Ecto Changeset
-Corex bridges the gap between Phoenix and modern JavaScript UI patterns by leveraging Zag.js: a collection of framework-agnostic UI component state machines. This approach gives you:
+- **Flexible anatomy:** declarative, custom slots and full compound mode.
+- **Server and client API & Events:** push state in, pull it out and react to changes in Elixir and JavaScript.
+- **LiveView-native:** update props at runtime without resetting component state.
+- **Server App & Static Website** Build a full app with Phoenix or build a static site using Tableau.
+- **Accessible and keyboard-first:** powered by Zag.js state machines.
+- **Truly unstyled:** bring your own CSS or use Corex Design System.
-- **Accessible by default** - Built-in ARIA attributes and keyboard navigation
-- **Unstyled components** - Complete control over styling and design
-- **Type-safe state management** - Powered by Zag.js state machines
-- **Works everywhere** - Phoenix Controllers and LiveView
-- **No Node.js required** - Install directly from Hex and connect the Phoenix hooks
+> **Beta stage**
+> Corex is actively being developed and is currently in beta stage.
+> It is getting closer to a stable release and no critical changes in the API are excpected at this stage
-> **Alpha stage**
-> Corex is actively being developed and is currently in alpha stage.
-> It's not recommended for production use at this time.
-> You can monitor development progress and contribute to the [project on GitHub](https://github.com/corex-ui/corex).
## Live Demo
-To preview the components, a [Live Demo](https://corex.gigalixirapp.com/en) is available to showcase some uses of components, language switching, RTL, and Dark Mode and Site Navigation.
-
-You can also explore all components via [Live Captures](https://corex.gigalixirapp.com/captures/components/Elixir.CorexWeb.Accordion/accordion), a zero-boilerplate storybook for LiveView components. A big thanks to [@achempion](https://github.com/achempion) for assisting.
+To preview the components, a [Live Demo](https://corex.gigalixirapp.com/en) is available to showcase some uses of components, language switching, RTL, Dark Mode and Site Navigation.
-This is still in an early stage and will evolve with future stable releases.
+## New project with Corex
-Thanks to [Gigalixir](https://www.gigalixir.com/) for providing a reliable hosting solution for Elixir projects *(not sponsored, just a personal experience)*.
+To create a new Phoenix application with Corex preconfigured, install the Corex project generator archive and Igniter (first time only), then generate the app:
+```bash
+mix archive.install hex igniter
+mix archive.install hex corex_new
+mix corex.new my_app
+```
-## Documentation
+To update the generator to the latest version before creating a project:
-Full documentation is available at [hexdocs.pm/corex](http://hexdocs.pm/corex).
+```bash
+mix local.corex
+mix corex.new my_app
+```
-## Installation
+See full options at `Mix.Tasks.Corex.New`
-Install the Corex project generator, then create a new Phoenix application with Corex:
+## Existing project
```bash
-mix archive.install hex corex_new
-mix corex.new my_app
-cd my_app
-mix deps.get
+mix igniter.install corex
```
-To update the generator to the latest version first, run `mix local.corex` before `mix corex.new my_app`.
-
-The generated project includes Corex, configuration, and default styling.
+See full options at `Mix.Tasks.Corex.Install`
-## Existing Project
+## Add your first component
-To add Corex to an existing Phoenix app instead of using the generator, see [Manual installation](manual_installation.html).
+Add the following Accordion examples to your application.
+You can use `Corex.Content.new/1` to create a list of content items.
-### Add your first component
+The `id` for each item is optional and will be auto-generated if not provided.
-Example Accordion using `Corex.Content.new/1`:
+You can specify `disabled` for each item.
```heex
<.accordion
@@ -72,7 +76,245 @@ Example Accordion using `Corex.Content.new/1`:
/>
```
-More Accordion examples (with indicator, custom slots, controlled, async) and API control are in the [Installation guide](https://hexdocs.pm/corex/installation.html#add-your-first-component).
+### With indicator
+
+Use the optional `:indicator` slot to add an icon after each trigger.
+
+This example assumes the import of `.heroicon` from Core Components.
+
+```heex
+<.accordion
+ class="accordion"
+ items={Corex.Content.new([
+ [
+ id: "lorem",
+ trigger: "Lorem ipsum dolor sit amet",
+ content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique."
+ ],
+ [
+ trigger: "Duis dictum gravida odio ac pharetra?",
+ content: "Nullam eget vestibulum ligula, at interdum tellus."
+ ],
+ [
+ id: "donec",
+ trigger: "Donec condimentum ex mi",
+ content: "Congue molestie ipsum gravida a. Sed ac eros luctus."
+ ]
+ ])}
+>
+ <:indicator>
+ <.heroicon name="hero-chevron-right" />
+
+
+```
+
+### Custom
+
+Use `:trigger` and `:content` together to fully customize how each item is rendered. Add the `:indicator` slot to show an icon after each trigger. Use `:let={item}` on slots to access the item and its `data` (including `meta` for per-item customization).
+
+```heex
+<.accordion
+ class="accordion"
+ items={
+ Corex.Content.new([
+ [
+ id: "lorem",
+ trigger: "Lorem ipsum dolor sit amet",
+ content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.",
+ meta: %{indicator: "hero-arrow-long-right", icon: "hero-chat-bubble-left-right"}
+ ],
+ [
+ trigger: "Duis dictum gravida ?",
+ content: "Nullam eget vestibulum ligula, at interdum tellus.",
+ meta: %{indicator: "hero-chevron-right", icon: "hero-device-phone-mobile"}
+ ],
+ [
+ id: "donec",
+ trigger: "Donec condimentum ex mi",
+ content: "Congue molestie ipsum gravida a. Sed ac eros luctus.",
+ disabled: true,
+ meta: %{indicator: "hero-chevron-double-right", icon: "hero-phone"}
+ ]
+ ])
+ }
+>
+ <:trigger :let={item}>
+ <.heroicon name={item.data.meta.icon} />{item.data.trigger}
+
+ <:content :let={item}>{item.data.content}
+ <:indicator :let={item}>
+ <.heroicon name={item.data.meta.indicator} />
+
+
+```
+
+### Controlled
+
+Render an accordion controlled by the server.
+
+You must use the `on_value_change` event to update the value on the server and pass the value as a list of strings.
+
+The event will receive the value as a map with the key `value` and the id of the accordion.
+
+```elixir
+defmodule MyAppWeb.AccordionLive do
+ use MyAppWeb, :live_view
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, :value, ["lorem"])}
+ end
+
+ def handle_event("on_value_change", %{"value" => value}, socket) do
+ {:noreply, assign(socket, :value, value)}
+ end
+
+ def render(assigns) do
+ ~H"""
+ <.accordion
+ controlled
+ value={@value}
+ on_value_change="on_value_change"
+ class="accordion"
+ items={Corex.Content.new([
+ [id: "lorem", trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique. Proin quis risus feugiat tellus iaculis fringilla."],
+ [id: "duis", trigger: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus. Quisque feugiat, dui ut fermentum sodales, lectus metus dignissim ex."]
+ ])}
+ />
+ """
+ end
+end
+```
+
+### Async
+
+When the initial props are not available on mount, you can use `Phoenix.LiveView.assign_async` to assign the props asynchronously.
+
+You can use the optional `Corex.Accordion.accordion_skeleton/1` to render a loading or error state.
+
+```elixir
+defmodule MyAppWeb.AccordionAsyncLive do
+ use MyAppWeb, :live_view
+
+ def mount(_params, _session, socket) do
+ socket =
+ socket
+ |> assign_async(:accordion, fn ->
+ Process.sleep(1000)
+
+ items = Corex.Content.new([
+ [
+ id: "lorem",
+ trigger: "Lorem ipsum dolor sit amet",
+ content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.",
+ disabled: true
+ ],
+ [
+ id: "duis",
+ trigger: "Duis dictum gravida odio ac pharetra?",
+ content: "Nullam eget vestibulum ligula, at interdum tellus."
+ ],
+ [
+ id: "donec",
+ trigger: "Donec condimentum ex mi",
+ content: "Congue molestie ipsum gravida a. Sed ac eros luctus."
+ ]
+ ])
+
+ {:ok,
+ %{
+ accordion: %{
+ items: items,
+ value: ["duis", "donec"]
+ }
+ }}
+ end)
+
+ {:ok, socket}
+ end
+
+ def render(assigns) do
+ ~H"""
+ <.async_result :let={accordion} assign={@accordion}>
+ <:loading>
+ <.accordion_skeleton count={3} class="accordion" />
+
+
+ <:failed>
+ there was an error loading the accordion
+
+
+ <.accordion
+ id="async-accordion"
+ class="accordion"
+ items={accordion.items}
+ value={accordion.value}
+ />
+
+ """
+ end
+end
+```
+
+## API Control
+
+In order to use the API, you must use an id on the component.
+
+**Client-side**
+
+```heex
+
+```
+
+**Server-side**
+
+```elixir
+def handle_event("open_item", _, socket) do
+ {:noreply, Corex.Accordion.set_value(socket, "my-accordion", ["item-1"])}
+end
+```
+
+## Events
+
+Listen to component events on the **server** (LiveView events) or on the **client** (DOM CustomEvents).
+
+### Server
+
+```heex
+<.accordion
+ id="my-accordion"
+ class="accordion"
+ items={@items}
+ on_value_change="accordion_value_changed"
+/>
+```
+
+```elixir
+def handle_event("accordion_value_changed", %{"id" => "my-accordion", "value" => value}, socket) do
+ {:noreply, assign(socket, open_values: value)}
+end
+```
+
+### Client
+
+```heex
+<.accordion
+ id="my-accordion"
+ class="accordion"
+ items={@items}
+ on_value_change_client="accordion-value-changed"
+/>
+```
+
+```javascript
+const el = document.getElementById("my-accordion");
+
+el.addEventListener("accordion-value-changed", (event) => {
+ const { id, value } = event.detail;
+ console.log("accordion value changed", { id, value });
+});
+```
## License
diff --git a/assets/components/accordion.ts b/assets/components/accordion.ts
index 77159451..ca47a0d5 100644
--- a/assets/components/accordion.ts
+++ b/assets/components/accordion.ts
@@ -1,6 +1,7 @@
import { connect, machine, type Props, type Api } from "@zag-js/accordion";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+import { stripHiddenFromProps } from "../lib/animation";
export class Accordion extends Component {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -9,25 +10,28 @@ export class Accordion extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
const rootEl =
this.el.querySelector('[data-scope="accordion"][data-part="root"]') ?? this.el;
this.spreadProps(rootEl, this.api.getRootProps());
-
- const itemsList = this.getItemsList();
+ const scopeId = this.el.id;
+ const itemPrefix = scopeId ? `accordion:${scopeId}:item:` : "";
const itemEls = rootEl.querySelectorAll(
'[data-scope="accordion"][data-part="item"]'
);
- for (let i = 0; i < itemEls.length; i++) {
- const itemEl = itemEls[i];
- const itemData = itemsList[i];
- if (!itemData?.value) continue;
+ const animation = this.el.dataset.animation ?? "instant";
+
+ for (const itemEl of itemEls) {
+ if (itemPrefix && !itemEl.id.startsWith(itemPrefix)) continue;
+ const value = itemEl.dataset.value;
+ if (!value) continue;
+
+ const disabled = itemEl.dataset.disabled === "";
- const { value, disabled } = itemData;
this.spreadProps(itemEl, this.api.getItemProps({ value, disabled }));
const triggerEl = itemEl.querySelector(
@@ -48,18 +52,18 @@ export class Accordion extends Component {
'[data-scope="accordion"][data-part="item-content"]'
);
if (contentEl) {
- this.spreadProps(contentEl, this.api.getItemContentProps({ value, disabled }));
+ if (animation === "instant") {
+ this.spreadProps(contentEl, this.api.getItemContentProps({ value, disabled }));
+ } else if (animation === "js" || animation === "custom") {
+ this.spreadProps(
+ contentEl,
+ stripHiddenFromProps(
+ this.api.getItemContentProps({ value, disabled }) as Record
+ )
+ );
+ contentEl.removeAttribute("hidden");
+ }
}
}
}
-
- private getItemsList(): Array<{ value: string; disabled: boolean }> {
- const raw = this.el.getAttribute("data-items");
- if (!raw) return [];
- try {
- return JSON.parse(raw) as Array<{ value: string; disabled: boolean }>;
- } catch {
- return [];
- }
- }
}
diff --git a/assets/components/angle-slider.ts b/assets/components/angle-slider.ts
index 5e3da02d..9069e019 100644
--- a/assets/components/angle-slider.ts
+++ b/assets/components/angle-slider.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/angle-slider";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class AngleSlider extends Component {
@@ -9,7 +9,7 @@ export class AngleSlider extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
@@ -46,9 +46,12 @@ export class AngleSlider extends Component {
const valueSpan = valueTextEl.querySelector(
'[data-scope="angle-slider"][data-part="value"]'
);
- if (valueSpan && valueSpan.textContent !== String(this.api.value)) {
- valueSpan.textContent = String(this.api.value);
- }
+ const format = this.el.dataset.valueTextAs;
+ const nextValue =
+ format === "raw"
+ ? String(this.api.value)
+ : String(this.api.valueAsDegree ?? this.api.value);
+ if (valueSpan && valueSpan.textContent !== nextValue) valueSpan.textContent = nextValue;
}
const markerGroupEl = this.el.querySelector(
diff --git a/assets/components/avatar.ts b/assets/components/avatar.ts
index db4d37aa..5f1d07b3 100644
--- a/assets/components/avatar.ts
+++ b/assets/components/avatar.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/avatar";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Avatar extends Component {
@@ -9,7 +9,7 @@ export class Avatar extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/carousel.ts b/assets/components/carousel.ts
index c6821a73..d5c7a75d 100644
--- a/assets/components/carousel.ts
+++ b/assets/components/carousel.ts
@@ -1,6 +1,6 @@
import { connect, machine, type Props, type Api } from "@zag-js/carousel";
import type { ItemProps, IndicatorProps } from "@zag-js/carousel";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Carousel extends Component {
@@ -10,7 +10,7 @@ export class Carousel extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/checkbox.ts b/assets/components/checkbox.ts
index 9b479e16..178b7e52 100644
--- a/assets/components/checkbox.ts
+++ b/assets/components/checkbox.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/checkbox";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Checkbox extends Component {
@@ -9,7 +9,7 @@ export class Checkbox extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/clipboard.ts b/assets/components/clipboard.ts
index e0e2784b..b74f73b3 100644
--- a/assets/components/clipboard.ts
+++ b/assets/components/clipboard.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/clipboard";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Clipboard extends Component {
@@ -9,7 +9,7 @@ export class Clipboard extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/collapsible.ts b/assets/components/collapsible.ts
index 74fdbf8f..9cb746aa 100644
--- a/assets/components/collapsible.ts
+++ b/assets/components/collapsible.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/collapsible";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Collapsible extends Component {
@@ -9,7 +9,7 @@ export class Collapsible extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/color-picker.ts b/assets/components/color-picker.ts
index 1d252193..9c11a15c 100644
--- a/assets/components/color-picker.ts
+++ b/assets/components/color-picker.ts
@@ -1,5 +1,5 @@
import { connect, machine, parse, type Props, type Api } from "@zag-js/color-picker";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class ColorPicker extends Component {
@@ -9,7 +9,7 @@ export class ColorPicker extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/combobox.ts b/assets/components/combobox.ts
index 5fe5d925..b9349aaa 100644
--- a/assets/components/combobox.ts
+++ b/assets/components/combobox.ts
@@ -7,8 +7,10 @@ import {
type OpenChangeDetails,
type InputValueChangeDetails,
} from "@zag-js/combobox";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+import { zagComboboxCollectionConfig } from "../lib/list-collection";
+import { templatesContentRoot } from "../lib/util";
export type ComboboxItem = { id?: string; label: string; disabled?: boolean; group?: string };
@@ -24,23 +26,7 @@ export class Combobox extends Component {
getCollection() {
const items = this.options || this.allOptions || [];
-
- if (this.hasGroups) {
- return collection({
- items: items,
- itemToValue: (item: ComboboxItem) => item.id ?? "",
- itemToString: (item: ComboboxItem) => item.label,
- isItemDisabled: (item: ComboboxItem) => item.disabled ?? false,
- groupBy: (item: ComboboxItem) => item.group ?? "",
- });
- }
-
- return collection({
- items: items,
- itemToValue: (item: ComboboxItem) => item.id ?? "",
- itemToString: (item: ComboboxItem) => item.label,
- isItemDisabled: (item: ComboboxItem) => item.disabled ?? false,
- });
+ return collection(zagComboboxCollectionConfig(items, this.hasGroups));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -65,9 +51,11 @@ export class Combobox extends Component {
props.onInputValueChange(details);
}
if (this.el.hasAttribute("data-filter")) {
- const filtered = this.allOptions.filter((item: ComboboxItem) =>
- item.label.toLowerCase().includes(details.inputValue.toLowerCase())
- );
+ const q = String(details.inputValue ?? "").toLowerCase();
+ const filtered = this.allOptions.filter((item: ComboboxItem) => {
+ const label = String(item.label ?? "");
+ return label.toLowerCase().includes(q);
+ });
this.options = filtered.length > 0 ? filtered : this.allOptions;
} else {
this.options = this.allOptions;
@@ -77,50 +65,15 @@ export class Combobox extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
- renderItems(): void {
- const contentEl = this.el.querySelector(
- '[data-scope="combobox"][data-part="content"]'
- );
- if (!contentEl) return;
-
- const templatesContainer = this.el.querySelector('[data-templates="combobox"]');
- if (!templatesContainer) return;
-
- contentEl
- .querySelectorAll('[data-scope="combobox"][data-part="item"]:not([data-template])')
- .forEach((el) => el.remove());
-
- contentEl
- .querySelectorAll('[data-scope="combobox"][data-part="item-group"]:not([data-template])')
- .forEach((el) => el.remove());
-
- contentEl
- .querySelectorAll('[data-scope="combobox"][data-part="empty"]:not([data-template])')
- .forEach((el) => el.remove());
-
- const items = this.options?.length ? this.options : this.allOptions;
-
- if (items.length === 0) {
- const emptyTemplate = templatesContainer.querySelector(
- '[data-scope="combobox"][data-part="empty"][data-template]'
- );
- if (emptyTemplate) {
- const emptyEl = emptyTemplate.cloneNode(true) as HTMLElement;
- emptyEl.removeAttribute("data-template");
- contentEl.appendChild(emptyEl);
- }
- } else if (this.hasGroups) {
- const groups = this.api.collection.group?.() ?? [];
- this.renderGroupedItems(contentEl, templatesContainer, groups);
- } else {
- this.renderFlatItems(contentEl, templatesContainer, items);
- }
+ private getItemValue(item: ComboboxItem): string {
+ const v = this.api.collection.getItemValue?.(item) as string | undefined;
+ return v ?? item.id ?? "";
}
- buildOrderedBlocks(
+ private buildOrderedBlocks(
items: ComboboxItem[]
): (
| { type: "default"; items: ComboboxItem[] }
@@ -154,89 +107,149 @@ export class Combobox extends Component {
return blocks;
}
- renderGroupedItems(
- contentEl: HTMLElement,
- templatesContainer: HTMLElement,
- _groups: [string | null, ComboboxItem[]][]
- ): void {
+ renderItems(): void {
+ const listEl = this.el.querySelector('[data-scope="combobox"][data-part="list"]');
+ if (!listEl) return;
+
+ const isOwnedByList = (el: Element) =>
+ el.closest('[data-scope="combobox"][data-part="list"]') === listEl;
+
+ const templatesRoot = templatesContentRoot(this.el, "combobox");
+ if (!templatesRoot) return;
+
+ (["empty", "item-group", "item"] as const).forEach((part) => {
+ Array.from(
+ listEl.querySelectorAll(
+ `[data-scope="combobox"][data-part="${part}"]:not([data-template])`
+ )
+ )
+ .filter(isOwnedByList)
+ .forEach((el) => el.remove());
+ });
+
const items = this.options?.length ? this.options : this.allOptions;
- const blocks = this.buildOrderedBlocks(items);
- for (const block of blocks) {
- const templateId = block.type === "default" ? "default" : block.groupId;
- const groupTemplate = templatesContainer.querySelector(
- `[data-scope="combobox"][data-part="item-group"][data-id="${templateId}"][data-template]`
+ if (items.length === 0) {
+ const emptyTemplate = templatesRoot.querySelector(
+ '[data-scope="combobox"][data-part="empty"][data-template]'
);
- if (!groupTemplate) continue;
+ if (emptyTemplate) {
+ const emptyEl = emptyTemplate.cloneNode(true) as HTMLElement;
+ emptyEl.removeAttribute("data-template");
+ listEl.appendChild(emptyEl);
+ }
+ return;
+ }
- const groupEl = groupTemplate.cloneNode(true) as HTMLElement;
- groupEl.removeAttribute("data-template");
+ if (this.hasGroups) {
+ this.renderGroupedItems(listEl, templatesRoot, items);
+ } else {
+ this.renderFlatItems(listEl, templatesRoot, items);
+ }
+ }
- this.spreadProps(groupEl, this.api.getItemGroupProps({ id: templateId }));
+ private renderGroupedItems(
+ listEl: HTMLElement,
+ templatesRoot: DocumentFragment | HTMLElement,
+ items: ComboboxItem[]
+ ): void {
+ const blocks = this.buildOrderedBlocks(items);
- const labelEl = groupEl.querySelector(
- '[data-scope="combobox"][data-part="item-group-label"]'
- );
- if (labelEl) {
- this.spreadProps(labelEl, this.api.getItemGroupLabelProps({ htmlFor: templateId }));
- }
+ for (const block of blocks) {
+ if (block.type !== "group") continue;
- const groupContentEl = groupEl.querySelector(
- '[data-scope="combobox"][data-part="item-group-content"]'
+ const groupTemplate = templatesRoot.querySelector(
+ `[data-scope="combobox"][data-part="item-group"][data-id="${CSS.escape(block.groupId)}"][data-template]`
);
- if (!groupContentEl) continue;
-
- groupContentEl.innerHTML = "";
-
- for (const item of block.items) {
- const itemEl = this.cloneItem(templatesContainer, item);
- if (itemEl) groupContentEl.appendChild(itemEl);
- }
+ if (!groupTemplate) continue;
- contentEl.appendChild(groupEl);
+ const groupEl = groupTemplate.cloneNode(true) as HTMLElement;
+ groupEl.removeAttribute("data-template");
+ groupEl
+ .querySelectorAll("[data-template]")
+ .forEach((e) => e.removeAttribute("data-template"));
+
+ const keepValues = new Set(block.items.map((i) => this.getItemValue(i)));
+ groupEl
+ .querySelectorAll('[data-scope="combobox"][data-part="item"]')
+ .forEach((itemEl) => {
+ const v = itemEl.dataset.value ?? "";
+ if (!keepValues.has(v)) itemEl.remove();
+ });
+
+ listEl.appendChild(groupEl);
}
}
- renderFlatItems(
- contentEl: HTMLElement,
- templatesContainer: HTMLElement,
+ private renderFlatItems(
+ listEl: HTMLElement,
+ templatesRoot: DocumentFragment | HTMLElement,
items: ComboboxItem[]
): void {
for (const item of items) {
- const itemEl = this.cloneItem(templatesContainer, item);
- if (itemEl) contentEl.appendChild(itemEl);
+ const value = this.getItemValue(item);
+ const template = templatesRoot.querySelector(
+ `:scope > [data-scope="combobox"][data-part="item"][data-value="${CSS.escape(value)}"][data-template]`
+ );
+ if (!template) continue;
+ const itemEl = template.cloneNode(true) as HTMLElement;
+ itemEl.removeAttribute("data-template");
+ listEl.appendChild(itemEl);
}
}
- cloneItem(templatesContainer: HTMLElement, item: ComboboxItem): HTMLElement | null {
- const value = (this.api.collection.getItemValue?.(item) as string | undefined) ?? item.id ?? "";
-
- const template = templatesContainer.querySelector(
- `[data-scope="combobox"][data-part="item"][data-value="${value}"][data-template]`
- );
- if (!template) return null;
-
- const el = template.cloneNode(true) as HTMLElement;
- el.removeAttribute("data-template");
-
- this.spreadProps(el, this.api.getItemProps({ item }));
+ applyItemProps(): void {
+ const listEl = this.el.querySelector('[data-scope="combobox"][data-part="list"]');
+ if (!listEl) return;
+
+ const isOwnedByList = (el: Element) =>
+ el.closest('[data-scope="combobox"][data-part="list"]') === listEl;
+
+ listEl
+ .querySelectorAll('[data-scope="combobox"][data-part="item-group"]')
+ .forEach((groupEl) => {
+ if (!isOwnedByList(groupEl)) return;
+ const groupId = groupEl.dataset.id ?? "";
+ this.spreadProps(groupEl, this.api.getItemGroupProps({ id: groupId }));
+ const labelEl = groupEl.querySelector(
+ '[data-scope="combobox"][data-part="item-group-label"]'
+ );
+ if (labelEl) {
+ this.spreadProps(labelEl, this.api.getItemGroupLabelProps({ htmlFor: groupId }));
+ }
+ });
- const textEl = el.querySelector('[data-scope="combobox"][data-part="item-text"]');
- if (textEl) {
- this.spreadProps(textEl, this.api.getItemTextProps({ item }));
- if (textEl.children.length === 0) {
- textEl.textContent = item.label || "";
- }
+ const sourceItems = this.options?.length ? this.options : this.allOptions;
+ const byValue = new Map();
+ for (const item of sourceItems) {
+ byValue.set(this.getItemValue(item), item);
}
-
- const indicatorEl = el.querySelector(
- '[data-scope="combobox"][data-part="item-indicator"]'
- );
- if (indicatorEl) {
- this.spreadProps(indicatorEl, this.api.getItemIndicatorProps({ item }));
+ for (const item of this.allOptions) {
+ const v = this.getItemValue(item);
+ if (!byValue.has(v)) byValue.set(v, item);
}
- return el;
+ listEl
+ .querySelectorAll('[data-scope="combobox"][data-part="item"]')
+ .forEach((itemEl) => {
+ if (!isOwnedByList(itemEl)) return;
+ const value = itemEl.dataset.value ?? "";
+ const item = byValue.get(value);
+ if (!item) return;
+ this.spreadProps(itemEl, this.api.getItemProps({ item }));
+ const textEl = itemEl.querySelector(
+ '[data-scope="combobox"][data-part="item-text"]'
+ );
+ if (textEl) {
+ this.spreadProps(textEl, this.api.getItemTextProps({ item }));
+ }
+ const indicatorEl = itemEl.querySelector(
+ '[data-scope="combobox"][data-part="item-indicator"]'
+ );
+ if (indicatorEl) {
+ this.spreadProps(indicatorEl, this.api.getItemIndicatorProps({ item }));
+ }
+ });
}
render(): void {
@@ -244,7 +257,16 @@ export class Combobox extends Component {
if (!root) return;
this.spreadProps(root, this.api.getRootProps());
- ["label", "control", "input", "trigger", "clear-trigger", "positioner"].forEach((part) => {
+ [
+ "label",
+ "control",
+ "input",
+ "trigger",
+ "clear-trigger",
+ "positioner",
+ "content",
+ "list",
+ ].forEach((part) => {
const el = this.el.querySelector(`[data-scope="combobox"][data-part="${part}"]`);
if (!el) return;
@@ -260,12 +282,7 @@ export class Combobox extends Component {
this.spreadProps(el, this.api[apiMethod]());
});
- const contentEl = this.el.querySelector(
- '[data-scope="combobox"][data-part="content"]'
- );
- if (contentEl) {
- this.spreadProps(contentEl, this.api.getContentProps());
- this.renderItems();
- }
+ this.renderItems();
+ this.applyItemProps();
}
}
diff --git a/assets/components/date-picker.ts b/assets/components/date-picker.ts
index e69830fe..4e437e72 100644
--- a/assets/components/date-picker.ts
+++ b/assets/components/date-picker.ts
@@ -1,7 +1,127 @@
import { connect, machine, type Props, type Api } from "@zag-js/date-picker";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+type ZagDatePickerTranslations = NonNullable;
+
+export type DatePickerMessageMap = {
+ content?: string;
+ monthSelect?: string;
+ yearSelect?: string;
+ clearTrigger?: string;
+ weekColumnHeader?: string;
+ openCalendar?: string;
+ closeCalendar?: string;
+ viewTriggerYear?: string;
+ viewTriggerMonth?: string;
+ viewTriggerDay?: string;
+ prevTriggerYear?: string;
+ prevTriggerMonth?: string;
+ prevTriggerDay?: string;
+ nextTriggerYear?: string;
+ nextTriggerMonth?: string;
+ nextTriggerDay?: string;
+ weekNumber?: string;
+ placeholderDay?: string;
+ placeholderMonth?: string;
+ placeholderYear?: string;
+ input?: string;
+ rangeStart?: string;
+ rangeEnd?: string;
+};
+
+type DatePickerView = "day" | "month" | "year";
+
+const pickViewLabel = (
+ view: DatePickerView,
+ day: T | undefined,
+ month: T | undefined,
+ year: T | undefined
+): T | "" => (view === "year" ? (year ?? "") : view === "month" ? (month ?? "") : (day ?? ""));
+
+const formatWeek = (template: string, n: number): string => template.split("__N__").join(String(n));
+
+export function buildZagDatePickerTranslations(m: DatePickerMessageMap): ZagDatePickerTranslations {
+ const t: Record = {};
+ if (m.content) t.content = m.content;
+ if (m.monthSelect) t.monthSelect = m.monthSelect;
+ if (m.yearSelect) t.yearSelect = m.yearSelect;
+ if (m.clearTrigger) t.clearTrigger = m.clearTrigger;
+ if (m.weekColumnHeader) t.weekColumnHeader = m.weekColumnHeader;
+ if (m.weekNumber) t.weekNumberCell = (n: number) => formatWeek(m.weekNumber!, n);
+
+ if (m.openCalendar && m.closeCalendar) {
+ t.trigger = (open: boolean) => (open ? m.closeCalendar! : m.openCalendar!);
+ }
+
+ if (m.viewTriggerDay || m.viewTriggerMonth || m.viewTriggerYear) {
+ t.viewTrigger = (view: string) =>
+ pickViewLabel(
+ view as DatePickerView,
+ m.viewTriggerDay,
+ m.viewTriggerMonth,
+ m.viewTriggerYear
+ );
+ }
+ if (m.prevTriggerDay || m.prevTriggerMonth || m.prevTriggerYear) {
+ t.prevTrigger = (view: string) =>
+ pickViewLabel(
+ view as DatePickerView,
+ m.prevTriggerDay,
+ m.prevTriggerMonth,
+ m.prevTriggerYear
+ );
+ }
+ if (m.nextTriggerDay || m.nextTriggerMonth || m.nextTriggerYear) {
+ t.nextTrigger = (view: string) =>
+ pickViewLabel(
+ view as DatePickerView,
+ m.nextTriggerDay,
+ m.nextTriggerMonth,
+ m.nextTriggerYear
+ );
+ }
+
+ if (m.placeholderDay && m.placeholderMonth && m.placeholderYear) {
+ t.placeholder = () => ({
+ day: m.placeholderDay!,
+ month: m.placeholderMonth!,
+ year: m.placeholderYear!,
+ });
+ }
+
+ return t as unknown as ZagDatePickerTranslations;
+}
+
+export function applyInputAriaIfNeeded(
+ el: HTMLElement,
+ inputs: HTMLInputElement[],
+ selectionMode: string | undefined
+): void {
+ if (
+ selectionMode === "range" ||
+ el.querySelector('[data-scope="date-picker"][data-part="label"]')
+ ) {
+ return;
+ }
+ let tr: DatePickerMessageMap | null = null;
+ const raw = el.dataset.translation;
+ if (raw) {
+ try {
+ tr = JSON.parse(raw) as DatePickerMessageMap;
+ } catch {
+ tr = null;
+ }
+ }
+ const value = tr?.input;
+ if (!value) return;
+ for (const input of inputs) {
+ if (!input.getAttribute("aria-labelledby")) {
+ input.setAttribute("aria-label", value);
+ }
+ }
+}
+
export class DatePicker extends Component {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initMachine(props: Props): VanillaMachine {
@@ -9,7 +129,7 @@ export class DatePicker extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
private getDayView = () => this.el.querySelector('[data-part="day-view"]');
@@ -116,12 +236,30 @@ export class DatePicker extends Component {
);
if (control) this.spreadProps(control, this.api.getControlProps());
- const input = this.el.querySelector(
- '[data-scope="date-picker"][data-part="input"]'
+ const inputs = Array.from(
+ this.el.querySelectorAll('[data-scope="date-picker"][data-part="input"]')
);
- if (input) {
- this.spreadProps(input, this.api.getInputProps());
+ const selectionMode = this.api.selectionMode;
+ for (let i = 0; i < inputs.length; i += 1) {
+ this.spreadProps(inputs[i]!, this.api.getInputProps({ index: i }));
}
+ if (selectionMode === "multiple" && inputs.length > 0) {
+ const input = inputs[0]!;
+ const applyMultipleDisplay = () => {
+ const vs = this.api.valueAsString;
+ const parts = Array.isArray(vs) ? vs : vs == null || vs === "" ? [] : [String(vs)];
+ const joined = parts.filter(Boolean).join(", ");
+ if (input.value !== joined) {
+ input.value = joined;
+ }
+ };
+ applyMultipleDisplay();
+ queueMicrotask(() => {
+ requestAnimationFrame(applyMultipleDisplay);
+ });
+ }
+
+ applyInputAriaIfNeeded(this.el, inputs, this.api.selectionMode);
const trigger = this.el.querySelector(
'[data-scope="date-picker"][data-part="trigger"]'
diff --git a/assets/components/dialog.ts b/assets/components/dialog.ts
index 99cfceef..84afac53 100644
--- a/assets/components/dialog.ts
+++ b/assets/components/dialog.ts
@@ -1,6 +1,7 @@
import { connect, machine, type Props, type Api } from "@zag-js/dialog";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+import { stripHiddenFromProps } from "../lib/animation";
export class Dialog extends Component {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -9,11 +10,13 @@ export class Dialog extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
const rootEl = this.el;
+ const animation = rootEl.dataset.animation ?? "instant";
+ const open = this.api.open;
const triggerEl = rootEl.querySelector(
'[data-scope="dialog"][data-part="trigger"]'
@@ -23,7 +26,19 @@ export class Dialog extends Component {
const backdropEl = rootEl.querySelector(
'[data-scope="dialog"][data-part="backdrop"]'
);
- if (backdropEl) this.spreadProps(backdropEl, this.api.getBackdropProps());
+ if (backdropEl) {
+ const rawBackdrop = this.api.getBackdropProps() as Record;
+ if (animation === "instant") {
+ this.spreadProps(backdropEl, rawBackdrop);
+ } else {
+ this.spreadProps(backdropEl, stripHiddenFromProps(rawBackdrop));
+ if (open) {
+ backdropEl.removeAttribute("hidden");
+ } else if (rootEl.dataset.exitAnim !== "running") {
+ backdropEl.setAttribute("hidden", "");
+ }
+ }
+ }
const positionerEl = rootEl.querySelector(
'[data-scope="dialog"][data-part="positioner"]'
@@ -33,7 +48,19 @@ export class Dialog extends Component {
const contentEl = rootEl.querySelector(
'[data-scope="dialog"][data-part="content"]'
);
- if (contentEl) this.spreadProps(contentEl, this.api.getContentProps());
+ if (contentEl) {
+ const rawContent = this.api.getContentProps() as Record;
+ if (animation === "instant") {
+ this.spreadProps(contentEl, rawContent);
+ } else {
+ this.spreadProps(contentEl, stripHiddenFromProps(rawContent));
+ if (open) {
+ contentEl.removeAttribute("hidden");
+ } else if (rootEl.dataset.exitAnim !== "running") {
+ contentEl.setAttribute("hidden", "");
+ }
+ }
+ }
const titleEl = rootEl.querySelector('[data-scope="dialog"][data-part="title"]');
if (titleEl) this.spreadProps(titleEl, this.api.getTitleProps());
@@ -47,5 +74,30 @@ export class Dialog extends Component {
'[data-scope="dialog"][data-part="close-trigger"]'
);
if (closeTriggerEl) this.spreadProps(closeTriggerEl, this.api.getCloseTriggerProps());
+
+ if (animation !== "instant") {
+ if (rootEl.dataset.animInteractionLocked === "true") {
+ if (backdropEl) backdropEl.style.pointerEvents = "auto";
+ if (positionerEl) positionerEl.style.pointerEvents = "auto";
+ if (contentEl) contentEl.style.pointerEvents = "none";
+ } else {
+ if (contentEl) contentEl.style.removeProperty("pointer-events");
+
+ if (open) {
+ if (backdropEl) backdropEl.style.pointerEvents = "auto";
+ if (positionerEl) positionerEl.style.pointerEvents = "auto";
+ } else if (animation === "js") {
+ const pe: "auto" | "none" = rootEl.dataset.exitAnim === "running" ? "auto" : "none";
+ if (backdropEl) backdropEl.style.pointerEvents = pe;
+ if (positionerEl) positionerEl.style.pointerEvents = pe;
+ } else if (animation === "custom") {
+ if (backdropEl) backdropEl.style.pointerEvents = "none";
+ if (positionerEl) positionerEl.style.pointerEvents = "none";
+ } else {
+ if (backdropEl) backdropEl.style.pointerEvents = "none";
+ if (positionerEl) positionerEl.style.pointerEvents = "none";
+ }
+ }
+ }
}
}
diff --git a/assets/components/editable.ts b/assets/components/editable.ts
index 5bd24809..c57aaf80 100644
--- a/assets/components/editable.ts
+++ b/assets/components/editable.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/editable";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Editable extends Component {
@@ -9,7 +9,7 @@ export class Editable extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/floating-panel.ts b/assets/components/floating-panel.ts
index 3f57b7dc..7fd5c552 100644
--- a/assets/components/floating-panel.ts
+++ b/assets/components/floating-panel.ts
@@ -1,6 +1,6 @@
import { connect, machine, type Props, type Api } from "@zag-js/floating-panel";
import type { ResizeTriggerProps, StageTriggerProps } from "@zag-js/floating-panel";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class FloatingPanel extends Component {
@@ -10,7 +10,7 @@ export class FloatingPanel extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/listbox.ts b/assets/components/listbox.ts
index a1a04281..8ff8ce50 100644
--- a/assets/components/listbox.ts
+++ b/assets/components/listbox.ts
@@ -1,7 +1,9 @@
import { connect, machine, collection, type Props, type Api } from "@zag-js/listbox";
import type { ListCollection } from "@zag-js/collection";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+import { zagIdValueLabelCollectionConfig } from "../lib/list-collection";
+import { templatesContentRoot } from "../lib/util";
type Item = {
id?: string;
@@ -14,6 +16,7 @@ type Item = {
export class Listbox extends Component, Api> {
private _options: Item[] = [];
hasGroups: boolean = false;
+ private lastItemsFingerprint = "";
constructor(el: HTMLElement | null, props: Props- ) {
super(el, props);
@@ -30,6 +33,10 @@ export class Listbox extends Component, Api> {
this._options = Array.isArray(options) ? options : [];
}
+ private itemsFingerprint(): string {
+ return `${this.hasGroups}:${JSON.stringify(this.options)}`;
+ }
+
getOrderedGroupIds(): string[] {
const seen = new Set();
const ids: string[] = [];
@@ -44,22 +51,7 @@ export class Listbox extends Component, Api> {
}
getCollection(): ListCollection
- {
- const items = this.options;
- if (this.hasGroups) {
- return collection({
- items,
- itemToValue: (item) => item.id ?? item.value ?? "",
- itemToString: (item) => item.label,
- isItemDisabled: (item) => !!item.disabled,
- groupBy: (item: Item) => item.group ?? "",
- });
- }
- return collection({
- items,
- itemToValue: (item) => item.id ?? item.value ?? "",
- itemToString: (item) => item.label,
- isItemDisabled: (item) => !!item.disabled,
- });
+ return collection(zagIdValueLabelCollectionConfig(this.options, this.hasGroups));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -74,7 +66,7 @@ export class Listbox extends Component, Api> {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
init = (): void => {
@@ -92,29 +84,38 @@ export class Listbox extends Component, Api> {
);
if (!contentEl) return;
- const templatesContainer = this.el.querySelector('[data-templates="listbox"]');
- if (!templatesContainer) return;
+ const isOwnedByContent = (el: Element) =>
+ el.closest('[data-scope="listbox"][data-part="content"]') === contentEl;
- contentEl
- .querySelectorAll(
+ const templatesRoot = templatesContentRoot(this.el, "listbox");
+ if (!templatesRoot) return;
+
+ Array.from(
+ contentEl.querySelectorAll(
'[data-scope="listbox"][data-part="empty"]:not([data-template])'
)
+ )
+ .filter(isOwnedByContent)
.forEach((el) => el.remove());
- contentEl
- .querySelectorAll(
+ Array.from(
+ contentEl.querySelectorAll(
'[data-scope="listbox"][data-part="item-group"]:not([data-template])'
)
+ )
+ .filter(isOwnedByContent)
.forEach((el) => el.remove());
- contentEl
- .querySelectorAll(
+ Array.from(
+ contentEl.querySelectorAll(
'[data-scope="listbox"][data-part="item"]:not([data-template])'
)
+ )
+ .filter(isOwnedByContent)
.forEach((el) => el.remove());
const items = this.options;
if (items.length === 0) {
- const emptyTemplate = templatesContainer.querySelector(
+ const emptyTemplate = templatesRoot.querySelector(
'[data-scope="listbox"][data-part="empty"][data-template]'
);
if (emptyTemplate) {
@@ -125,7 +126,7 @@ export class Listbox extends Component, Api> {
} else if (this.hasGroups) {
const groupIds = this.getOrderedGroupIds();
for (const groupId of groupIds) {
- const template = templatesContainer.querySelector(
+ const template = templatesRoot.querySelector(
`[data-scope="listbox"][data-part="item-group"][data-id="${CSS.escape(groupId)}"][data-template]`
);
if (!template) continue;
@@ -139,7 +140,7 @@ export class Listbox extends Component, Api> {
} else {
for (const item of items) {
const value = String(item.id ?? item.value ?? "");
- const template = templatesContainer.querySelector(
+ const template = templatesRoot.querySelector(
`[data-scope="listbox"][data-part="item"][data-value="${value}"][data-template]`
);
if (!template) continue;
@@ -156,9 +157,13 @@ export class Listbox extends Component, Api> {
);
if (!contentEl) return;
+ const isOwnedByContent = (el: Element) =>
+ el.closest('[data-scope="listbox"][data-part="content"]') === contentEl;
+
contentEl
.querySelectorAll('[data-scope="listbox"][data-part="item-group"]')
.forEach((groupEl) => {
+ if (!isOwnedByContent(groupEl)) return;
const groupId = groupEl.dataset.id ?? "";
this.spreadProps(groupEl, this.api.getItemGroupProps({ id: groupId }));
const labelEl = groupEl.querySelector(
@@ -172,6 +177,7 @@ export class Listbox extends Component, Api> {
contentEl
.querySelectorAll('[data-scope="listbox"][data-part="item"]')
.forEach((itemEl) => {
+ if (!isOwnedByContent(itemEl)) return;
const value = itemEl.dataset.value ?? "";
const item = this.options.find((i) => String(i.id ?? i.value ?? "") === String(value));
if (!item) return;
@@ -199,11 +205,6 @@ export class Listbox extends Component, Api> {
const labelEl = this.el.querySelector('[data-scope="listbox"][data-part="label"]');
if (labelEl) this.spreadProps(labelEl, this.api.getLabelProps());
- const valueTextEl = this.el.querySelector(
- '[data-scope="listbox"][data-part="value-text"]'
- );
- if (valueTextEl) this.spreadProps(valueTextEl, this.api.getValueTextProps());
-
const inputEl = this.el.querySelector('[data-scope="listbox"][data-part="input"]');
if (inputEl) this.spreadProps(inputEl, this.api.getInputProps());
@@ -212,7 +213,11 @@ export class Listbox extends Component, Api> {
);
if (contentEl) {
this.spreadProps(contentEl, this.api.getContentProps());
- this.renderItems();
+ const fp = this.itemsFingerprint();
+ if (fp !== this.lastItemsFingerprint) {
+ this.lastItemsFingerprint = fp;
+ this.renderItems();
+ }
this.applyItemProps();
}
}
diff --git a/assets/components/marquee.ts b/assets/components/marquee.ts
index aff30f6e..83e01214 100644
--- a/assets/components/marquee.ts
+++ b/assets/components/marquee.ts
@@ -1,47 +1,115 @@
import { connect, machine, type Props, type Api } from "@zag-js/marquee";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Marquee extends Component {
- initMachine(props: Props): VanillaMachine {
+ private items: HTMLElement[] | null = null;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ initMachine(props: Props): VanillaMachine {
return new VanillaMachine(machine, props);
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
- render(): void {
- const rootEl =
- this.el.querySelector('[data-scope="marquee"][data-part="root"]') ?? this.el;
- this.spreadProps(rootEl, this.api.getRootProps());
+ buildDom(): void {
+ const ssrPreview = this.el.querySelector('[data-part="ssr-preview"]');
+ if (ssrPreview) ssrPreview.remove();
+ const templateEl = this.el.querySelector(
+ 'template[data-part="items-template"]'
+ );
+ if (!templateEl) return;
- const edgeStart = this.el.querySelector(
- '[data-scope="marquee"][data-part="edge"][data-side="start"]'
+ this.items = Array.from(templateEl.content.children).map(
+ (el) => el.cloneNode(true) as HTMLElement
);
+ templateEl.remove();
+
+ if (this.el.querySelector('[data-scope="marquee"][data-part="root"]')) {
+ return;
+ }
+
+ const root = document.createElement("div");
+ root.setAttribute("data-scope", "marquee");
+ root.setAttribute("data-part", "root");
+ root.id = `marquee:${this.el.id}`;
+ root.style.cssText =
+ "display:flex;flex-direction:row;position:relative;overflow:hidden;width:100%";
+ this.el.appendChild(root);
+
+ const edgeStart = document.createElement("div");
+ root.appendChild(edgeStart);
+ this.spreadProps(edgeStart, this.api.getEdgeProps({ side: "start" }));
+
+ const viewport = document.createElement("div");
+ viewport.setAttribute("data-scope", "marquee");
+ viewport.setAttribute("data-part", "viewport");
+ viewport.id = `marquee:${this.el.id}:viewport`;
+ viewport.style.cssText = "display:flex;width:100%";
+ root.appendChild(viewport);
+
+ const content = document.createElement("div");
+ content.setAttribute("data-scope", "marquee");
+ content.setAttribute("data-part", "content");
+ content.setAttribute("data-index", "0");
+ content.id = `marquee:${this.el.id}:content:0`;
+ content.style.cssText = "display:flex;flex-direction:row;flex-shrink:0";
+ viewport.appendChild(content);
+
+ this.items.forEach((itemEl) => {
+ content.appendChild(itemEl.cloneNode(true) as HTMLElement);
+ });
+
+ const edgeEnd = document.createElement("div");
+ root.appendChild(edgeEnd);
+ this.spreadProps(edgeEnd, this.api.getEdgeProps({ side: "end" }));
+ }
+
+ render(): void {
+ if (!this.items) return;
+
+ const root = this.el.querySelector('[data-scope="marquee"][data-part="root"]');
+ if (!root) return;
+ this.spreadProps(root, this.api.getRootProps());
+
+ const edgeStart = root.querySelector('[data-part="edge"][data-side="start"]');
if (edgeStart) this.spreadProps(edgeStart, this.api.getEdgeProps({ side: "start" }));
- const viewport = this.el.querySelector(
- '[data-scope="marquee"][data-part="viewport"]'
- );
- if (viewport) this.spreadProps(viewport, this.api.getViewportProps());
+ const viewport = root.querySelector('[data-part="viewport"]');
+ if (!viewport) return;
+ this.spreadProps(viewport, this.api.getViewportProps());
- const contentEls = this.el.querySelectorAll(
- '[data-scope="marquee"][data-part="content"]'
+ // Sync content count exactly like the official Zag example
+ const existingContents = Array.from(
+ viewport.querySelectorAll(':scope > [data-part="content"]')
);
- contentEls.forEach((contentEl, i) => {
+
+ // Remove excess
+ while (existingContents.length > this.api.contentCount) {
+ const el = existingContents.pop();
+ if (el) viewport.removeChild(el);
+ }
+
+ // Add missing or update existing
+ Array.from({ length: this.api.contentCount }).forEach((_, i) => {
+ let contentEl = existingContents[i];
+ if (!contentEl) {
+ contentEl = document.createElement("div");
+ viewport.appendChild(contentEl);
+ this.items!.forEach((itemEl) => {
+ const clone = itemEl.cloneNode(true) as HTMLElement;
+ contentEl.appendChild(clone);
+ });
+ }
this.spreadProps(contentEl, this.api.getContentProps({ index: i }));
- const itemEls = contentEl.querySelectorAll(
- '[data-scope="marquee"][data-part="item"]'
- );
- itemEls.forEach((itemEl) => {
+ contentEl.querySelectorAll('[data-part="item"]').forEach((itemEl) => {
this.spreadProps(itemEl, this.api.getItemProps());
});
});
- const edgeEnd = this.el.querySelector(
- '[data-scope="marquee"][data-part="edge"][data-side="end"]'
- );
+ const edgeEnd = root.querySelector('[data-part="edge"][data-side="end"]');
if (edgeEnd) this.spreadProps(edgeEnd, this.api.getEdgeProps({ side: "end" }));
}
}
diff --git a/assets/components/menu.ts b/assets/components/menu.ts
index 774c6d83..cd370993 100644
--- a/assets/components/menu.ts
+++ b/assets/components/menu.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/menu";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Menu extends Component {
@@ -11,7 +11,7 @@ export class Menu extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
setChild(child: Menu) {
diff --git a/assets/components/number-input.ts b/assets/components/number-input.ts
index 39e1f964..1f5b0692 100644
--- a/assets/components/number-input.ts
+++ b/assets/components/number-input.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/number-input";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class NumberInput extends Component {
@@ -9,7 +9,7 @@ export class NumberInput extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
@@ -47,14 +47,5 @@ export class NumberInput extends Component {
'[data-scope="number-input"][data-part="increment-trigger"]'
);
if (incrementEl) this.spreadProps(incrementEl, this.api.getIncrementTriggerProps());
-
- const scrubberEl = this.el.querySelector(
- '[data-scope="number-input"][data-part="scrubber"]'
- );
- if (scrubberEl) {
- this.spreadProps(scrubberEl, this.api.getScrubberProps());
- scrubberEl.setAttribute("aria-label", "Scrub to adjust value");
- scrubberEl.removeAttribute("role");
- }
}
}
diff --git a/assets/components/password-input.ts b/assets/components/password-input.ts
index 179e0c44..e65fe06a 100644
--- a/assets/components/password-input.ts
+++ b/assets/components/password-input.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/password-input";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class PasswordInput extends Component {
@@ -9,7 +9,7 @@ export class PasswordInput extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/pin-input.ts b/assets/components/pin-input.ts
index f1d93dc8..a0a0e782 100644
--- a/assets/components/pin-input.ts
+++ b/assets/components/pin-input.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/pin-input";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class PinInput extends Component {
@@ -9,7 +9,7 @@ export class PinInput extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/radio-group.ts b/assets/components/radio-group.ts
index 8a785e23..aba7a1a4 100644
--- a/assets/components/radio-group.ts
+++ b/assets/components/radio-group.ts
@@ -1,6 +1,6 @@
import { connect, machine, type Props, type Api } from "@zag-js/radio-group";
import type { ItemProps } from "@zag-js/radio-group";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class RadioGroup extends Component {
@@ -10,7 +10,7 @@ export class RadioGroup extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/select.ts b/assets/components/select.ts
index 765b9558..ffb13c31 100644
--- a/assets/components/select.ts
+++ b/assets/components/select.ts
@@ -1,7 +1,8 @@
import { connect, machine, collection, type Props, type Api } from "@zag-js/select";
import type { ListCollection } from "@zag-js/collection";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+import { zagIdValueLabelCollectionConfig } from "../lib/list-collection";
import { getString } from "../lib/util";
type Item = {
@@ -33,62 +34,37 @@ export class Select extends Component {
}
getCollection(): ListCollection
- {
- const items = this.options;
-
- if (this.hasGroups) {
- return collection({
- items: items,
- itemToValue: (item) => item.id ?? item.value ?? "",
- itemToString: (item) => item.label,
- isItemDisabled: (item) => !!item.disabled,
- groupBy: (item: Item) => item.group ?? "",
- });
- }
-
- return collection({
- items: items,
- itemToValue: (item) => item.id ?? item.value ?? "",
- itemToString: (item) => item.label,
- isItemDisabled: (item) => !!item.disabled,
- });
+ return collection(zagIdValueLabelCollectionConfig(this.options, this.hasGroups));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initMachine(props: Props): VanillaMachine {
const getCollection = this.getCollection.bind(this);
- const collectionFromProps = (props as Props & { collection?: ListCollection
- }).collection;
return new VanillaMachine(machine, {
...props,
get collection() {
- return collectionFromProps ?? getCollection();
+ return getCollection();
},
});
}
-
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
- init = (): void => {
- this.machine.start();
- this.render();
- this.machine.subscribe(() => {
- this.api = this.initApi();
- this.render();
- });
- this.el.removeAttribute("data-js");
- };
-
applyItemProps(): void {
const contentEl = this.el.querySelector(
'[data-scope="select"][data-part="content"]'
);
if (!contentEl) return;
+ const isOwnedByContent = (el: Element) =>
+ el.closest('[data-scope="select"][data-part="content"]') === contentEl;
+
contentEl
.querySelectorAll('[data-scope="select"][data-part="item-group"]')
.forEach((groupEl) => {
+ if (!isOwnedByContent(groupEl)) return;
const groupId = groupEl.dataset.id ?? "";
this.spreadProps(groupEl, this.api.getItemGroupProps({ id: groupId }));
const labelEl = groupEl.querySelector(
@@ -102,16 +78,22 @@ export class Select extends Component {
contentEl
.querySelectorAll('[data-scope="select"][data-part="item"]')
.forEach((itemEl) => {
+ if (!isOwnedByContent(itemEl)) return;
const value = itemEl.dataset.value ?? "";
+ if (!value) return;
+
const item = this.options.find((i) => String(i.id ?? i.value ?? "") === String(value));
if (!item) return;
+
this.spreadProps(itemEl, this.api.getItemProps({ item }));
+
const textEl = itemEl.querySelector(
'[data-scope="select"][data-part="item-text"]'
);
if (textEl) {
this.spreadProps(textEl, this.api.getItemTextProps({ item }));
}
+
const indicatorEl = itemEl.querySelector(
'[data-scope="select"][data-part="item-indicator"]'
);
@@ -127,23 +109,17 @@ export class Select extends Component {
this.spreadProps(root, this.api.getRootProps());
- const hiddenSelect = this.el.querySelector(
- '[data-scope="select"][data-part="hidden-select"]'
- );
-
const valueInput = this.el.querySelector(
'[data-scope="select"][data-part="value-input"]'
);
if (valueInput) {
- if (!this.api.value || this.api.value.length === 0) {
- valueInput.value = "";
- } else if (this.api.value.length === 1) {
- valueInput.value = String(this.api.value[0]);
- } else {
- valueInput.value = this.api.value.map(String).join(",");
- }
+ const valueStr = this.api.value?.length ? this.api.value.map(String).join(",") : "";
+ valueInput.value = valueStr;
}
+ const hiddenSelect = this.el.querySelector(
+ '[data-scope="select"][data-part="hidden-select"]'
+ );
if (hiddenSelect) {
this.spreadProps(hiddenSelect, this.api.getHiddenSelectProps());
}
@@ -175,13 +151,9 @@ export class Select extends Component {
const itemValue = item.id ?? item.value ?? "";
return String(itemValue) === String(selectedValue);
});
- if (selectedItem) {
- valueText.textContent = selectedItem.label;
- } else {
- valueText.textContent = this.placeholder || "";
- }
+ valueText.textContent = selectedItem?.label || this.placeholder;
} else {
- valueText.textContent = valueAsString || this.placeholder || "";
+ valueText.textContent = valueAsString || this.placeholder;
}
}
diff --git a/assets/components/signature-pad.ts b/assets/components/signature-pad.ts
index 24384eb2..0be81c17 100644
--- a/assets/components/signature-pad.ts
+++ b/assets/components/signature-pad.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/signature-pad";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class SignaturePad extends Component {
@@ -22,7 +22,7 @@ export class SignaturePad extends Component {
}
initApi() {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
syncPaths = () => {
@@ -40,7 +40,9 @@ export class SignaturePad extends Component {
const hiddenInput = this.el.querySelector(
'[data-scope="signature-pad"][data-part="hidden-input"]'
);
- if (hiddenInput) hiddenInput.value = "";
+ if (hiddenInput && hiddenInput.value !== "") {
+ hiddenInput.value = "";
+ }
return;
}
@@ -104,7 +106,7 @@ export class SignaturePad extends Component {
this.spreadProps(
hiddenInput,
this.api.getHiddenInputProps({
- value: this.api.paths.length > 0 ? JSON.stringify(this.api.paths) : "",
+ value: this.api.paths.length > 0 ? this.api.paths.join("\n") : "",
})
);
}
diff --git a/assets/components/switch.ts b/assets/components/switch.ts
index efbe1ceb..d898660b 100644
--- a/assets/components/switch.ts
+++ b/assets/components/switch.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/switch";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Switch extends Component {
@@ -9,7 +9,7 @@ export class Switch extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/tabs.ts b/assets/components/tabs.ts
index 80f90ad3..4708baba 100644
--- a/assets/components/tabs.ts
+++ b/assets/components/tabs.ts
@@ -1,15 +1,32 @@
import { connect, machine, type Props, type Api } from "@zag-js/tabs";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine, type Attrs } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+function tabsDomIds(rootId: string): NonNullable {
+ return {
+ root: `tabs-${rootId}-root`,
+ list: `tabs-${rootId}-list`,
+ indicator: `tabs-${rootId}-indicator`,
+ content: (value: string) => `tabs-${rootId}-content-${value}`,
+ trigger: (value: string) => `tabs-${rootId}-trigger-${value}`,
+ };
+}
+
export class Tabs extends Component {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initMachine(props: Props): VanillaMachine {
- return new VanillaMachine(machine, props);
+ const id = props.id ?? this.el.id;
+ return new VanillaMachine(machine, { ...props, id, ids: tabsDomIds(id) });
}
+ updateProps = (attrs: Attrs) => {
+ const props = attrs as Props;
+ const id = props.id ?? this.el.id;
+ this.machine.updateProps({ ...props, id, ids: tabsDomIds(id) });
+ };
+
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
@@ -17,36 +34,35 @@ export class Tabs extends Component {
if (!rootEl) return;
this.spreadProps(rootEl, this.api.getRootProps());
- const listEl = rootEl.querySelector('[data-scope="tabs"][data-part="list"]');
+ const listEl = rootEl.querySelector(
+ ':scope > [data-scope="tabs"][data-part="list"]'
+ );
if (!listEl) return;
this.spreadProps(listEl, this.api.getListProps());
- const itemsData = this.el.getAttribute("data-items");
- const items: Array<{ value: string; disabled: boolean }> = itemsData
- ? JSON.parse(itemsData)
- : [];
-
const triggers = listEl.querySelectorAll(
- '[data-scope="tabs"][data-part="trigger"]'
+ ':scope > [data-scope="tabs"][data-part="trigger"]'
);
- for (let i = 0; i < triggers.length && i < items.length; i++) {
- const triggerEl = triggers[i];
- const item = items[i];
- this.spreadProps(
- triggerEl,
- this.api.getTriggerProps({ value: item.value, disabled: item.disabled })
- );
- }
+ triggers.forEach((triggerEl) => {
+ const value = triggerEl.dataset.value;
+ const disabled = triggerEl.dataset.disabled == "";
+
+ if (!value) return;
+
+ this.spreadProps(triggerEl, this.api.getTriggerProps({ value, disabled }));
+ });
const contents = rootEl.querySelectorAll(
- '[data-scope="tabs"][data-part="content"]'
+ ':scope > [data-scope="tabs"][data-part="content"]'
);
- for (let i = 0; i < contents.length && i < items.length; i++) {
- const contentEl = contents[i];
- const item = items[i];
- this.spreadProps(contentEl, this.api.getContentProps({ value: item.value }));
- }
+ contents.forEach((contentEl) => {
+ const value = contentEl.dataset.value;
+
+ if (!value) return;
+
+ this.spreadProps(contentEl, this.api.getContentProps({ value }));
+ });
}
}
diff --git a/assets/components/timer.ts b/assets/components/timer.ts
index 89187811..9b18cac2 100644
--- a/assets/components/timer.ts
+++ b/assets/components/timer.ts
@@ -1,6 +1,6 @@
import { connect, machine, type Props, type Api } from "@zag-js/timer";
import type { ItemProps, ActionTriggerProps } from "@zag-js/timer";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
export class Timer extends Component {
@@ -10,7 +10,7 @@ export class Timer extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
init = (): void => {
diff --git a/assets/components/toast.ts b/assets/components/toast.ts
index 59247a1d..1d9539df 100644
--- a/assets/components/toast.ts
+++ b/assets/components/toast.ts
@@ -11,22 +11,20 @@ import {
type StoreProps,
type Options,
} from "@zag-js/toast";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
-import { generateId } from "../lib/util";
+import { getDir } from "../lib/util";
export const toastGroups = new Map();
export const toastStores = new Map();
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type ToastItemProps = Props & {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- parent: any;
+type ToastItemProps = Props & {
+ parent: unknown;
index: number;
+ meta?: { loading?: boolean };
};
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export class ToastItem extends Component, Api> {
+export class ToastItem extends Component, Api> {
private parts!: {
title: HTMLElement;
description: HTMLElement;
@@ -36,43 +34,44 @@ export class ToastItem extends Component, Api> {
progressbar: HTMLElement;
loadingSpinner: HTMLElement;
};
+
duration?: number | string;
+ showLoading: boolean;
constructor(el: HTMLElement, props: ToastItemProps) {
super(el, props);
this.duration = props.duration;
+ this.showLoading = props.meta?.loading === true;
this.el.setAttribute("data-scope", "toast");
this.el.setAttribute("data-part", "root");
+ this.el.classList.add("toast-item");
this.el.innerHTML = `
-
-
-
`;
this.parts = {
- title: this.el.querySelector('[data-scope="toast"][data-part="title"]')!,
- description: this.el.querySelector('[data-scope="toast"][data-part="description"]')!,
- close: this.el.querySelector('[data-scope="toast"][data-part="close-trigger"]')!,
- ghostBefore: this.el.querySelector('[data-scope="toast"][data-part="ghost-before"]')!,
- ghostAfter: this.el.querySelector('[data-scope="toast"][data-part="ghost-after"]')!,
- progressbar: this.el.querySelector('[data-scope="toast"][data-part="progressbar"]')!,
- loadingSpinner: this.el.querySelector('[data-scope="toast"][data-part="loading-spinner"]')!,
+ title: this.el.querySelector('[data-part="title"]')!,
+ description: this.el.querySelector('[data-part="description"]')!,
+ close: this.el.querySelector('[data-part="close-trigger"]')!,
+ ghostBefore: this.el.querySelector('[data-part="ghost-before"]')!,
+ ghostAfter: this.el.querySelector('[data-part="ghost-after"]')!,
+ progressbar: this.el.querySelector('[data-part="progressbar"]')!,
+ loadingSpinner: this.el.querySelector('[data-part="loading-spinner"]')!,
};
}
@@ -82,16 +81,42 @@ export class ToastItem extends Component, Api> {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render() {
this.spreadProps(this.el, this.api.getRootProps());
this.spreadProps(this.parts.close, this.api.getCloseTriggerProps());
-
this.spreadProps(this.parts.ghostBefore, this.api.getGhostBeforeProps());
this.spreadProps(this.parts.ghostAfter, this.api.getGhostAfterProps());
+ const toastGroup = this.el.closest('[phx-hook="Toast"]') as HTMLElement;
+
+ // templates
+ const loadingIconTemplate = toastGroup?.querySelector(
+ "[data-loading-icon-template]"
+ ) as HTMLElement;
+
+ const closeIconTemplate = toastGroup?.querySelector(
+ "[data-close-icon-template]"
+ ) as HTMLElement;
+
+ const loadingIcon = loadingIconTemplate?.innerHTML;
+ const closeIcon = closeIconTemplate?.innerHTML;
+
+ // inject close icon
+ if (closeIcon) {
+ if (this.parts.close.innerHTML !== closeIcon) {
+ this.parts.close.innerHTML = closeIcon;
+ }
+ } else {
+ // fallback
+ if (!this.parts.close.innerHTML) {
+ this.parts.close.innerHTML = "×";
+ }
+ }
+
+ // text updates
if (this.parts.title.textContent !== this.api.title) {
this.parts.title.textContent = this.api.title ?? "";
}
@@ -106,23 +131,22 @@ export class ToastItem extends Component, Api> {
const duration = this.duration;
const isInfinity =
duration === "Infinity" || duration === Infinity || duration === Number.POSITIVE_INFINITY;
- const toastGroup = this.el.closest('[phx-hook="Toast"]') as HTMLElement;
- const loadingIconTemplate = toastGroup?.querySelector(
- "[data-loading-icon-template]"
- ) as HTMLElement;
- const loadingIcon = loadingIconTemplate?.innerHTML;
if (isInfinity) {
this.parts.progressbar.style.display = "none";
- this.parts.loadingSpinner.style.display = "flex";
this.el.setAttribute("data-duration-infinity", "true");
+ } else {
+ this.parts.progressbar.style.display = "block";
+ this.el.removeAttribute("data-duration-infinity");
+ }
+
+ if (this.showLoading) {
+ this.parts.loadingSpinner.style.display = "flex";
if (loadingIcon && this.parts.loadingSpinner.innerHTML !== loadingIcon) {
this.parts.loadingSpinner.innerHTML = loadingIcon;
}
} else {
- this.parts.progressbar.style.display = "block";
this.parts.loadingSpinner.style.display = "none";
- this.el.removeAttribute("data-duration-infinity");
}
}
@@ -159,7 +183,7 @@ export class ToastGroup extends Component {
}
initApi(): GroupApi {
- return group.connect(this.machine.service, normalizeProps);
+ return this.zagConnect(group.connect);
}
render() {
@@ -176,6 +200,7 @@ export class ToastGroup extends Component {
if (!item) {
const el = document.createElement("div");
+ el.classList.add("toast-item");
el.setAttribute("data-scope", "toast");
el.setAttribute("data-part", "root");
this.groupEl.appendChild(el);
@@ -190,6 +215,8 @@ export class ToastGroup extends Component {
this.toastComponents.set(toastData.id, item);
} else {
item.duration = toastData.duration;
+ (item as ToastItem).showLoading =
+ (toastData as { meta?: { loading?: boolean } }).meta?.loading === true;
item.updateProps({
...toastData,
parent: this.machine.service,
@@ -222,7 +249,7 @@ export function createToastGroup(
store?: Store;
}
) {
- const groupId = options?.id ?? generateId(container, "toast");
+ const groupId = options?.id ?? container.id;
const store =
options?.store ??
@@ -235,7 +262,7 @@ export function createToastGroup(
pauseOnPageIdle: options?.pauseOnPageIdle,
});
- const group = new ToastGroup(container, { id: groupId, store });
+ const group = new ToastGroup(container, { id: groupId, store, dir: getDir(container) });
group.init();
toastGroups.set(groupId, group);
@@ -247,6 +274,17 @@ export function createToastGroup(
return { group, store };
}
+export function disposeToastGroup(groupId: string) {
+ const group = toastGroups.get(groupId);
+ if (!group) return;
+ const container = group.el;
+ group.destroy();
+ toastGroups.delete(groupId);
+ toastStores.delete(groupId);
+ delete container.dataset.toastGroup;
+ delete container.dataset.toastGroupId;
+}
+
export function getToastStore(groupId?: string): Store | undefined {
if (groupId) return toastStores.get(groupId);
@@ -257,14 +295,11 @@ export function getToastStore(groupId?: string): Store | undefined {
return id ? toastStores.get(id) : undefined;
}
-export function createToast(options: Options & { groupId?: string }) {
- const store = getToastStore(options.groupId);
+export function createToast(options: Options & { id: string; groupId?: string }) {
+ const { groupId, ...rest } = options;
+ const store = getToastStore(groupId);
if (!store) throw new Error("No toast store found");
-
- store.create({
- ...options,
- id: options.id ?? generateId(undefined, "toast"),
- });
+ store.create(rest);
}
export function updateToast(id: string, options: Partial, groupId?: string) {
diff --git a/assets/components/toggle-group.ts b/assets/components/toggle-group.ts
index 8fac7d09..eb605a78 100644
--- a/assets/components/toggle-group.ts
+++ b/assets/components/toggle-group.ts
@@ -1,5 +1,5 @@
import { connect, machine, type Props, type Api } from "@zag-js/toggle-group";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
import { getString, getBoolean } from "../lib/util";
@@ -10,7 +10,7 @@ export class ToggleGroup extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
render(): void {
diff --git a/assets/components/tooltip.ts b/assets/components/tooltip.ts
new file mode 100644
index 00000000..be8f847d
--- /dev/null
+++ b/assets/components/tooltip.ts
@@ -0,0 +1,42 @@
+import { connect, machine, type Props, type Api } from "@zag-js/tooltip";
+import { VanillaMachine } from "@zag-js/vanilla";
+import { Component } from "../lib/core";
+
+export class Tooltip extends Component {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ initMachine(props: Props): VanillaMachine {
+ return new VanillaMachine(machine, props);
+ }
+
+ initApi(): Api {
+ return this.zagConnect(connect);
+ }
+
+ render(): void {
+ const rootEl = this.el;
+
+ const triggerEl = rootEl.querySelector(
+ '[data-scope="tooltip"][data-part="trigger"]'
+ );
+
+ if (triggerEl) this.spreadProps(triggerEl, this.api.getTriggerProps());
+
+ const positionerEl = rootEl.querySelector(
+ '[data-scope="tooltip"][data-part="positioner"]'
+ );
+ if (positionerEl) this.spreadProps(positionerEl, this.api.getPositionerProps());
+
+ const contentEl = rootEl.querySelector(
+ '[data-scope="tooltip"][data-part="content"]'
+ );
+ if (contentEl) this.spreadProps(contentEl, this.api.getContentProps());
+
+ const arrowEl = rootEl.querySelector('[data-scope="tooltip"][data-part="arrow"]');
+ if (arrowEl) this.spreadProps(arrowEl, this.api.getArrowProps());
+
+ const arrowTipEl = rootEl.querySelector(
+ '[data-scope="tooltip"][data-part="arrow-tip"]'
+ );
+ if (arrowTipEl) this.spreadProps(arrowTipEl, this.api.getArrowTipProps());
+ }
+}
diff --git a/assets/components/tree-view.ts b/assets/components/tree-view.ts
index f2eef703..05d20744 100644
--- a/assets/components/tree-view.ts
+++ b/assets/components/tree-view.ts
@@ -1,69 +1,40 @@
import { collection, connect, machine, type Props, type Api } from "@zag-js/tree-view";
-import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
+import { VanillaMachine } from "@zag-js/vanilla";
import { Component } from "../lib/core";
+import { stripHiddenFromProps } from "../lib/animation";
export interface TreeNode {
id: string;
name: string;
children?: TreeNode[];
- redirect?: boolean;
+ to?: string;
+ redirect?: "href" | "patch" | "navigate" | false;
new_tab?: boolean;
+ disabled?: boolean;
}
-function buildTreeFromDOM(rootEl: HTMLElement): TreeNode {
- const selector =
- '[data-scope="tree-view"][data-part="branch"], [data-scope="tree-view"][data-part="item"]';
- const elements = rootEl.querySelectorAll(selector);
- const nodes: Array<{
- pathArr: number[];
- id: string;
- name: string;
- isBranch: boolean;
- }> = [];
- for (const el of elements) {
- const pathRaw = el.getAttribute("data-path");
- const value = el.getAttribute("data-value");
- if (pathRaw == null || value == null) continue;
- const pathArr = pathRaw.split("/").map((s) => parseInt(s, 10));
- if (pathArr.some(Number.isNaN)) continue;
- const name = el.getAttribute("data-name") ?? value;
- const isBranch = el.getAttribute("data-part") === "branch";
- nodes.push({ pathArr, id: value, name, isBranch });
- }
- nodes.sort((a, b) => {
- const len = Math.min(a.pathArr.length, b.pathArr.length);
- for (let i = 0; i < len; i++) {
- if (a.pathArr[i] !== b.pathArr[i]) return a.pathArr[i] - b.pathArr[i];
- }
- return a.pathArr.length - b.pathArr.length;
+function createTreeCollection(rootNode: TreeNode) {
+ return collection({
+ nodeToValue: (node) => node.id,
+ nodeToString: (node) => node.name,
+ rootNode,
});
- const root: TreeNode = { id: "ROOT", name: "", children: [] };
- for (const { pathArr, id, name, isBranch } of nodes) {
- let parent: TreeNode = root;
- for (let i = 0; i < pathArr.length - 1; i++) {
- const idx = pathArr[i];
- if (!parent.children) parent.children = [];
- parent = parent.children[idx] as TreeNode;
- }
- const lastIdx = pathArr[pathArr.length - 1]!;
- if (!parent.children) parent.children = [];
- parent.children[lastIdx] = isBranch ? { id, name, children: [] } : { id, name };
- }
- return root;
}
export class TreeView extends Component {
private treeCollection: ReturnType>;
- constructor(el: HTMLElement | null, props: Omit & { treeData?: TreeNode }) {
- const treeData = props.treeData ?? buildTreeFromDOM(el as HTMLElement);
- const treeCollection = collection({
- nodeToValue: (node) => node.id,
- nodeToString: (node) => node.name,
- rootNode: treeData,
- });
- super(el, { ...props, collection: treeCollection } as Props);
+ constructor(el: HTMLElement | null, props: Omit & { rootNode: TreeNode }) {
+ const { rootNode, ...rest } = props;
+ const treeCollection = createTreeCollection(rootNode);
+ super(el, { ...rest, collection: treeCollection } as Props);
+ this.treeCollection = treeCollection;
+ }
+
+ replaceRootNode(rootNode: TreeNode): void {
+ const treeCollection = createTreeCollection(rootNode);
this.treeCollection = treeCollection;
+ this.updateProps({ collection: treeCollection });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -72,7 +43,7 @@ export class TreeView extends Component {
}
initApi(): Api {
- return connect(this.machine.service, normalizeProps);
+ return this.zagConnect(connect);
}
private getNodeAt(indexPath: number[]): TreeNode | undefined {
@@ -88,10 +59,16 @@ export class TreeView extends Component {
private updateExistingTree(treeEl: HTMLElement) {
this.spreadProps(treeEl, this.api.getTreeProps());
+ const animation = this.el.dataset.animation ?? "js";
+
+ const isOwnedByTree = (el: Element) =>
+ el.closest('[data-scope="tree-view"][data-part="tree"]') === treeEl;
+
const branches = treeEl.querySelectorAll(
'[data-scope="tree-view"][data-part="branch"]'
);
for (const branchEl of branches) {
+ if (!isOwnedByTree(branchEl)) continue;
const pathRaw = branchEl.getAttribute("data-path");
if (pathRaw == null) continue;
const indexPath = pathRaw.split("/").map((s) => parseInt(s, 10));
@@ -119,7 +96,18 @@ export class TreeView extends Component {
const contentEl = branchEl.querySelector(
'[data-scope="tree-view"][data-part="branch-content"]'
);
- if (contentEl) this.spreadProps(contentEl, this.api.getBranchContentProps(nodeProps));
+ if (contentEl) {
+ const contentPropsRaw = this.api.getBranchContentProps(nodeProps);
+ if (animation === "instant") {
+ this.spreadProps(contentEl, contentPropsRaw);
+ } else {
+ this.spreadProps(
+ contentEl,
+ stripHiddenFromProps(contentPropsRaw as Record)
+ );
+ contentEl.removeAttribute("hidden");
+ }
+ }
const indentGuideEl = branchEl.querySelector(
'[data-scope="tree-view"][data-part="branch-indent-guide"]'
@@ -132,6 +120,7 @@ export class TreeView extends Component {
'[data-scope="tree-view"][data-part="item"]'
);
for (const itemEl of items) {
+ if (!isOwnedByTree(itemEl)) continue;
const pathRaw = itemEl.getAttribute("data-path");
if (pathRaw == null) continue;
const indexPath = pathRaw.split("/").map((s) => parseInt(s, 10));
@@ -139,6 +128,17 @@ export class TreeView extends Component {
if (!node) continue;
const nodeProps = { indexPath, node };
this.spreadProps(itemEl, this.api.getItemProps(nodeProps));
+
+ const itemTextEl = itemEl.querySelector(
+ '[data-scope="tree-view"][data-part="item-text"]'
+ );
+ if (itemTextEl) this.spreadProps(itemTextEl, this.api.getItemTextProps(nodeProps));
+
+ const itemIndicatorEl = itemEl.querySelector(
+ '[data-scope="tree-view"][data-part="item-indicator"]'
+ );
+ if (itemIndicatorEl)
+ this.spreadProps(itemIndicatorEl, this.api.getItemIndicatorProps(nodeProps));
}
}
diff --git a/assets/hooks/accordion.ts b/assets/hooks/accordion.ts
index 40afbc1c..90389225 100644
--- a/assets/hooks/accordion.ts
+++ b/assets/hooks/accordion.ts
@@ -1,21 +1,45 @@
import type { Hook } from "phoenix_live_view";
-import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook";
+import type { HookInterface } from "phoenix_live_view/assets/js/types/view_hook";
import { Accordion } from "../components/accordion";
-import type { ValueChangeDetails, FocusChangeDetails, Props } from "@zag-js/accordion";
+import type { ValueChangeDetails, FocusChangeDetails, Props, ItemProps } from "@zag-js/accordion";
import type { Orientation } from "@zag-js/types";
import { getString, getBoolean, getStringList, getDir, canPushEvent } from "../lib/util";
+import {
+ readHeightAnimationOptions,
+ prepareInitialHeightState,
+ runOpenStateTransitionsHeight,
+} from "../lib/animation";
+import {
+ parseRespondTo,
+ emitResponse,
+ idMatches,
+ readPayloadId,
+ notifyChange,
+ type RespondTo,
+} from "../lib/respond-to";
+import { createHookHandleEventRegistry } from "../lib/hook-handlers";
+import { createDomEventRegistry } from "../lib/dom-events";
+import { type AccordionChangedDetail, diffStringValues } from "../lib/event-details";
type AccordionHookState = {
accordion?: Accordion;
- handlers?: Array;
- onSetValue?: (event: Event) => void;
+ handleRegistry?: ReturnType;
+ domRegistry?: ReturnType;
+ lastValue?: string[];
+ previousValue?: string[];
};
const AccordionHook: Hook